feat(skills): 点击技能展示 SKILL.md 详情弹窗
- 后端新增 GET /api/skills/{name}/content 接口,返回公共/自定义技能的 SKILL.md 原文
- 前端技能行内容区可点击,弹窗以等宽原文展示 SKILL.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
12798c00bc
commit
6981fc06ca
4 files changed
+108
-1
No files matched your search
@@ -54,6 +54,7 @@ import {
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkillContent,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -256,6 +257,7 @@ function SkillRow({
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -306,7 +308,10 @@ function SkillRow({
|
||||
return (
|
||||
<>
|
||||
<Item className="w-full" variant="outline">
|
||||
<ItemContent>
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
@@ -471,10 +476,55 @@ function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SkillDetailDialog
|
||||
skill={skill}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
|
||||
@@ -174,6 +174,24 @@ export async function setSkillPinned(
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillContentResponse {
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function getSkillContent(
|
||||
skillName: string,
|
||||
): Promise<SkillContentResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill content");
|
||||
}
|
||||
return response.json() as Promise<SkillContentResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
@@ -21,6 +22,14 @@ export function useSkills() {
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
queryFn: () => getSkillContent(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -816,6 +816,36 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get skill: {str(e)}")
|
||||
|
||||
|
||||
class SkillContentResponse(BaseModel):
|
||||
"""SKILL.md 原文内容,适用于公共和自定义技能。"""
|
||||
|
||||
name: str
|
||||
category: SkillCategory
|
||||
content: str
|
||||
|
||||
|
||||
@router.get(
|
||||
"/skills/{skill_name}/content",
|
||||
response_model=SkillContentResponse,
|
||||
summary="Get Skill Markdown Content",
|
||||
description="Retrieve the raw SKILL.md content of a specific skill (public or custom).",
|
||||
)
|
||||
async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
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")
|
||||
content = skill.skill_file.read_text(encoding="utf-8")
|
||||
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get skill content %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get skill content: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}",
|
||||
response_model=SkillResponse,
|
||||
|
||||
Reference in new issue
Block a user