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>
  );
}