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
+1270
-97
No files matched your search
@@ -2,9 +2,9 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
InfoIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-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 { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -28,6 +29,7 @@ type SettingsSection =
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "notification"
|
||||
| "about";
|
||||
|
||||
@@ -67,12 +69,14 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
icon: BrainIcon,
|
||||
},
|
||||
{ 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 },
|
||||
],
|
||||
[
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
t.settings.sections.notification,
|
||||
t.settings.sections.about,
|
||||
],
|
||||
@@ -123,6 +127,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "skills" && <SkillSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
CheckCircle2,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
@@ -41,15 +45,14 @@ import {
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useEnableSkill,
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
@@ -59,6 +62,7 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
@@ -88,34 +92,99 @@ function SkillSettingsList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("public");
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const { mutate: enableSkill } = useEnableSkill();
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.category === filter),
|
||||
[skills, filter],
|
||||
);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// Visible-to-me skills, classified into the three buckets the UI cares about.
|
||||
// "All" = built-in + anyone-published-custom + mine (active state only).
|
||||
// "Mine" = strictly my own custom skills (active state only).
|
||||
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 = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Tabs defaultValue="public" onValueChange={setFilter}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
||||
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
</TabsList>
|
||||
</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 className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -152,9 +221,6 @@ function SkillSettingsList({
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
onToggleEnabled={(enabled) =>
|
||||
enableSkill({ skillName: skill.name, enabled })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<InstallSkillDialog
|
||||
@@ -162,6 +228,18 @@ function SkillSettingsList({
|
||||
onClose={() => setInstallOpen(false)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<SkillStateSheet
|
||||
kind="stale"
|
||||
open={staleSheetOpen}
|
||||
onOpenChange={setStaleSheetOpen}
|
||||
skills={staleVisible}
|
||||
/>
|
||||
<SkillStateSheet
|
||||
kind="archived"
|
||||
open={archiveSheetOpen}
|
||||
onOpenChange={setArchiveSheetOpen}
|
||||
skills={archivedVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -170,15 +248,14 @@ function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
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 (
|
||||
<>
|
||||
<Item className="w-full" variant="outline">
|
||||
@@ -223,7 +312,7 @@ function SkillRow({
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
{isBuiltin && isCustom && (
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
</Badge>
|
||||
@@ -261,13 +350,70 @@ function SkillRow({
|
||||
{t.settings.skills.badgePublished}
|
||||
</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>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
</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>
|
||||
<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 && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -299,11 +445,6 @@ function SkillRow({
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onCheckedChange={(checked) => onToggleEnabled(checked)}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-all">
|
||||
{record.message}
|
||||
{record.message === "run_started"
|
||||
? "——— 本次策展开始 ———"
|
||||
: record.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -446,14 +446,66 @@ export const enUS: Translations = {
|
||||
badgeFromOthersTitle: "Owned by user {user_id}",
|
||||
badgePublished: "Published",
|
||||
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",
|
||||
unpublishAction: "Unpublish",
|
||||
publishSuccess: "Skill published",
|
||||
unpublishSuccess: "Skill unpublished",
|
||||
pinAction: "Pin (prevent auto-archive)",
|
||||
unpinAction: "Unpin",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
deleteAction: "Delete skill",
|
||||
deleteConfirm:
|
||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||
deleteSuccess: "Skill deleted",
|
||||
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: {
|
||||
title: "Install skill",
|
||||
dropHint: "Drop a .zip or .skill archive here, or",
|
||||
|
||||
@@ -375,13 +375,63 @@ export interface Translations {
|
||||
badgeFromOthersTitle: string;
|
||||
badgePublished: 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;
|
||||
unpublishAction: string;
|
||||
publishSuccess: string;
|
||||
unpublishSuccess: string;
|
||||
pinAction: string;
|
||||
unpinAction: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
deleteAction: string;
|
||||
deleteConfirm: 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: {
|
||||
title: string;
|
||||
dropHint: string;
|
||||
|
||||
@@ -427,13 +427,63 @@ export const zhCN: Translations = {
|
||||
badgeFromOthersTitle: "来自用户 {user_id}",
|
||||
badgePublished: "已发布",
|
||||
badgePublishedTitle: "该技能已对所有用户可见",
|
||||
badgeStale: "过时",
|
||||
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
||||
badgeArchived: "归档",
|
||||
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||
badgePinned: "钉选",
|
||||
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||
badgeSourceAgent: "Agent 创建",
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||
tabAll: "全部",
|
||||
tabMine: "我的",
|
||||
entryStale: "过时 {count}",
|
||||
entryArchived: "归档 {count}",
|
||||
useCountInline: "调用 {count} 次",
|
||||
useCountInlineZero: "尚未调用",
|
||||
publishAction: "发布",
|
||||
unpublishAction: "取消发布",
|
||||
publishSuccess: "已发布",
|
||||
unpublishSuccess: "已取消发布",
|
||||
pinAction: "钉选(防止被自动归档)",
|
||||
unpinAction: "取消钉选",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
|
||||
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: {
|
||||
title: "安装技能",
|
||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
export async function loadSkills() {
|
||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
||||
@@ -138,6 +138,42 @@ export async function setSkillPublished(
|
||||
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> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
installSkillUpload,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
published?: boolean;
|
||||
builtin?: boolean;
|
||||
}
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
published?: boolean;
|
||||
builtin?: boolean;
|
||||
// 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)}")
|
||||
|
||||
|
||||
_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")
|
||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||
try:
|
||||
|
||||
+17
-8
@@ -191,14 +191,23 @@ def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
|
||||
return ""
|
||||
return """
|
||||
## Skill Self-Evolution
|
||||
After completing a task, consider creating or updating a skill when:
|
||||
- The task required 5+ tool calls to resolve
|
||||
- You overcame non-obvious errors or pitfalls
|
||||
- The user corrected your approach and the corrected version worked
|
||||
- You discovered a non-trivial, recurring workflow
|
||||
If you used a skill and encountered issues not covered by it, patch it immediately.
|
||||
Prefer patch over edit. Before creating a new skill, confirm with the user first.
|
||||
Skip simple one-off tasks.
|
||||
|
||||
You have access to `skill_manage` (plus `skill_list` / `skill_view`).
|
||||
|
||||
After completing a non-trivial task — 5+ tool calls to resolve, recovery from
|
||||
a non-obvious error, a corrected approach that worked, or a recurring
|
||||
workflow — **offer to save the approach as a reusable skill** so you can
|
||||
replay it next time. Briefly propose a hyphen-case name and a one-line
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Coroutine
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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:
|
||||
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]:
|
||||
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}
|
||||
except Exception:
|
||||
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:
|
||||
return await store.get_any(skill_name)
|
||||
|
||||
record = asyncio.run(_run())
|
||||
record = _run_async_blocking(_run())
|
||||
return bool(record and record.get("published", False))
|
||||
except Exception:
|
||||
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,
|
||||
}
|
||||
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
for name, entry in eligible.items():
|
||||
if entry.get("pinned"):
|
||||
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:
|
||||
set_state(name, STATE_ARCHIVED)
|
||||
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:
|
||||
set_state(name, STATE_STALE)
|
||||
counters["marked_stale"] += 1
|
||||
curator_log.record(
|
||||
"info",
|
||||
f" 标记过时 [{name}]:已闲置 {days_idle} 天(阈值 {stale_days} 天)",
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return counters
|
||||
|
||||
@@ -351,9 +414,19 @@ _CURATOR_REVIEW_PROMPT = (
|
||||
"Hard rules — do not violate:\n"
|
||||
"1. DO NOT touch public (built-in) skills. The candidate list below "
|
||||
"is already filtered to agent-created custom skills only.\n"
|
||||
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
|
||||
"into the .archive/ folder under the skills root) is the maximum "
|
||||
"destructive action. Archives are recoverable; deletion is not.\n"
|
||||
"2. DO NOT delete any skill. Archiving (a soft state flip via the "
|
||||
"skill_archive tool — the file stays in custom/<name>/ and the user "
|
||||
"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"
|
||||
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
||||
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
||||
@@ -425,8 +498,8 @@ _CURATOR_REVIEW_PROMPT = (
|
||||
" - name: <skill-name>\n"
|
||||
" reason: <one short sentence — why archived with no merge target>\n"
|
||||
"```\n\n"
|
||||
"Every skill you moved to .archive/ MUST appear in exactly one of the "
|
||||
"two lists. If you consolidated X into umbrella Y (patched Y, wrote "
|
||||
"Every skill you archived via skill_archive MUST appear in exactly one "
|
||||
"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 "
|
||||
"goes under `consolidations` with `into: Y`. If you archived X with "
|
||||
"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):
|
||||
"""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 deerflow.skills import curator_log
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
@lc_tool("skill_archive")
|
||||
def _skill_archive(name: str) -> str:
|
||||
"""Move an agent-created skill to the .archive/ directory (recoverable). Use this instead of delete.
|
||||
def _skill_archive(name: str, reason: str) -> str:
|
||||
"""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:
|
||||
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
|
||||
|
||||
# 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()
|
||||
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."
|
||||
if get_entry(name).get("pinned"):
|
||||
curator_log.record("info", f"skill_archive: '{name}' is pinned — skipping", user_id=user_id)
|
||||
entry = get_entry(name)
|
||||
if entry.get("pinned"):
|
||||
curator_log.record("info", f"归档:技能 [{name}] 已被钉选,跳过", user_id=user_id)
|
||||
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
|
||||
# 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):
|
||||
curator_log.record(
|
||||
"warning",
|
||||
f"skill_archive: '{name}' is published — refused (curator must not touch published skills)",
|
||||
f"归档:已拒绝处理 [{name}],该技能已发布(策展不允许操作已发布的技能)",
|
||||
user_id=user_id,
|
||||
)
|
||||
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:
|
||||
curator_log.record(
|
||||
"warning",
|
||||
f"skill_archive: '{name}' is referenced by an agent's skills config — refused",
|
||||
f"归档:已拒绝处理 [{name}],该技能正被某个 Agent 引用",
|
||||
user_id=user_id,
|
||||
)
|
||||
return f"Refused: skill '{name}' is still referenced by one or more agents."
|
||||
|
||||
skill_dir = storage.get_custom_skill_dir(name)
|
||||
archive_root = Path(storage.get_skills_root_path()) / "custom" / ".archive"
|
||||
archive_root.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
dest = archive_root / f"{name}-{stamp}"
|
||||
# Soft archive: flip the lifecycle state. The skill directory stays in
|
||||
# ``custom/<name>/`` untouched so the user can recover it from the
|
||||
# Web UI's archive drawer (which lists skills by state, not by location).
|
||||
try:
|
||||
skill_dir.rename(dest)
|
||||
set_state(name, STATE_ARCHIVED)
|
||||
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}"
|
||||
set_state(name, STATE_ARCHIVED)
|
||||
curator_log.record("info", f"Archived '{name}' → custom/.archive/{dest.name}", user_id=user_id)
|
||||
return f"Archived '{name}' → custom/.archive/{dest.name}."
|
||||
curator_log.record(
|
||||
"info",
|
||||
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
|
||||
|
||||
@@ -501,18 +595,25 @@ def _run_llm_review(candidate_list: str, *, user_id: str) -> str:
|
||||
from deerflow.skills import curator_log
|
||||
|
||||
try:
|
||||
from langchain.agents import create_agent
|
||||
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.tools.builtins.skill_tools import skill_list_tool, skill_view_tool
|
||||
from deerflow.tools.skill_manage_tool import skill_manage_tool
|
||||
|
||||
model = create_chat_model()
|
||||
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}"
|
||||
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):
|
||||
content = getattr(msg, "content", "")
|
||||
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
|
||||
if isinstance(content, list):
|
||||
text = " ".join(
|
||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||
for block in 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 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 ""
|
||||
except Exception as 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 ""
|
||||
|
||||
|
||||
@@ -563,16 +664,16 @@ def run_curator_review(user_id: str) -> dict:
|
||||
|
||||
now = datetime.now(UTC)
|
||||
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)
|
||||
logger.info("Curator[%s]: transitions=%s", user_id, transition_counters)
|
||||
curator_log.record(
|
||||
"info",
|
||||
(
|
||||
f"Age-based transitions: checked={transition_counters['checked']}, "
|
||||
f"marked_stale={transition_counters['marked_stale']}, "
|
||||
f"archived={transition_counters['archived']}"
|
||||
f"按年龄自动流转完成:已检查 {transition_counters['checked']} 个,"
|
||||
f"标记陈旧 {transition_counters['marked_stale']} 个,"
|
||||
f"自动归档 {transition_counters['archived']} 个"
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
@@ -580,7 +681,7 @@ def run_curator_review(user_id: str) -> dict:
|
||||
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("- ")])
|
||||
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)
|
||||
@@ -588,36 +689,82 @@ def run_curator_review(user_id: str) -> dict:
|
||||
curator_log.record(
|
||||
"info",
|
||||
(
|
||||
f"LLM suggestions parsed: consolidations={len(suggestions['consolidations'])}, "
|
||||
f"prunings={len(suggestions['prunings'])}"
|
||||
f"LLM 建议已解析:建议合并 {len(suggestions['consolidations'])} 项,"
|
||||
f"建议删减 {len(suggestions['prunings'])} 项"
|
||||
),
|
||||
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["last_run_at"] = now.isoformat()
|
||||
state["run_count"] = state.get("run_count", 0) + 1
|
||||
state["last_run_summary"] = (
|
||||
f"auto: {transition_counters['marked_stale']} marked stale, "
|
||||
f"{transition_counters['archived']} archived; "
|
||||
f"llm: {len(suggestions['consolidations'])} consolidations suggested"
|
||||
f"自动流转:标记陈旧 {transition_counters['marked_stale']} 个、"
|
||||
f"归档 {transition_counters['archived']} 个;"
|
||||
f"LLM:建议合并 {len(suggestions['consolidations'])} 项"
|
||||
)
|
||||
state["last_consolidation_suggestions"] = suggestions
|
||||
_write_curator_state(state, user_id)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
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:
|
||||
run_curator_review(user_id)
|
||||
except Exception as exc:
|
||||
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:
|
||||
|
||||
+69
-5
@@ -5,11 +5,60 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from langchain.tools import tool
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
|
||||
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")
|
||||
def skill_view_tool(name: str) -> str:
|
||||
"""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")
|
||||
def skill_list_tool() -> str:
|
||||
"""List all available skills with metadata and usage stats. Does NOT read SKILL.md content.
|
||||
def skill_list_tool(runtime: ToolRuntime) -> str:
|
||||
"""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,
|
||||
use_count, view_count, patch_count, last_activity_at, pinned, source.
|
||||
For a **custom agent**, the result is filtered to skills explicitly
|
||||
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.usage import all_entries, latest_activity_at
|
||||
|
||||
agent_id = _resolve_active_agent_id(runtime)
|
||||
allowed = _load_agent_skill_whitelist(agent_id)
|
||||
|
||||
try:
|
||||
storage = get_or_new_skill_storage()
|
||||
skills = storage.load_skills(enabled_only=False)
|
||||
usage = all_entries()
|
||||
result = []
|
||||
for skill in skills:
|
||||
if allowed is not None and skill.name not in allowed:
|
||||
continue
|
||||
entry = usage.get(skill.name, {})
|
||||
result.append({
|
||||
"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.thread_state import ThreadState
|
||||
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.storage import get_or_new_skill_storage
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
runtime: ToolRuntime[ContextT, ThreadState],
|
||||
action: str,
|
||||
@@ -124,6 +205,13 @@ async def _skill_manage_impl(
|
||||
await _to_thread(mark_agent_created, name)
|
||||
except Exception:
|
||||
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()
|
||||
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)."
|
||||
|
||||
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(
|
||||
skill_storage.delete_custom_skill,
|
||||
name,
|
||||
@@ -181,6 +278,36 @@ async def _skill_manage_impl(
|
||||
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()
|
||||
return f"Deleted custom skill '{name}'."
|
||||
|
||||
|
||||
Reference in new issue
Block a user