Files
ttstd-infinite-canvas/web/src/pages/skills/skill-editor-drawer.tsx

187 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Form, Input } from "antd";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { Select } from "@/components/ui/base/select";
import { Switch } from "@/components/ui/base/switch";
import { Minus, Plus, Save, Wand2 } from "lucide-react";
import { useEffect, useState } from "react";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { generateSkillDraft } from "@/lib/canvas/skill-drafting";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { createSkill, updateSkill, type Skill, type SkillMutationInput, type SkillShowcaseMedia } from "@/services/api/skills";
type SkillFormValues = Omit<SkillMutationInput, "isPrivate"> & { is_public: boolean };
export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boolean; skill: Skill | null; onClose: () => void; onSaved: (skill: Skill) => void }) {
const { message, modal } = App.useApp();
const [form] = Form.useForm<SkillFormValues>();
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftIdea, setDraftIdea] = useState("");
const [drafting, setDrafting] = useState(false);
const effectiveConfig = useEffectiveConfig();
const isPackageSkill = Boolean(skill && skill.sourceType !== "markdown" && skill.sourceType !== "builtin" && skill.sourceType !== "");
useEffect(() => {
if (!open) return;
form.setFieldsValue({
skillName: skill?.skillName || "",
description: skill?.description || "",
instruction: skill?.instruction || "",
tag: skill?.tag || "creative",
is_public: skill ? !skill.isPrivate : true,
markdownUrl: skill?.markdownUrl || skill?.sourceUrl || "",
showcaseMedia: skill?.showcaseMedia || [],
extraInfo: skill?.extraInfo || "",
});
setDirty(false);
}, [form, open, skill]);
const requestClose = () => {
if (!dirty) {
onClose();
return;
}
modal.confirm({ title: "放弃未保存的修改?", content: "当前填写内容不会保留。", okText: "放弃修改", okButtonProps: { danger: true }, cancelText: "继续编辑", onOk: onClose });
};
const submit = async (values: SkillFormValues) => {
setSaving(true);
try {
const input: SkillMutationInput = {
skillName: values.skillName,
description: values.description,
instruction: values.instruction || "",
tag: values.tag,
isPrivate: !values.is_public,
markdownUrl: values.markdownUrl || "",
showcaseMedia: (values.showcaseMedia || []).map((item) => ({ ...item, showcaseUri: item.showcaseUri || "" })),
extraInfo: values.extraInfo || "",
};
const result = skill ? await updateSkill(skill.skillId, input) : await createSkill(input);
setDirty(false);
message.success(skill ? "技能已更新" : "技能已创建");
onSaved(result.skill);
} catch (error) {
message.error(error instanceof Error ? error.message : "技能保存失败");
} finally {
setSaving(false);
}
};
const draftFromIdea = async () => {
const idea = draftIdea.trim();
if (!idea) {
message.warning("请先描述你想沉淀的技能");
return;
}
if (!useConfigStore.getState().isAiConfigReady(effectiveConfig, effectiveConfig.model)) {
message.info("尚未配置可用的文本模型,请先到设置页配置");
navigateToSettings({ section: "models", continueCreation: true });
return;
}
setDrafting(true);
try {
const draft = await generateSkillDraft(idea, effectiveConfig);
form.setFieldsValue({
skillName: draft.skillName || "",
description: draft.description || "",
instruction: draft.instruction || "",
...(draft.tag ? { tag: draft.tag } : {}),
});
setDirty(true);
message.success("草稿已生成,请检查并调整后保存");
} catch (error) {
message.error(error instanceof Error ? `起草失败:${error.message}` : "起草失败");
} finally {
setDrafting(false);
}
};
return (
<AppDrawer className="library-drawer" open={open} size={720} mask={{ closable: !dirty }} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
{!isPackageSkill ? <div className="mb-4 rounded-xl border bg-foreground/[.02] p-3">
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium">
<Wand2 className="size-4" />
AI 起草
<span className="font-normal text-foreground/45">描述想法,一键生成名称、简介与指令草稿(可再编辑)</span>
</div>
<Input.TextArea
value={draftIdea}
onChange={(event) => setDraftIdea(event.target.value)}
autoSize={{ minRows: 3, maxRows: 6 }}
maxLength={2000}
showCount
disabled={drafting}
placeholder="例如:我要一个竖屏短剧分镜技能——输入剧本段落,输出按景别排列的分镜表,每个镜头包含画面、台词、时长与转场…"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-xs text-foreground/45">将使用你的文本模型生成一次草稿</span>
<Button type="primary" loading={drafting} disabled={!draftIdea.trim()} icon={<Wand2 className="size-4" />} onClick={() => void draftFromIdea()}>生成草稿</Button>
</div>
</div> : <div className="mb-4 rounded-xl border bg-foreground/[.02] p-3 text-sm leading-6 text-foreground/58">这是多文件技能包。这里仅编辑名称、简介、分类和展示信息;技能正文请更新 ZIP,或在 GitHub 仓库修改后执行同步。</div>}
<Form form={form} layout="vertical" requiredMark="optional" onFinish={submit} onValuesChange={() => setDirty(true)}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Form.Item name="skillName" label="技能名称" rules={[{ required: true, message: "请填写技能名称" }, { max: 80, message: "最多 80 个字符" }]}>
<Input maxLength={80} showCount placeholder="例如:短剧导演分镜" autoComplete="off" />
</Form.Item>
<Form.Item name="tag" label="技能分类" rules={[{ required: true, message: "请选择技能分类" }]}>
<Select options={fallbackSkillCategories.map(({ value, label }) => ({ value, label }))} />
</Form.Item>
</div>
<Form.Item name="description" label="技能简介" rules={[{ required: true, message: "请填写技能简介" }, { max: 500, message: "最多 500 个字符" }]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={500} showCount placeholder="说明适用场景、输入条件和最终产出" />
</Form.Item>
{!isPackageSkill ? <Form.Item name="instruction" label="技能指令" rules={[{ required: true, message: "请填写技能指令" }, { max: 100000, message: "最多 100000 个字符" }]} extra="单文件技能会作为 SKILL.md 安装,Agent 按任务需要读取。">
<Input.TextArea className="font-mono text-xs leading-5" autoSize={{ minRows: 14, maxRows: 28 }} maxLength={100000} showCount placeholder="使用 Markdown 编写角色、约束、流程、检查清单和输出格式" />
</Form.Item> : null}
<div className="grid gap-x-4 sm:grid-cols-[minmax(0,1fr)_180px]">
<Form.Item name="markdownUrl" label={isPackageSkill ? "来源地址" : "Markdown 地址"} rules={[{ type: "url", message: "请输入有效的 HTTP(S) 链接" }]}>
<Input type="url" inputMode="url" spellCheck={false} placeholder="https://example.com/SKILL.md" />
</Form.Item>
<Form.Item name="is_public" label="公开状态" valuePropName="checked" extra="公开后其他用户可以加入使用。">
<Switch checkedChildren="公开" unCheckedChildren="私有" />
</Form.Item>
</div>
<Form.List name="showcaseMedia">
{(fields, { add, remove }) => (
<section aria-labelledby="skill-media-title">
<div className="mb-3 flex items-center justify-between">
<div><h3 id="skill-media-title" className="text-sm font-medium">展示媒体</h3><p className="mt-1 text-xs text-foreground/50">可选,最多 8 个公开图片或视频链接。</p></div>
<Button disabled={fields.length >= 8} icon={<Plus className="size-4" />} onClick={() => add(emptyMedia())}>添加媒体</Button>
</div>
<div className="space-y-2">
{fields.map((field) => (
<div key={field.key} className="grid grid-cols-[112px_minmax(0,1fr)_36px] gap-2">
<Form.Item {...field} name={[field.name, "type"]} className="mb-0" rules={[{ required: true, message: "选择类型" }]}>
<Select options={[{ value: "image", label: "图片" }, { value: "video", label: "视频" }]} />
</Form.Item>
<Form.Item {...field} name={[field.name, "showcaseUrl"]} className="mb-0" rules={[{ required: true, message: "请填写媒体链接" }, { type: "url", message: "链接格式无效" }]}>
<Input type="url" inputMode="url" spellCheck={false} placeholder="https://example.com/media" />
</Form.Item>
<Button aria-label="移除媒体" title="移除媒体" icon={<Minus className="size-4" />} onClick={() => remove(field.name)} />
<Form.Item {...field} name={[field.name, "showcaseUri"]} hidden><Input /></Form.Item>
</div>
))}
</div>
</section>
)}
</Form.List>
<Form.Item name="extraInfo" label="补充信息" className="mt-5" rules={[{ max: 2000, message: "最多 2000 个字符" }]}>
<Input.TextArea autoSize={{ minRows: 2, maxRows: 5 }} maxLength={2000} showCount placeholder="版本说明、依赖工具或使用注意事项" />
</Form.Item>
</Form>
</AppDrawer>
);
}
function emptyMedia(): SkillShowcaseMedia {
return { type: "image", showcaseUri: "", showcaseUrl: "" };
}