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 ? ( +
+