File size: 14,944 Bytes
6daa87a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | "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>
);
}
|