Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd-v2
This commit is contained in:
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: "这些技能已被你或策展程序归档,可随时恢复。",
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+42
@@ -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")
|
||||||
+6
@@ -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]
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -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
|
||||||
Reference in new issue
Block a user