From f61d053010642346062fd04395b5d850841495d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 18 May 2026 10:03:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(skills):=20=E6=96=B0=E5=A2=9E=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E8=AF=A6=E6=83=85=E5=AD=97=E6=AE=B5=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=8F=91=E5=B8=83=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技能新增 detail 字段(skills 表新增列 + Alembic 迁移), 由管理员或技能发布者编辑,内置/公开技能仅管理员可编辑 - 新增 PUT /api/skills/{name}/detail 接口,GET/列表接口返回 detail - 新增可复用的技能详情弹窗组件,支持 Markdown 渲染与内联编辑 - 智能体创建/编辑选技能、智能体卡片技能徽章均可查看技能详情 - 将定时任务/智能体/技能的发布图标统一为 Globe/Lock Co-Authored-By: Claude Opus 4.7 (1M context) --- .../workspace/agents/agent-card.tsx | 59 +++-- .../settings/skill-settings-page.tsx | 53 +---- .../workspace/skills/skill-detail-dialog.tsx | 213 ++++++++++++++++++ frontend-web/src/core/i18n/locales/en-US.ts | 10 + frontend-web/src/core/i18n/locales/types.ts | 8 + frontend-web/src/core/i18n/locales/zh-CN.ts | 8 + frontend-web/src/core/skills/api.ts | 37 +++ frontend-web/src/core/skills/hooks.ts | 22 ++ frontend-web/src/core/skills/type.ts | 3 + frontend-web/src/pages/NewAgentPage.tsx | 49 +++- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../backend/app/gateway/routers/skills.py | 84 ++++++- .../versions/20260518_01_skill_detail.py | 42 ++++ .../deerflow/persistence/skills/memory.py | 6 + .../deerflow/persistence/skills/model.py | 7 +- .../deerflow/persistence/skills/sql.py | 6 + .../backend/tests/test_skill_detail.py | 162 +++++++++++++ 17 files changed, 694 insertions(+), 77 deletions(-) create mode 100644 frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260518_01_skill_detail.py create mode 100644 offline-backend-20260512/backend/tests/test_skill_detail.py diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index dfe62108..59dc4c06 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -2,8 +2,9 @@ import { BotIcon, - EyeIcon, - EyeOffIcon, + GlobeIcon, + InfoIcon, + LockIcon, MessageSquareIcon, SquarePenIcon, TagsIcon, @@ -34,6 +35,7 @@ import { import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { useDeleteAgent, useUpdateAgent } from "@/core/agents"; @@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) { const [selectedSkills, setSelectedSkills] = useState( agent.skills ?? [], ); + const [detailSkillName, setDetailSkillName] = useState(null); const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; @@ -204,6 +207,7 @@ export function AgentCard({ agent }: AgentCardProps) { className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs" title="该智能体已对所有用户可见" > + 已发布 )} @@ -238,7 +242,12 @@ export function AgentCard({ agent }: AgentCardProps) { { + e.stopPropagation(); + setDetailSkillName(skill); + }} > {skill} @@ -275,9 +284,9 @@ export function AgentCard({ agent }: AgentCardProps) { title={agent.published ? "取消发布" : "发布"} > {agent.published ? ( - + ) : ( - + )} )} @@ -353,17 +362,31 @@ export function AgentCard({ agent }: AgentCardProps) { {enabledSkills.map((skill) => { const checked = selectedSkills.includes(skill.name); return ( - + + + ); })} @@ -445,6 +468,14 @@ export function AgentCard({ agent }: AgentCardProps) { currentTags={agent.tags ?? []} /> )} + + { + if (!o) setDetailSkillName(null); + }} + /> ); } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index b5b5056a..96ff5207 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -5,10 +5,10 @@ import { ArchiveIcon, CheckCircle2, ClockIcon, - EyeIcon, - EyeOffIcon, FileArchive, + GlobeIcon, Loader2, + LockIcon, PinIcon, PinOffIcon, SparklesIcon, @@ -47,6 +47,7 @@ import { ItemDescription, } from "@/components/ui/item"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; @@ -58,7 +59,6 @@ import { useInstallSkillUpload, useSetSkillPinned, useSetSkillPublished, - useSkillContent, useSkills, useValidateSkillUpload, } from "@/core/skills/hooks"; @@ -358,6 +358,7 @@ function SkillRow({ className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs" title={t.settings.skills.badgePublishedTitle} > + {t.settings.skills.badgePublished} )} @@ -453,9 +454,9 @@ function SkillRow({ } > {skill.published ? ( - + ) : ( - + )} )} @@ -508,7 +509,7 @@ function SkillRow({ /> )} @@ -516,46 +517,6 @@ function SkillRow({ ); } -function SkillDetailDialog({ - skill, - open, - onOpenChange, -}: { - skill: Skill; - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const { t } = useI18n(); - const { data, isLoading, error } = useSkillContent(open ? skill.name : null); - - return ( - - - - {skill.name} - {skill.description} - -
- {isLoading ? ( -
- - {t.common.loading} -
- ) : error ? ( -
- {error instanceof Error ? error.message : String(error)} -
- ) : ( -
-              {data?.content}
-            
- )} -
-
-
- ); -} - function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { const { t } = useI18n(); return ( diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx new file mode 100644 index 00000000..a731c087 --- /dev/null +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { Loader2, PencilIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { getStoredAuth } from "@/core/auth"; +import { useI18n } from "@/core/i18n/hooks"; +import { + useSetSkillDetail, + useSkill, + useSkillContent, +} from "@/core/skills/hooks"; +import type { Skill } from "@/core/skills/type"; +import { reasoningPlugins } from "@/core/streamdown"; + +/** + * Whether the current user may edit a skill's detail text. + * + * Admins can edit any skill; the publisher (owner of a custom skill) can edit + * their own. Built-in skills have no human owner, so only admins qualify. + * Mirrors the backend authz in `PUT /api/skills/{name}/detail`. + */ +function canEditSkillDetail(skill: Skill | null | undefined): boolean { + if (!skill) return false; + const auth = getStoredAuth(); + if (!auth) return false; + if (auth.system_role === "admin") return true; + return ( + skill.category === "custom" && + !!skill.owner_user_id && + skill.owner_user_id === auth.user_id + ); +} + +/** + * Reusable skill detail dialog. Given a skill name it fetches the skill + * metadata (including the admin/owner-authored `detail`) and the raw SKILL.md + * source, and lets authorized users edit the detail inline. + */ +export function SkillDetailDialog({ + skillName, + open, + onOpenChange, +}: { + skillName: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const tx = t.settings.skills; + // Only fetch while the dialog is open to avoid background requests. + const activeName = open ? skillName : null; + const { data: skill, isLoading: isSkillLoading } = useSkill(activeName); + const { + data: contentData, + isLoading: isContentLoading, + error: contentError, + } = useSkillContent(activeName); + const setDetail = useSetSkillDetail(); + + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [showSource, setShowSource] = useState(false); + + // Reset transient UI whenever the dialog closes or switches to another skill. + useEffect(() => { + if (!open) { + setEditing(false); + setShowSource(false); + } + }, [open, skillName]); + + const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]); + + function startEditing() { + setDraft(skill?.detail ?? ""); + setEditing(true); + } + + async function handleSave() { + if (!skill) return; + try { + await setDetail.mutateAsync({ skillName: skill.name, detail: draft }); + toast.success(tx.detailSaveSuccess); + setEditing(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + + return ( + + + + {skill?.name ?? skillName ?? ""} + {skill?.description} + + +
+ {/* Detail — human-authored long-form introduction */} +
+
+

{tx.detailSectionTitle}

+ {canEdit && !editing && ( + + )} +
+ {editing ? ( +
+