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,
|
useInstallSkillUpload,
|
||||||
useSetSkillPinned,
|
useSetSkillPinned,
|
||||||
useSetSkillPublished,
|
useSetSkillPublished,
|
||||||
|
useSkillContent,
|
||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
@@ -256,6 +257,7 @@ function SkillRow({
|
|||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
const isCustom = skill.category === "custom";
|
const isCustom = skill.category === "custom";
|
||||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||||
@@ -306,7 +308,10 @@ function SkillRow({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full" variant="outline">
|
<Item className="w-full" variant="outline">
|
||||||
<ItemContent>
|
<ItemContent
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setDetailOpen(true)}
|
||||||
|
>
|
||||||
<ItemTitle>
|
<ItemTitle>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span>{skill.name}</span>
|
<span>{skill.name}</span>
|
||||||
@@ -471,10 +476,55 @@ function SkillRow({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 }) {
|
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -174,6 +174,24 @@ export async function setSkillPinned(
|
|||||||
return response.json() as Promise<Skill>;
|
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> {
|
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
deleteCustomSkill,
|
deleteCustomSkill,
|
||||||
enableSkill,
|
enableSkill,
|
||||||
|
getSkillContent,
|
||||||
installSkillUpload,
|
installSkillUpload,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
@@ -21,6 +22,14 @@ export function useSkills() {
|
|||||||
return { skills: data ?? [], isLoading, error };
|
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() {
|
export function useEnableSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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)}")
|
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(
|
@router.put(
|
||||||
"/skills/{skill_name}",
|
"/skills/{skill_name}",
|
||||||
response_model=SkillResponse,
|
response_model=SkillResponse,
|
||||||
|
|||||||
Reference in new issue
Block a user