This commit is contained in:
xuhd committed 2026-05-18 10:22:23 +08:00
commit 817585a4ce
17 files changed
+694 -77

No files matched your search

@@ -2,8 +2,9 @@
import { import {
BotIcon, BotIcon,
EyeIcon, GlobeIcon,
EyeOffIcon, InfoIcon,
LockIcon,
MessageSquareIcon, MessageSquareIcon,
SquarePenIcon, SquarePenIcon,
TagsIcon, TagsIcon,
@@ -34,6 +35,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents"; import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
@@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const [selectedSkills, setSelectedSkills] = useState<string[]>( const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [], agent.skills ?? [],
); );
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const storedAuth = useMemo(() => getStoredAuth(), []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; 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" className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见" title="该智能体已对所有用户可见"
> >
<GlobeIcon className="size-3" />
已发布 已发布
</Badge> </Badge>
)} )}
@@ -238,7 +242,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<Badge <Badge
key={`sk:${skill}`} key={`sk:${skill}`}
variant="secondary" variant="secondary"
className="text-xs" className="hover:bg-secondary/70 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
> >
{skill} {skill}
</Badge> </Badge>
@@ -275,9 +284,9 @@ export function AgentCard({ agent }: AgentCardProps) {
title={agent.published ? "取消发布" : "发布"} title={agent.published ? "取消发布" : "发布"}
> >
{agent.published ? ( {agent.published ? (
<EyeOffIcon className="h-3.5 w-3.5" /> <LockIcon className="h-3.5 w-3.5" />
) : ( ) : (
<EyeIcon className="h-3.5 w-3.5" /> <GlobeIcon className="h-3.5 w-3.5" />
)} )}
</Button> </Button>
)} )}
@@ -353,17 +362,31 @@ export function AgentCard({ agent }: AgentCardProps) {
{enabledSkills.map((skill) => { {enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name); const checked = selectedSkills.includes(skill.name);
return ( return (
<Button <div
key={skill.name} key={skill.name}
type="button" className="inline-flex max-w-full items-center"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={skill.description}
className="h-8"
> >
{skill.name} <Button
</Button> type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={skill.description}
className="h-8 max-w-[200px] rounded-r-none"
>
<span className="truncate">{skill.name}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
); );
})} })}
</div> </div>
@@ -445,6 +468,14 @@ export function AgentCard({ agent }: AgentCardProps) {
currentTags={agent.tags ?? []} currentTags={agent.tags ?? []}
/> />
)} )}
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
</> </>
); );
} }
@@ -5,10 +5,10 @@ import {
ArchiveIcon, ArchiveIcon,
CheckCircle2, CheckCircle2,
ClockIcon, ClockIcon,
EyeIcon,
EyeOffIcon,
FileArchive, FileArchive,
GlobeIcon,
Loader2, Loader2,
LockIcon,
PinIcon, PinIcon,
PinOffIcon, PinOffIcon,
SparklesIcon, SparklesIcon,
@@ -47,6 +47,7 @@ import {
ItemDescription, ItemDescription,
} from "@/components/ui/item"; } from "@/components/ui/item";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
@@ -58,7 +59,6 @@ import {
useInstallSkillUpload, useInstallSkillUpload,
useSetSkillPinned, useSetSkillPinned,
useSetSkillPublished, useSetSkillPublished,
useSkillContent,
useSkills, useSkills,
useValidateSkillUpload, useValidateSkillUpload,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
@@ -358,6 +358,7 @@ function SkillRow({
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs" className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle} title={t.settings.skills.badgePublishedTitle}
> >
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished} {t.settings.skills.badgePublished}
</Badge> </Badge>
)} )}
@@ -453,9 +454,9 @@ function SkillRow({
} }
> >
{skill.published ? ( {skill.published ? (
<EyeOffIcon className="size-3.5" /> <LockIcon className="size-3.5" />
) : ( ) : (
<EyeIcon className="size-3.5" /> <GlobeIcon className="size-3.5" />
)} )}
</Button> </Button>
)} )}
@@ -508,7 +509,7 @@ function SkillRow({
/> />
)} )}
<SkillDetailDialog <SkillDetailDialog
skill={skill} skillName={skill.name}
open={detailOpen} open={detailOpen}
onOpenChange={setDetailOpen} onOpenChange={setDetailOpen}
/> />
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{skill.description}</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive p-4 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : (
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{data?.content}
</pre>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill?.name ?? skillName ?? ""}</DialogTitle>
<DialogDescription>{skill?.description}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-auto pr-1">
{/* Detail — human-authored long-form introduction */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{tx.detailSectionTitle}</h3>
{canEdit && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
{tx.detailEditAction}
</Button>
)}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={tx.detailPlaceholder}
className="max-h-[320px] min-h-40 resize-y overflow-y-auto text-sm"
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setEditing(false)}
disabled={setDetail.isPending}
>
{t.common.cancel}
</Button>
<Button
size="sm"
onClick={() => void handleSave()}
disabled={setDetail.isPending}
>
{setDetail.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
{t.common.save}
</Button>
</div>
</div>
) : isSkillLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : skill?.detail ? (
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<MarkdownContent
content={skill.detail}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{tx.detailEmpty}
{!canEdit ? ` ${tx.detailAdminOnlyHint}` : ""}
</p>
)}
</section>
{/* SKILL.md source — collapsed by default */}
<section className="space-y-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-sm font-medium"
onClick={() => setShowSource((v) => !v)}
>
{showSource ? "▾ " : "▸ "}
{tx.contentSectionTitle}
</button>
{showSource && (
<div className="bg-muted/40 overflow-auto rounded-md border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/90 max-h-[320px] overflow-auto whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{contentData?.content}
</pre>
)}
</div>
)}
</section>
</div>
</DialogContent>
</Dialog>
);
}
@@ -474,6 +474,16 @@ export const enUS: Translations = {
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",
detailSectionTitle: "Details",
detailEmpty: "No details yet.",
detailEditAction: "Edit details",
detailPlaceholder:
"Describe the skill's purpose, use cases and usage notes (Markdown supported).",
detailSaveSuccess: "Details updated",
detailAdminOnlyHint:
"Only an admin or the skill publisher can edit the details.",
contentSectionTitle: "SKILL.md source",
viewDetailAction: "View skill details",
stateSheet: { stateSheet: {
archivedTitle: "Archived skills", archivedTitle: "Archived skills",
archivedDescription: archivedDescription:
@@ -405,6 +405,14 @@ export interface Translations {
deleteAction: string; deleteAction: string;
deleteConfirm: string; deleteConfirm: string;
deleteSuccess: string; deleteSuccess: string;
detailSectionTitle: string;
detailEmpty: string;
detailEditAction: string;
detailPlaceholder: string;
detailSaveSuccess: string;
detailAdminOnlyHint: string;
contentSectionTitle: string;
viewDetailAction: string;
stateSheet: { stateSheet: {
archivedTitle: string; archivedTitle: string;
archivedDescription: string; archivedDescription: string;
@@ -454,6 +454,14 @@ export const zhCN: Translations = {
deleteAction: "删除技能", deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。", deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除", deleteSuccess: "技能已删除",
detailSectionTitle: "详情",
detailEmpty: "暂无详情说明。",
detailEditAction: "编辑详情",
detailPlaceholder: "用 Markdown 补充技能的用途、适用场景、使用说明等。",
detailSaveSuccess: "详情已更新",
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
contentSectionTitle: "SKILL.md 原文",
viewDetailAction: "查看技能详情",
stateSheet: { stateSheet: {
archivedTitle: "归档的技能", archivedTitle: "归档的技能",
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。", archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
+37
View File
@@ -20,6 +20,16 @@ export async function loadSkills(params: ListSkillsParams = {}) {
return json.skills as Skill[]; return json.skills as Skill[];
} }
export async function getSkill(skillName: string): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill");
}
return response.json() as Promise<Skill>;
}
export async function enableSkill(skillName: string, enabled: boolean) { export async function enableSkill(skillName: string, enabled: boolean) {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${skillName}`, `${getBackendBaseURL()}/api/skills/${skillName}`,
@@ -212,3 +222,30 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
throw await parseDetailedError(response, "Failed to delete skill"); throw await parseDetailedError(response, "Failed to delete skill");
} }
} }
export interface SetSkillDetailResponse {
name: string;
detail: string | null;
}
/**
* Update a skill's long-form detail text. Owner/admin for custom skills,
* admin-only for built-in skills (backend enforces). An empty string clears it.
*/
export async function setSkillDetail(
skillName: string,
detail: string,
): Promise<SetSkillDetailResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/detail`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ detail }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill detail");
}
return response.json() as Promise<SetSkillDetailResponse>;
}
+22
View File
@@ -8,8 +8,10 @@ import {
import { import {
deleteCustomSkill, deleteCustomSkill,
enableSkill, enableSkill,
getSkill,
getSkillContent, getSkillContent,
installSkillUpload, installSkillUpload,
setSkillDetail,
setSkillPinned, setSkillPinned,
setSkillPublished, setSkillPublished,
setSkillState, setSkillState,
@@ -38,6 +40,26 @@ export function useSkillContent(skillName: string | null) {
}); });
} }
export function useSkill(skillName: string | null) {
return useQuery({
queryKey: ["skill", skillName],
queryFn: () => getSkill(skillName!),
enabled: !!skillName,
});
}
export function useSetSkillDetail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
setSkillDetail(skillName, detail),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useEnableSkill() { export function useEnableSkill() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+3
View File
@@ -14,6 +14,9 @@ export interface Skill {
owner_user_id?: string | null; owner_user_id?: string | null;
published?: boolean; published?: boolean;
builtin?: boolean; builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null;
// Lifecycle fields — populated by curator. The main list filters on these // Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead. // so stale / archived skills appear in dedicated drawers instead.
state?: SkillState; state?: SkillState;
+37 -12
View File
@@ -3,6 +3,7 @@
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CheckCircleIcon, CheckCircleIcon,
InfoIcon,
SaveIcon, SaveIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -24,6 +25,7 @@ import {
type SetupAgentApprovalPayload, type SetupAgentApprovalPayload,
} from "@/components/workspace/messages"; } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
import { import {
@@ -73,6 +75,7 @@ export default function NewAgentPage() {
const [isSavingDraft, setIsSavingDraft] = useState(false); const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]); const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [threadPrepared, setThreadPrepared] = useState(false); const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills(); const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels(); const { models, isLoading: isModelsLoading } = useModels();
@@ -448,20 +451,34 @@ export default function NewAgentPage() {
{enabledSkills.map((skill) => { {enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name); const checked = selectedSkills.includes(skill.name);
return ( return (
<Button <div
key={skill.name} key={skill.name}
type="button" className="inline-flex max-w-full items-center"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-full",
!checked && "text-muted-foreground",
)}
> >
<span className="truncate">{skill.name}</span> <Button
</Button> type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-[200px] rounded-r-none",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
); );
})} })}
</div> </div>
@@ -469,6 +486,14 @@ export default function NewAgentPage() {
</div> </div>
</div> </div>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
{formError ? ( {formError ? (
<p className="text-destructive text-sm">{formError}</p> <p className="text-destructive text-sm">{formError}</p>
) : null} ) : null}
+1 -1
View File
@@ -214,7 +214,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|--------|-----------| |--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details | | **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) | | **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags`); `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) | | **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) | | **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. | | **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
@@ -96,6 +96,7 @@ class SkillResponse(BaseModel):
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)") owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users") published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)") builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill") tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
@@ -180,10 +181,12 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
owner_user_id: str | None = None owner_user_id: str | None = None
published = False published = False
builtin = skill.category != SkillCategory.CUSTOM builtin = skill.category != SkillCategory.CUSTOM
detail: str | None = None
if ownership is not None: if ownership is not None:
owner_user_id = ownership.get("owner_user_id") owner_user_id = ownership.get("owner_user_id")
published = bool(ownership.get("published", False)) published = bool(ownership.get("published", False))
builtin = bool(ownership.get("builtin", owner_user_id is None)) builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail")
return SkillResponse( return SkillResponse(
name=skill.name, name=skill.name,
@@ -201,6 +204,7 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
published=published, published=published,
builtin=builtin, builtin=builtin,
detail=detail,
) )
@@ -251,7 +255,9 @@ async def list_skills(
# Public/built-in skills (no DB row) are visible to everyone. # Public/built-in skills (no DB row) are visible to everyone.
# Custom skills must have an ownership record in the visible set. # Custom skills must have an ownership record in the visible set.
if skill.category != SkillCategory.CUSTOM: if skill.category != SkillCategory.CUSTOM:
responses.append(_skill_to_response(skill)) # Built-in skills usually have no DB row, but an admin-authored
# ``detail`` annotation materializes one — surface it when present.
responses.append(_skill_to_response(skill, ownership.get(skill.name)))
continue continue
record = ownership.get(skill.name) record = ownership.get(skill.name)
if record is None: if record is None:
@@ -828,7 +834,12 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest,
summary="Get Skill Details", summary="Get Skill Details",
description="Retrieve detailed information about a specific skill by its name.", description="Retrieve detailed information about a specific skill by its name.",
) )
async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillResponse: async def get_skill(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillResponse:
try: try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "") skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
@@ -837,7 +848,9 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
if skill is None: if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
return _skill_to_response(skill) # Attach ownership (owner/published/detail) when the caller may see it.
ownership = await skill_store.get_visible(skill_name, _current_user_id(request))
return _skill_to_response(skill, ownership)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -926,6 +939,71 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
class SkillDetailRequest(BaseModel):
"""Request body for setting a skill's human-authored detail text."""
detail: str = Field(default="", description="详情说明文本,支持 Markdown;传空字符串表示清空")
class SkillDetailResponse(BaseModel):
"""Response model for the skill-detail update endpoint."""
name: str = Field(..., description="Skill name")
detail: str | None = Field(default=None, description="Persisted detail text (null when cleared)")
@router.put(
"/skills/{skill_name}/detail",
response_model=SkillDetailResponse,
summary="Update Skill Detail",
description="设置技能的详情说明。自定义技能由其所有者或管理员编辑;内置/公开技能仅管理员可编辑。",
)
async def update_skill_detail(
skill_name: str,
body: SkillDetailRequest,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillDetailResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
user_id = _current_user_id(request)
is_admin = _is_admin_user(request)
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")
# Empty / whitespace-only input clears the annotation.
detail_value: str | None = body.detail.strip() or None
if skill.category == SkillCategory.CUSTOM:
# Owner edits their own skill; admins may edit anyone's.
_record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
if mode == "admin":
updated = await skill_store.update_admin(skill_name, {"detail": detail_value})
else:
updated = await skill_store.update(skill_name, user_id, {"detail": detail_value})
else:
# Built-in / public skill — only admins may annotate it. These carry
# no DB row by default, so materialize one before writing detail.
if not is_admin:
raise HTTPException(status_code=403, detail="Only admin users can edit built-in skill details")
if await skill_store.get_any(skill_name) is None:
await skill_store.ensure_legacy({"name": skill_name, "owner_user_id": None, "published": True})
updated = await skill_store.update_admin(skill_name, {"detail": detail_value})
if updated is None:
raise HTTPException(status_code=500, detail="Failed to persist skill detail")
return SkillDetailResponse(name=skill_name, detail=updated.get("detail"))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update detail for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update skill detail: {str(e)}")
class SkillPinRequest(BaseModel): class SkillPinRequest(BaseModel):
pinned: bool = Field(..., description="Whether to pin or unpin the skill") pinned: bool = Field(..., description="Whether to pin or unpin the skill")
@@ -0,0 +1,42 @@
"""Add detail column to skills table.
Revision ID: 20260518_01
Revises: 20260515_01
Create Date: 2026-05-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260518_01"
down_revision = "20260515_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
# ``skills`` may not exist yet on a brand-new DB — create_all handles that
# case with the column already present, so only patch existing tables.
if _has_table("skills") and not _has_column("skills", "detail"):
op.add_column("skills", sa.Column("detail", sa.Text(), nullable=True))
def downgrade() -> None:
if _has_column("skills", "detail"):
op.drop_column("skills", "detail")
@@ -69,6 +69,7 @@ class MemorySkillStore(SkillStore):
"name": name, "name": name,
"owner_user_id": data.get("owner_user_id"), "owner_user_id": data.get("owner_user_id"),
"published": bool(data.get("published", False)), "published": bool(data.get("published", False)),
"detail": data.get("detail"),
"created_at": _now(), "created_at": _now(),
"updated_at": _now(), "updated_at": _now(),
} }
@@ -83,6 +84,7 @@ class MemorySkillStore(SkillStore):
"name": name, "name": name,
"owner_user_id": data.get("owner_user_id"), "owner_user_id": data.get("owner_user_id"),
"published": bool(data.get("published", True)), "published": bool(data.get("published", True)),
"detail": data.get("detail"),
"created_at": _now(), "created_at": _now(),
"updated_at": _now(), "updated_at": _now(),
} }
@@ -98,6 +100,8 @@ class MemorySkillStore(SkillStore):
return None return None
if "published" in data: if "published" in data:
record["published"] = bool(data["published"]) record["published"] = bool(data["published"])
if "detail" in data:
record["detail"] = data["detail"]
record["updated_at"] = _now() record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type] return _with_derived(_copy(record)) # type: ignore[arg-type]
@@ -109,6 +113,8 @@ class MemorySkillStore(SkillStore):
record["published"] = bool(data["published"]) record["published"] = bool(data["published"])
if "owner_user_id" in data: if "owner_user_id" in data:
record["owner_user_id"] = data["owner_user_id"] record["owner_user_id"] = data["owner_user_id"]
if "detail" in data:
record["detail"] = data["detail"]
record["updated_at"] = _now() record["updated_at"] = _now()
return _with_derived(_copy(record)) # type: ignore[arg-type] return _with_derived(_copy(record)) # type: ignore[arg-type]
@@ -10,7 +10,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, String from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
@@ -24,5 +24,10 @@ class SkillRow(Base):
name: Mapped[str] = mapped_column(String(64), primary_key=True) name: Mapped[str] = mapped_column(String(64), primary_key=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# Human-authored long-form detail/introduction, editable by the skill owner
# or an admin. Distinct from the skill ``description`` parsed from SKILL.md;
# kept in the DB so admins can annotate built-in skills without rewriting
# the shared on-disk SKILL.md file.
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -63,6 +63,7 @@ class SkillRepository(SkillStore):
name=data["name"], name=data["name"],
owner_user_id=data.get("owner_user_id"), owner_user_id=data.get("owner_user_id"),
published=bool(data.get("published", False)), published=bool(data.get("published", False)),
detail=data.get("detail"),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -81,6 +82,7 @@ class SkillRepository(SkillStore):
name=data["name"], name=data["name"],
owner_user_id=data.get("owner_user_id"), owner_user_id=data.get("owner_user_id"),
published=bool(data.get("published", True)), published=bool(data.get("published", True)),
detail=data.get("detail"),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -101,6 +103,8 @@ class SkillRepository(SkillStore):
return None return None
if "published" in data: if "published" in data:
row.published = bool(data["published"]) row.published = bool(data["published"])
if "detail" in data:
row.detail = data["detail"]
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
@@ -115,6 +119,8 @@ class SkillRepository(SkillStore):
row.published = bool(data["published"]) row.published = bool(data["published"])
if "owner_user_id" in data: if "owner_user_id" in data:
row.owner_user_id = data["owner_user_id"] row.owner_user_id = data["owner_user_id"]
if "detail" in data:
row.detail = data["detail"]
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
await session.commit() await session.commit()
await session.refresh(row) await session.refresh(row)
@@ -0,0 +1,162 @@
"""Tests for the skill ``detail`` field (long-form annotation editable by
the skill owner or an admin).
Covers:
- ``SkillStore`` persists ``detail`` through create / ensure_legacy / update /
update_admin, and owner-only ``update`` still refuses non-owners.
- ``_skill_to_response`` surfaces ``detail`` from the ownership record.
- ``SkillResponse`` / ``SkillDetailRequest`` model shapes.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.gateway.routers.skills import (
SkillDetailRequest,
SkillResponse,
_skill_to_response,
)
from deerflow.config import paths as paths_module
from deerflow.persistence.skills import MemorySkillStore
from deerflow.skills.types import SkillCategory
@pytest.fixture
def isolated_paths(tmp_path, monkeypatch):
"""Redirect get_paths() at a fresh temp directory so usage lookups in
``_skill_to_response`` never touch real state."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
yield tmp_path
monkeypatch.setattr(paths_module, "_paths", None)
# ---------------------------------------------------------------------------
# SkillStore: detail persistence
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_skill_store_create_carries_detail() -> None:
store = MemorySkillStore()
created = await store.create(
{"name": "alpha-tool", "owner_user_id": "user-a", "detail": "初始详情"}
)
assert created["detail"] == "初始详情"
fetched = await store.get_any("alpha-tool")
assert fetched is not None and fetched["detail"] == "初始详情"
@pytest.mark.asyncio
async def test_skill_store_create_defaults_detail_to_none() -> None:
store = MemorySkillStore()
created = await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
assert created["detail"] is None
@pytest.mark.asyncio
async def test_skill_store_owner_can_set_and_clear_detail() -> None:
store = MemorySkillStore()
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
updated = await store.update("alpha-tool", "user-a", {"detail": "技能用途说明"})
assert updated is not None and updated["detail"] == "技能用途说明"
# Clearing the annotation persists None.
cleared = await store.update("alpha-tool", "user-a", {"detail": None})
assert cleared is not None and cleared["detail"] is None
@pytest.mark.asyncio
async def test_skill_store_update_detail_rejects_non_owner() -> None:
store = MemorySkillStore()
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
# A different user cannot write detail via the owner-scoped update.
assert await store.update("alpha-tool", "user-b", {"detail": "x"}) is None
record = await store.get_any("alpha-tool")
assert record is not None and record["detail"] is None
@pytest.mark.asyncio
async def test_skill_store_admin_can_set_detail_on_any_skill() -> None:
store = MemorySkillStore()
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
# Admin override writes detail regardless of ownership.
updated = await store.update_admin("alpha-tool", {"detail": "管理员补充说明"})
assert updated is not None and updated["detail"] == "管理员补充说明"
@pytest.mark.asyncio
async def test_skill_store_partial_update_preserves_detail() -> None:
"""Updating only ``published`` must not wipe an existing ``detail``."""
store = MemorySkillStore()
await store.create({"name": "alpha-tool", "owner_user_id": "user-a"})
await store.update("alpha-tool", "user-a", {"detail": "保留我"})
await store.update("alpha-tool", "user-a", {"published": True})
record = await store.get_any("alpha-tool")
assert record is not None
assert record["detail"] == "保留我"
assert record["published"] is True
@pytest.mark.asyncio
async def test_skill_store_ensure_legacy_carries_detail() -> None:
store = MemorySkillStore()
record = await store.ensure_legacy(
{"name": "builtin-skill", "owner_user_id": None, "published": True, "detail": "内置技能说明"}
)
assert record["detail"] == "内置技能说明"
# ---------------------------------------------------------------------------
# Response mapping
# ---------------------------------------------------------------------------
def test_skill_response_detail_defaults_to_none() -> None:
resp = SkillResponse(name="x", description="d", category=SkillCategory.CUSTOM)
assert resp.detail is None
def test_skill_detail_request_defaults_to_empty_string() -> None:
assert SkillDetailRequest().detail == ""
assert SkillDetailRequest(detail="hello").detail == "hello"
def test_skill_to_response_surfaces_detail_from_ownership(isolated_paths) -> None:
skill = SimpleNamespace(
name="alpha-tool",
description="desc",
license=None,
category=SkillCategory.CUSTOM,
enabled=True,
)
ownership = {
"name": "alpha-tool",
"owner_user_id": "user-a",
"published": False,
"builtin": False,
"detail": "这是技能的详细介绍",
}
resp = _skill_to_response(skill, ownership)
assert resp.detail == "这是技能的详细介绍"
assert resp.owner_user_id == "user-a"
def test_skill_to_response_detail_none_without_ownership(isolated_paths) -> None:
skill = SimpleNamespace(
name="builtin-skill",
description="desc",
license=None,
category=SkillCategory.PUBLIC,
enabled=True,
)
resp = _skill_to_response(skill, None)
assert resp.detail is None
assert resp.builtin is True