study / web /src /app /settings /components /skill-presentation-editor.tsx
xiaolongmr
1
6daa87a
Raw History Blame Contribute Delete
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>
);
}