Download web/src/app/settings/components/skill-presentation-editor.tsx from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 14.9 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/web/src/app/settings/components/skill-presentation-editor.tsx
- Command line
-
hf download hf://spaces/zlcy/study/web/src/app/settings/components/skill-presentation-editor.tsx
-
curl -L -o skill-presentation-editor.tsx https://huggingface.co/spaces/zlcy/study/resolve/main/web/src/app/settings/components/skill-presentation-editor.tsx
14.9 kB
| "use client"; | |
| import { useEffect, useRef, useState } from "react"; | |
| import { ImagePlus, LoaderCircle, Plus, Sparkles, Star, Trash2 } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { SkillCoverThumbnail } from "@/app/chat/components/skill-showcase"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { generateSkillExamples, updateSkillPresentation, uploadSkillPresentationAssets, type InstalledSkill, type SkillAsset, type SkillExample, type SkillInputSpec, type SkillRuntime } from "@/lib/api"; | |
| type Props = { | |
| skill: InstalledSkill | null; | |
| runtime: SkillRuntime | null; | |
| onOpenChange: (open: boolean) => void; | |
| onSaved: (runtime: SkillRuntime, items: InstalledSkill[]) => void; | |
| }; | |
| function emptyExample(index: number): SkillExample { | |
| return { id: `custom-${Date.now()}-${index}`, title: `示例 ${index + 1}`, description: "", prompt: "", assets: [], attachment_hint: "" }; | |
| } | |
| export function SkillPresentationEditor({ skill, runtime, onOpenChange, onSaved }: Props) { | |
| const [title, setTitle] = useState(""); | |
| const [description, setDescription] = useState(""); | |
| const [category, setCategory] = useState(""); | |
| const [tags, setTags] = useState(""); | |
| const [coverPath, setCoverPath] = useState(""); | |
| const [assets, setAssets] = useState<SkillAsset[]>([]); | |
| const [examples, setExamples] = useState<SkillExample[]>([]); | |
| const [inputs, setInputs] = useState<SkillInputSpec[]>([]); | |
| const [working, setWorking] = useState<"" | "save" | "upload" | "generate">(""); | |
| const fileRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| if (!runtime) return; | |
| setTitle(runtime.presentation.title || runtime.name); | |
| setDescription(runtime.presentation.description || runtime.description); | |
| setCategory(runtime.presentation.category || "专业工作流"); | |
| setTags(runtime.presentation.tags.join("、")); | |
| setCoverPath(runtime.presentation.cover?.path || ""); | |
| setAssets(runtime.presentation.assets); | |
| setExamples(runtime.presentation.examples); | |
| setInputs(runtime.presentation.inputs); | |
| }, [runtime]); | |
| const save = async () => { | |
| if (!skill || !runtime || working) return; | |
| const validExamples = examples.filter((example) => example.prompt.trim()).map((example, index) => ({ | |
| ...example, | |
| id: example.id || `custom-${index + 1}`, | |
| title: example.title.trim() || `示例 ${index + 1}`, | |
| prompt: example.prompt.trim(), | |
| })); | |
| if (!validExamples.length) { | |
| toast.error("至少保留一个可用示例"); | |
| return; | |
| } | |
| setWorking("save"); | |
| try { | |
| const result = await updateSkillPresentation(skill.id, { | |
| display: { | |
| title: title.trim() || runtime.name, | |
| description: description.trim(), | |
| category: category.trim() || "专业工作流", | |
| cover: coverPath, | |
| tags: tags.split(/[,,、\n]/).map((item) => item.trim()).filter(Boolean), | |
| }, | |
| assets: assets.map((asset) => ({ path: asset.path, name: asset.name, description: asset.description, role: asset.path === coverPath ? "cover" : "gallery" })), | |
| examples: validExamples, | |
| inputs: inputs.filter((input) => input.label.trim()).map((input, index) => ({ ...input, id: input.id || `input-${index + 1}`, label: input.label.trim(), description: input.description.trim() })), | |
| }); | |
| onSaved(result.skill, result.items); | |
| toast.success("Skill 展示信息已保存"); | |
| onOpenChange(false); | |
| } catch (error) { | |
| toast.error(error instanceof Error ? error.message : "保存展示信息失败"); | |
| } finally { | |
| setWorking(""); | |
| } | |
| }; | |
| const upload = async (files: File[]) => { | |
| if (!skill || !files.length || working) return; | |
| setWorking("upload"); | |
| try { | |
| const result = await uploadSkillPresentationAssets(skill.id, files); | |
| setAssets((current) => [...current, ...result.assets.filter((asset) => !current.some((item) => item.path === asset.path))]); | |
| if (!coverPath && result.assets[0]) setCoverPath(result.assets[0].path); | |
| toast.success(`已添加 ${result.assets.length} 张展示图片,保存后生效`); | |
| } catch (error) { | |
| toast.error(error instanceof Error ? error.message : "上传展示图片失败"); | |
| } finally { | |
| if (fileRef.current) fileRef.current.value = ""; | |
| setWorking(""); | |
| } | |
| }; | |
| const generateExamples = async () => { | |
| if (!skill || working) return; | |
| setWorking("generate"); | |
| try { | |
| const result = await generateSkillExamples(skill.id); | |
| setExamples(result.skill.presentation.examples); | |
| onSaved(result.skill, result.items); | |
| toast.success("AI 已根据 Skill 文档生成示例,你可以继续编辑后保存"); | |
| } catch (error) { | |
| toast.error(error instanceof Error ? error.message : "AI 生成示例失败"); | |
| } finally { | |
| setWorking(""); | |
| } | |
| }; | |
| return ( | |
| <Dialog open={Boolean(skill)} onOpenChange={onOpenChange}> | |
| <DialogContent className="flex h-[min(92dvh,960px)] w-[min(96vw,1100px)] max-w-[1100px] flex-col gap-0 overflow-hidden rounded-[28px] p-0"> | |
| <DialogHeader className="shrink-0 border-b border-stone-100 px-6 py-5 pr-14 text-left"> | |
| <DialogTitle>编辑 Skill 展示</DialogTitle> | |
| <p className="text-sm text-stone-500">后台设置会覆盖自动扫描结果,不会修改第三方 Skill 的原始文件。</p> | |
| </DialogHeader> | |
| {!runtime || !skill ? ( | |
| <div className="flex flex-1 items-center justify-center gap-2 text-sm text-stone-500"><LoaderCircle className="size-4 animate-spin" />正在读取展示信息…</div> | |
| ) : ( | |
| <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5"> | |
| <div className="grid gap-4 sm:grid-cols-2"> | |
| <label className="space-y-1.5 text-sm"><span className="font-medium text-stone-700">展示名称</span><Input value={title} onChange={(event) => setTitle(event.target.value)} /></label> | |
| <label className="space-y-1.5 text-sm"><span className="font-medium text-stone-700">分类</span><Input value={category} onChange={(event) => setCategory(event.target.value)} /></label> | |
| <label className="space-y-1.5 text-sm sm:col-span-2"><span className="font-medium text-stone-700">描述</span><Textarea value={description} onChange={(event) => setDescription(event.target.value)} className="min-h-20 resize-y" /></label> | |
| <label className="space-y-1.5 text-sm sm:col-span-2"><span className="font-medium text-stone-700">标签</span><Input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="用逗号或顿号分隔" /></label> | |
| </div> | |
| <section className="mt-7"> | |
| <div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold text-stone-900">用户输入要求</h3><p className="mt-1 text-xs text-stone-500">只有勾选“必需”的项目会阻止执行;参考图片默认可选。</p></div><Button type="button" variant="outline" size="sm" onClick={() => setInputs((current) => [...current, { id: `input-${Date.now()}`, label: "新输入项", type: "text", required: false, description: "" }])}><Plus />新增</Button></div> | |
| <div className="space-y-3"> | |
| {inputs.map((input, index) => ( | |
| <div key={input.id || index} className="grid gap-3 rounded-2xl border border-stone-200 bg-stone-50/60 p-4 sm:grid-cols-[1fr_150px_auto]"> | |
| <Input value={input.label} onChange={(event) => setInputs((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} placeholder="输入名称" /> | |
| <select value={input.type} onChange={(event) => setInputs((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, type: event.target.value } : item))} className="h-10 rounded-md border border-stone-200 bg-white px-3 text-sm text-stone-700 outline-none focus:border-blue-300"> | |
| <option value="text">文字要求</option><option value="image">参考图片</option><option value="file">文件</option><option value="pdf">PDF</option><option value="video">视频</option><option value="audio">音频</option> | |
| </select> | |
| <Button type="button" variant="ghost" size="icon" onClick={() => setInputs((current) => current.filter((_, itemIndex) => itemIndex !== index))} aria-label="删除输入项"><Trash2 className="size-4" /></Button> | |
| <Input value={input.description} onChange={(event) => setInputs((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, description: event.target.value } : item))} placeholder="给用户看的说明" className="sm:col-span-2" /> | |
| <label className="flex h-10 items-center gap-2 text-sm text-stone-700"><input type="checkbox" checked={input.required} onChange={(event) => setInputs((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, required: event.target.checked } : item))} className="size-4 rounded border-stone-300" />必需</label> | |
| </div> | |
| ))} | |
| </div> | |
| </section> | |
| <section className="mt-7"> | |
| <div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold text-stone-900">封面与案例图</h3><p className="mt-1 text-xs text-stone-500">封面按 4:3 展示;选择星标可设为封面,其余图片进入案例画廊。</p></div><Button type="button" variant="outline" size="sm" disabled={Boolean(working)} onClick={() => fileRef.current?.click()}>{working === "upload" ? <LoaderCircle className="animate-spin" /> : <ImagePlus />}上传图片</Button><input ref={fileRef} className="hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" multiple onChange={(event) => void upload(Array.from(event.currentTarget.files || []))} /></div> | |
| {assets.length ? <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{assets.map((asset, index) => ( | |
| <div key={asset.path} className={`overflow-hidden rounded-2xl border bg-white ${coverPath === asset.path ? "border-blue-400 ring-2 ring-blue-100" : "border-stone-200"}`}> | |
| <SkillCoverThumbnail skillId={skill.id} asset={asset} crop className="aspect-[4/3] w-full rounded-none border-0 border-b" /> | |
| <div className="space-y-2 p-3"><Input value={asset.name} onChange={(event) => setAssets((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, name: event.target.value } : item))} className="h-8 text-xs" /><div className="flex gap-2"><button type="button" onClick={() => setCoverPath(asset.path)} className={`inline-flex h-8 flex-1 items-center justify-center gap-1 rounded-lg text-xs ${coverPath === asset.path ? "bg-blue-600 text-white" : "bg-stone-100 text-stone-600 hover:bg-stone-200"}`}><Star className={`size-3 ${coverPath === asset.path ? "fill-current" : ""}`} />{coverPath === asset.path ? "当前封面" : "设为封面"}</button><button type="button" onClick={() => { setAssets((current) => current.filter((_, itemIndex) => itemIndex !== index)); if (coverPath === asset.path) setCoverPath(""); }} className="inline-flex size-8 items-center justify-center rounded-lg text-stone-400 hover:bg-red-50 hover:text-red-600" aria-label={`移除 ${asset.name}`}><Trash2 className="size-3.5" /></button></div></div> | |
| </div> | |
| ))}</div> : <div className="rounded-2xl border border-dashed border-stone-300 py-10 text-center text-sm text-stone-500">还没有展示图片,可上传自定义封面和案例图。</div>} | |
| </section> | |
| <section className="mt-7"> | |
| <div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold text-stone-900">套用示例</h3><p className="mt-1 text-xs text-stone-500">AI 会阅读 Skill 文档生成不同场景的示例;生成后仍可逐条修改。</p></div><div className="flex gap-2"><Button type="button" variant="outline" size="sm" disabled={Boolean(working)} onClick={() => setExamples((current) => [...current, emptyExample(current.length)])}><Plus />新增</Button><Button type="button" size="sm" disabled={Boolean(working)} onClick={() => void generateExamples()}>{working === "generate" ? <LoaderCircle className="animate-spin" /> : <Sparkles />}AI 生成示例</Button></div></div> | |
| <div className="space-y-3">{examples.map((example, index) => ( | |
| <div key={example.id || index} className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4"> | |
| <div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]"><Input value={example.title} onChange={(event) => setExamples((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item))} placeholder="示例标题" /><Input value={example.description} onChange={(event) => setExamples((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, description: event.target.value } : item))} placeholder="适合什么场景" /><Button type="button" variant="ghost" size="icon" onClick={() => setExamples((current) => current.filter((_, itemIndex) => itemIndex !== index))} aria-label="删除示例"><Trash2 className="size-4" /></Button></div> | |
| <Textarea value={example.prompt} onChange={(event) => setExamples((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, prompt: event.target.value } : item))} placeholder={`$${skill.id} 具体任务要求`} className="mt-3 min-h-24 resize-y bg-white" /> | |
| <Input value={example.attachment_hint} onChange={(event) => setExamples((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, attachment_hint: event.target.value } : item))} placeholder="附件提醒(可选),例如:请上传一张产品正面图" className="mt-3 bg-white" /> | |
| </div> | |
| ))}</div> | |
| </section> | |
| </div> | |
| )} | |
| <div className="flex shrink-0 items-center justify-between gap-3 border-t border-stone-100 bg-white px-6 py-4"><span className="text-xs text-stone-400">保存后用户端立即读取新的展示内容。</span><div className="flex gap-2"><Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button type="button" disabled={!runtime || Boolean(working)} onClick={() => void save()}>{working === "save" ? <LoaderCircle className="animate-spin" /> : null}保存展示</Button></div></div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |