feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复

This commit is contained in:
ddCat committed 2026-08-29 23:34:55 +08:00
1 parent 4f07daae9e
commit 2f6832f99c
113 files changed
+7016 -723

No files matched your search

+23 -6
View File
@@ -3,13 +3,14 @@ import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, Mo
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { ListToolbar, PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { ListToolbar, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { fallbackSkillCategories, formatSkillCount, groupSkills, skillCategoryLabel } from "@/pages/skills/skill-catalog";
import { SkillDetailDrawer } from "@/pages/skills/skill-detail-drawer";
import { SkillDetailModal } from "@/pages/skills/skill-detail-drawer";
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
import { SkillInstallModal } from "@/pages/skills/skill-install-modal";
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
const scopeOptions = [
{ label: "技能广场", value: "public", icon: Sparkles },
@@ -64,6 +65,7 @@ export default function SkillsPage() {
const [detailLoading, setDetailLoading] = useState(false);
const [mutatingID, setMutatingID] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
@@ -163,6 +165,20 @@ export default function SkillsPage() {
}
};
const synchronizeSkill = async (skill: Skill) => {
setMutatingID(skill.skill_id);
try {
const result = await syncSkill(skill.skill_id);
patchSkill(result.skill);
message.success(result.skill.version_id === skill.version_id ? "已是最新版本" : "已同步最新版本");
reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "GitHub 技能同步失败");
} finally {
setMutatingID("");
}
};
const confirmDelete = (skill: Skill) => {
modal.confirm({
title: `删除“${skill.skill_name}”?`,
@@ -239,7 +255,7 @@ export default function SkillsPage() {
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="library-grid skill-library-grid">
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => void openEditor()}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">创建技能</span><span className="library-create-meta">把一套方法变成可复用能力</span></button> : null}
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => setInstallOpen(true)}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">安装技能</span><span className="library-create-meta">上传 MD / ZIP 或连接 GitHub</span></button> : null}
{group.skills.map((skill, index) => <SkillCard key={skill.skill_id} skill={skill} categories={categories} loading={mutatingID === skill.skill_id} style={{ animationDelay: `${Math.min(index, 10) * 45}ms` }} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
@@ -256,7 +272,7 @@ export default function SkillsPage() {
action={filtersActive
? <Button onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }}>清除筛选</Button>
: (scope === "created" || scope === "public")
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>创建技能</Button>
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>
: undefined}
/>
)}
@@ -264,7 +280,8 @@ export default function SkillsPage() {
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</WorkspacePage>
<SkillDetailDrawer skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skill_id)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} />
<SkillDetailModal skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skill_id)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} onSync={(skill) => void synchronizeSkill(skill)} />
<SkillInstallModal open={installOpen} onClose={() => setInstallOpen(false)} onInstalled={(skill) => { setInstallOpen(false); setActiveSkill(skill); reload(); }} onManualCreate={() => { setInstallOpen(false); void openEditor(); }} />
<SkillEditorDrawer open={editorOpen} skill={editingSkill} onClose={() => setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} />
</>
);
+257 -38
View File
@@ -1,57 +1,276 @@
import { Button, Drawer, Skeleton, Tooltip } from "antd";
import { Check, ExternalLink, Heart, Pencil, Plus, Users } from "lucide-react";
import { Button, Input, Modal, Segmented, Skeleton, Tooltip, Tree } from "antd";
import type { DataNode } from "antd/es/tree";
import { Check, ChevronRight, Code2, ExternalLink, File, FileArchive, FileCode2, FileImage, FileText, Folder, FolderOpen, Heart, Pencil, Plus, RefreshCw, Users } from "lucide-react";
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { formatSkillCount, formatSkillDate, skillCategoryLabel } from "@/pages/skills/skill-catalog";
import type { Skill, SkillCategory } from "@/services/api/skills";
import { getSkillFile, listSkillFiles, skillFileRawURL, type Skill, type SkillCategory, type SkillPackageFile, type SkillPackageFileContent } from "@/services/api/skills";
type PreviewMode = "preview" | "source";
export function SkillDetailModal({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit, onSync }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void; onSync: (skill: Skill) => void }) {
const [files, setFiles] = useState<SkillPackageFile[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [filesError, setFilesError] = useState("");
const [activePath, setActivePath] = useState("SKILL.md");
const [content, setContent] = useState<SkillPackageFileContent | null>(null);
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState("");
const [previewMode, setPreviewMode] = useState<PreviewMode>("preview");
const [pathFilter, setPathFilter] = useState("");
useEffect(() => {
if (!skill) {
setFiles([]);
setContent(null);
return;
}
let cancelled = false;
setFilesLoading(true);
setFilesError("");
setPathFilter("");
listSkillFiles(skill.skill_id)
.then((result) => {
if (cancelled) return;
setFiles(result.files);
const first = result.files.find((file) => file.path === "SKILL.md")?.path || result.files[0]?.path || "";
setActivePath(first);
})
.catch((error) => {
if (cancelled) return;
setFiles([]);
setFilesError(error instanceof Error ? error.message : "技能文件加载失败");
})
.finally(() => {
if (!cancelled) setFilesLoading(false);
});
return () => { cancelled = true; };
}, [skill?.skill_id, skill?.version_id]);
useEffect(() => {
if (!skill || !activePath) {
setContent(null);
return;
}
let cancelled = false;
setContentLoading(true);
setContentError("");
getSkillFile(skill.skill_id, activePath)
.then((result) => { if (!cancelled) setContent(result.file); })
.catch((error) => {
if (cancelled) return;
setContent(null);
setContentError(error instanceof Error ? error.message : "文件读取失败");
})
.finally(() => { if (!cancelled) setContentLoading(false); });
return () => { cancelled = true; };
}, [activePath, skill?.skill_id, skill?.version_id]);
useEffect(() => {
setPreviewMode("preview");
}, [activePath]);
const visibleFiles = useMemo(() => {
const needle = pathFilter.trim().toLowerCase();
return needle ? files.filter((file) => file.path.toLowerCase().includes(needle)) : files;
}, [files, pathFilter]);
const treeData = useMemo(() => buildSkillTree(visibleFiles), [visibleFiles]);
const filePaths = useMemo(() => new Set(files.map((file) => file.path)), [files]);
const selectedFile = files.find((file) => file.path === activePath);
const canPreviewMarkdown = selectedFile?.kind === "markdown" && !content?.binary;
export function SkillDetailDrawer({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void }) {
return (
<Drawer className="library-drawer" open={Boolean(skill)} size={760} destroyOnHidden title={skill?.skill_name || "技能详情"} onClose={onClose} extra={skill?.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : undefined}>
<Modal
className="skill-package-modal"
open={Boolean(skill)}
width="82vw"
footer={null}
destroyOnHidden
onCancel={onClose}
styles={{ container: { height: "82vh", padding: 0, overflow: "hidden" }, body: { height: "100%", padding: 0 } }}
>
{skill ? (
<div className="space-y-6">
<header>
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/50">
<span>{skillCategoryLabel(skill.tag, categories)}</span><span aria-hidden="true">/</span><span>{skill.is_private ? "仅自己可见" : "公开技能"}</span><span aria-hidden="true">/</span><span>更新于 {formatSkillDate(skill.update_time)}</span>
<div className="skill-package-shell">
<header className="skill-package-header">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[var(--fs-label)] text-foreground/45">
<span>{skillCategoryLabel(skill.tag, categories)}</span><span aria-hidden="true">/</span><span>{sourceLabel(skill.source_type)}</span><span aria-hidden="true">/</span><span>v{skill.version || "1"}</span><span aria-hidden="true">/</span><span>更新于 {formatSkillDate(skill.update_time)}</span>
</div>
<h1 className="mt-1 truncate text-[var(--fs-heading-lg)] font-semibold text-foreground">{skill.skill_name}</h1>
<p className="mt-1 line-clamp-2 max-w-4xl text-sm leading-5 text-foreground/58">{skill.description}</p>
</div>
<p className="mt-3 text-sm leading-6 text-foreground/72">{skill.description}</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button type={skill.is_added ? "default" : "primary"} loading={mutating} disabled={skill.is_owner} icon={skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />} onClick={() => onAdd(skill)}>{skill.is_owner ? "我的技能" : skill.is_added ? "已加入" : "加入技能"}</Button>
<div className="skill-package-actions">
{skill.source_type === "github" && skill.is_owner ? <Tooltip title={skill.sync_error || "从 GitHub 检查并同步最新提交"}><Button loading={mutating} icon={<RefreshCw className="size-4" />} onClick={() => onSync(skill)}>同步</Button></Tooltip> : null}
{skill.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : null}
<Button loading={mutating} icon={<Heart className={`size-4 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />} onClick={() => onLike(skill)}>{skill.is_like ? "已收藏" : "收藏"}</Button>
<span className="ml-auto inline-flex items-center gap-1.5 text-xs text-foreground/45"><Users className="size-3.5" />{formatSkillCount(skill.added_count)} 人加入</span>
<Button type={skill.is_added ? "default" : "primary"} loading={mutating} disabled={skill.is_owner} icon={skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />} onClick={() => onAdd(skill)}>{skill.is_owner ? "我的技能" : skill.is_added ? "已加入" : "加入技能"}</Button>
</div>
</header>
{skill.showcase_media.length ? <SkillMediaGallery skill={skill} /> : null}
<div className="skill-package-workspace">
<aside className="skill-package-sidebar" aria-label="技能文件">
<div className="skill-package-sidebar-summary">
<span><FileArchive className="size-3.5" />{skill.file_count || files.length} 个文件</span>
<span>{formatBytes(skill.total_bytes)}</span>
</div>
<Input allowClear size="small" value={pathFilter} onChange={(event) => setPathFilter(event.target.value)} placeholder="筛选文件…" prefix={<File className="size-3.5 text-foreground/30" />} />
<div className="skill-package-tree thin-scrollbar">
{filesLoading || loading ? <Skeleton active title={false} paragraph={{ rows: 10 }} /> : filesError ? <div className="skill-package-empty">{filesError}</div> : treeData.length ? (
<Tree
blockNode
showIcon
showLine={false}
defaultExpandAll
expandAction="click"
selectedKeys={activePath ? [activePath] : []}
treeData={treeData}
switcherIcon={({ expanded, isLeaf }) => isLeaf ? null : <ChevronRight aria-hidden="true" className={`skill-package-tree-chevron size-3.5 ${expanded ? "is-expanded" : ""}`} />}
onSelect={(keys, info) => { if (!info.node.children?.length && keys[0]) setActivePath(String(keys[0])); }}
/>
) : <div className="skill-package-empty">没有匹配文件</div>}
</div>
<div className="skill-package-sidebar-footer">
<span className="inline-flex items-center gap-1"><Users className="size-3.5" />{formatSkillCount(skill.added_count)} 人加入</span>
<span>{skill.is_private ? "仅自己可见" : "公开"}</span>
</div>
</aside>
<section aria-labelledby="skill-instruction-title">
<div className="mb-2 flex items-center justify-between gap-3">
<h2 id="skill-instruction-title" className="text-sm font-semibold">技能指令</h2>
{skill.markdown_url ? <Tooltip title="打开 Markdown 原文"><a className="inline-flex size-8 items-center justify-center rounded-md text-foreground/55 hover:bg-surface-hover hover:text-foreground" href={skill.markdown_url} target="_blank" rel="noreferrer" aria-label="打开 Markdown 原文"><ExternalLink className="size-4" /></a></Tooltip> : null}
</div>
{loading ? <Skeleton active paragraph={{ rows: 14 }} /> : <pre className="thin-scrollbar max-h-[52vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border/70 bg-foreground/[.025] p-4 font-mono text-xs leading-6 text-foreground/78">{skill.instruction || "暂无技能指令"}</pre>}
</section>
<section className="border-t border-border/70 pt-4 text-xs leading-5 text-foreground/48">
<div>作者:{skill.effective_user.name || "未知用户"}</div>
{skill.extra_info ? <div className="mt-2 whitespace-pre-wrap">{skill.extra_info}</div> : null}
</section>
<main className="skill-package-preview">
<div className="skill-package-preview-toolbar">
<div className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/58">{activePath || "请选择文件"}</div>
{canPreviewMarkdown ? <Segmented size="small" value={previewMode} onChange={(value) => setPreviewMode(value as PreviewMode)} options={[{ value: "preview", label: <span className="inline-flex items-center gap-1"><FileText className="size-3.5" />预览</span> }, { value: "source", label: <span className="inline-flex items-center gap-1"><Code2 className="size-3.5" />源码</span> }]} /> : null}
{activePath ? <Tooltip title="打开原始文件"><a className="skill-package-raw-link" href={skillFileRawURL(skill.skill_id, activePath)} target="_blank" rel="noreferrer" aria-label="打开原始文件"><ExternalLink className="size-4" /></a></Tooltip> : null}
</div>
<div className="skill-package-preview-body thin-scrollbar">
{contentLoading ? <Skeleton active paragraph={{ rows: 18 }} /> : contentError ? <div className="skill-package-empty">{contentError}</div> : content && selectedFile ? (
<SkillFilePreview skill={skill} file={selectedFile} content={content} mode={previewMode} filePaths={filePaths} onNavigate={setActivePath} />
) : <div className="skill-package-empty">从左侧选择一个文件</div>}
</div>
</main>
</div>
</div>
) : null}
</Drawer>
</Modal>
);
}
function SkillMediaGallery({ skill }: { skill: Skill }) {
function SkillFilePreview({ skill, file, content, mode, filePaths, onNavigate }: { skill: Skill; file: SkillPackageFile; content: SkillPackageFileContent; mode: PreviewMode; filePaths: Set<string>; onNavigate: (path: string) => void }) {
const rawURL = skillFileRawURL(skill.skill_id, file.path);
if (content.binary) {
if (file.kind === "image") return <div className="skill-package-media-stage"><img src={rawURL} alt={file.path} /></div>;
if (file.kind === "video") return <div className="skill-package-media-stage"><video src={rawURL} controls playsInline preload="metadata" /></div>;
if (file.kind === "audio") return <div className="skill-package-media-stage"><audio src={rawURL} controls preload="metadata" /></div>;
return <div className="skill-package-empty"><FileArchive className="mb-3 size-9" /><div>该文件不支持在线预览</div><a className="mt-3 inline-flex items-center gap-1 text-foreground underline" href={rawURL} target="_blank" rel="noreferrer">打开原始文件<ExternalLink className="size-3.5" /></a></div>;
}
if (file.kind === "markdown" && mode === "preview") {
return <SkillMarkdown source={content.content} currentPath={file.path} filePaths={filePaths} onNavigate={onNavigate} />;
}
return <pre className="skill-package-source"><code>{content.content}</code></pre>;
}
function SkillMarkdown({ source, currentPath, filePaths, onNavigate }: { source: string; currentPath: string; filePaths: Set<string>; onNavigate: (path: string) => void }) {
const navigate = (event: MouseEvent<HTMLAnchorElement>, href?: string) => {
const target = resolveSkillPath(currentPath, href || "");
if (!target || !filePaths.has(target)) return;
event.preventDefault();
onNavigate(target);
};
return (
<section aria-labelledby="skill-showcase-title">
<h2 id="skill-showcase-title" className="mb-2 text-sm font-semibold">展示案例</h2>
<div className="thin-scrollbar flex snap-x gap-3 overflow-x-auto pb-2">
{skill.showcase_media.map((media, index) => (
<div key={`${media.showcase_url}-${index}`} className="aspect-video w-[min(78vw,420px)] shrink-0 snap-start overflow-hidden rounded-md border border-border/70 bg-black/90">
{media.type === "video" ? <video className="h-full w-full object-contain" controls playsInline preload="metadata" src={media.showcase_url} /> : <img className="h-full w-full object-contain" src={media.showcase_url} alt={`${skill.skill_name} 展示案例 ${index + 1}`} width={840} height={472} loading="lazy" />}
</div>
))}
</div>
</section>
<article className="skill-package-markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ children, href }) => <a href={href} target={isExternalURL(href) ? "_blank" : undefined} rel={isExternalURL(href) ? "noreferrer" : undefined} onClick={(event) => navigate(event, href)}>{children}</a>,
table: ({ children }) => <div className="skill-package-table-wrap"><table>{children}</table></div>,
img: ({ src, alt }) => {
if (isExternalURL(src)) return <img src={src} alt={alt || ""} loading="lazy" />;
const target = resolveSkillPath(currentPath, src || "");
if (target && filePaths.has(target)) return <button type="button" className="skill-package-inline-file" onClick={() => onNavigate(target)}><FileImage className="size-4" />{alt || target}</button>;
return <span className="skill-package-inline-missing">{alt || src || "图片"}</span>;
},
}}
>{source}</ReactMarkdown>
</article>
);
}
function buildSkillTree(files: SkillPackageFile[]): DataNode[] {
type MutableNode = DataNode & { children?: MutableNode[]; file?: SkillPackageFile };
const roots: MutableNode[] = [];
const folders = new Map<string, MutableNode>();
for (const file of files) {
const parts = file.path.split("/");
let children = roots;
let prefix = "";
parts.forEach((part, index) => {
const isLeaf = index === parts.length - 1;
prefix = prefix ? `${prefix}/${part}` : part;
if (isLeaf) {
children.push({ key: file.path, title: treeTitle(part, file), icon: fileIcon(file), isLeaf: true, file });
return;
}
let folder = folders.get(prefix);
if (!folder) {
folder = {
key: `folder:${prefix}`,
title: <span className="skill-package-folder-title">{part}</span>,
icon: ({ expanded }) => expanded ? <FolderOpen aria-hidden="true" className="size-4" /> : <Folder aria-hidden="true" className="size-4" />,
children: [],
};
folders.set(prefix, folder);
children.push(folder);
}
children = folder.children || [];
});
}
return roots;
}
function treeTitle(name: string, file: SkillPackageFile): ReactNode {
return <span className="skill-package-tree-title"><span>{name}</span><span>{formatBytes(file.size)}</span></span>;
}
function fileIcon(file: SkillPackageFile) {
if (file.kind === "markdown" || file.kind === "text") return <FileText aria-hidden="true" className="size-3.5" />;
if (file.kind === "code") return <FileCode2 aria-hidden="true" className="size-3.5" />;
if (file.kind === "image") return <FileImage aria-hidden="true" className="size-3.5" />;
return <File aria-hidden="true" className="size-3.5" />;
}
function resolveSkillPath(currentPath: string, href: string) {
const cleanHref = href.split("#", 1)[0]?.split("?", 1)[0] || "";
if (!cleanHref || isExternalURL(cleanHref) || cleanHref.startsWith("/")) return "";
const base = currentPath.split("/").slice(0, -1);
let decoded = cleanHref;
try {
decoded = decodeURIComponent(cleanHref);
} catch {
return "";
}
for (const segment of decoded.replaceAll("\\", "/").split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") base.pop();
else base.push(segment);
}
return base.join("/");
}
function isExternalURL(value?: string) {
return Boolean(value && /^(https?:|mailto:|data:)/i.test(value));
}
function sourceLabel(source: string) {
if (source === "github") return "GitHub";
if (source === "zip") return "ZIP 技能包";
if (source === "builtin") return "内置技能";
return "Markdown";
}
function formatBytes(value: number) {
if (!value) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
+8 -7
View File
@@ -18,6 +18,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
const [draftIdea, setDraftIdea] = useState("");
const [drafting, setDrafting] = useState(false);
const effectiveConfig = useEffectiveConfig();
const isPackageSkill = Boolean(skill && skill.source_type !== "markdown" && skill.source_type !== "builtin" && skill.source_type !== "");
useEffect(() => {
if (!open) return;
@@ -27,7 +28,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
instruction: skill?.instruction || "",
tag: skill?.tag || "creative",
is_public: skill ? !skill.is_private : true,
markdown_url: skill?.markdown_url || "",
markdown_url: skill?.markdown_url || skill?.source_url || "",
showcase_media: skill?.showcase_media || [],
extra_info: skill?.extra_info || "",
});
@@ -48,7 +49,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
const input: SkillMutationInput = {
skill_name: values.skill_name,
description: values.description,
instruction: values.instruction,
instruction: values.instruction || "",
tag: values.tag,
is_private: !values.is_public,
markdown_url: values.markdown_url || "",
@@ -97,7 +98,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
return (
<Drawer className="library-drawer" open={open} size={720} destroyOnHidden maskClosable={!dirty} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
<div className="mb-4 rounded-xl border bg-foreground/[.02] p-3">
{!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 起草
@@ -116,7 +117,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
<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> : <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="skill_name" label="技能名称" rules={[{ required: true, message: "请填写技能名称" }, { max: 80, message: "最多 80 个字符" }]}>
@@ -131,12 +132,12 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
<Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} maxLength={500} showCount placeholder="说明适用场景、输入条件和最终产出" />
</Form.Item>
<Form.Item name="instruction" label="技能指令" rules={[{ required: true, message: "请填写技能指令" }, { max: 100000, message: "最多 100000 个字符" }]} extra="画布引用该技能时会把这段指令发送给模型。">
{!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>
</Form.Item> : null}
<div className="grid gap-x-4 sm:grid-cols-[minmax(0,1fr)_180px]">
<Form.Item name="markdown_url" label="Markdown 地址" rules={[{ type: "url", message: "请输入有效的 HTTP(S) 链接" }]}>
<Form.Item name="markdown_url" 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="公开后其他用户可以加入使用。">
@@ -0,0 +1,145 @@
import { App, Button, Form, Input, Modal, Segmented, Select, Switch, Upload, type UploadFile } from "antd";
import { FileArchive, FileText, GitBranch, UploadCloud } from "lucide-react";
import { useEffect, useState } from "react";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { installGitHubSkill, installSkillUpload, type Skill } from "@/services/api/skills";
type InstallMode = "markdown" | "zip" | "github";
type InstallFormValues = {
name?: string;
description?: string;
tag: string;
is_public: boolean;
url?: string;
ref?: string;
subdir?: string;
auto_update: boolean;
};
const modeOptions = [
{ value: "markdown", label: <span className="inline-flex items-center gap-1.5"><FileText className="size-3.5" />Markdown</span> },
{ value: "zip", label: <span className="inline-flex items-center gap-1.5"><FileArchive className="size-3.5" />ZIP 技能包</span> },
{ value: "github", label: <span className="inline-flex items-center gap-1.5"><GitBranch className="size-3.5" />GitHub</span> },
];
export function SkillInstallModal({ open, onClose, onInstalled, onManualCreate }: { open: boolean; onClose: () => void; onInstalled: (skill: Skill) => void; onManualCreate: () => void }) {
const { message } = App.useApp();
const [form] = Form.useForm<InstallFormValues>();
const [mode, setMode] = useState<InstallMode>("markdown");
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [installing, setInstalling] = useState(false);
useEffect(() => {
if (!open) return;
setMode("markdown");
setFileList([]);
form.setFieldsValue({ tag: "creative", is_public: true, auto_update: true, name: "", description: "", url: "", ref: "", subdir: "" });
}, [form, open]);
const install = async () => {
const values = await form.validateFields();
const file = fileList[0]?.originFileObj;
if (mode !== "github" && !file) {
message.warning(`请选择一个 ${mode === "zip" ? "ZIP 技能包" : "Markdown 文件"}`);
return;
}
setInstalling(true);
try {
const result = mode === "github"
? await installGitHubSkill({
url: values.url || "",
ref: values.ref || undefined,
subdir: values.subdir || undefined,
tag: values.tag,
is_private: !values.is_public,
auto_update: values.auto_update,
})
: await installSkillUpload({
file: file as File,
source_type: mode,
name: values.name || undefined,
description: values.description || undefined,
tag: values.tag,
is_private: !values.is_public,
});
message.success("技能已安装");
onInstalled(result.skill);
} catch (error) {
message.error(error instanceof Error ? error.message : "技能安装失败");
} finally {
setInstalling(false);
}
};
return (
<Modal
className="skill-install-modal"
open={open}
width={680}
destroyOnHidden
maskClosable={!installing}
title="安装技能"
onCancel={onClose}
footer={(
<div className="flex items-center justify-between gap-3">
<Button type="text" onClick={onManualCreate}>从空白创建单文件技能</Button>
<div className="flex gap-2"><Button onClick={onClose}>取消</Button><Button type="primary" loading={installing} onClick={() => void install()}>安装技能</Button></div>
</div>
)}
>
<p className="mb-4 text-sm leading-6 text-foreground/55">支持标准 <code>SKILL.md</code>、包含多层目录的 ZIP 技能包,或公开 GitHub 仓库。名称和简介会优先从技能入口自动读取。</p>
<Segmented className="skill-install-mode" block options={modeOptions} value={mode} onChange={(value) => { setMode(value as InstallMode); setFileList([]); }} />
<Form form={form} layout="vertical" requiredMark="optional" className="skill-install-form">
{mode === "github" ? (
<>
<Form.Item name="url" label="GitHub 地址" rules={[{ required: true, message: "请填写 GitHub 仓库地址" }, { type: "url", message: "请输入有效链接" }]}>
<Input type="url" inputMode="url" spellCheck={false} prefix={<GitBranch className="size-4 text-foreground/35" />} placeholder="https://github.com/owner/repository" />
</Form.Item>
<div className="grid gap-x-3 sm:grid-cols-2">
<Form.Item name="ref" label="分支或标签" extra="留空时使用默认分支"><Input spellCheck={false} placeholder="main" /></Form.Item>
<Form.Item name="subdir" label="技能子目录" extra="仓库仅含一个技能时可留空"><Input spellCheck={false} placeholder="skills/ai-director" /></Form.Item>
</div>
</>
) : (
<>
<Upload.Dragger
accept={mode === "zip" ? ".zip,application/zip" : ".md,.markdown,text/markdown"}
maxCount={1}
fileList={fileList}
beforeUpload={(file) => {
if (file.size > 20 * 1024 * 1024) {
message.error("技能文件不能超过 20MB");
return Upload.LIST_IGNORE;
}
return false;
}}
onChange={({ fileList: next }) => setFileList(next.slice(-1))}
onRemove={() => { setFileList([]); return true; }}
>
<UploadCloud className="mx-auto mb-3 size-8 text-foreground/38" />
<div className="text-sm font-medium">拖入或选择 {mode === "zip" ? "ZIP 技能包" : "Markdown 文件"}</div>
<div className="mt-1 text-xs text-foreground/45">{mode === "zip" ? "根目录或唯一子目录中必须包含 SKILL.md" : "普通 .md 会作为技能入口 SKILL.md 安装"}</div>
</Upload.Dragger>
<div className="mt-4 grid gap-x-3 sm:grid-cols-2">
<Form.Item name="name" label="覆盖名称" extra="可选,留空时自动读取"><Input maxLength={80} autoComplete="off" /></Form.Item>
<Form.Item name="description" label="覆盖简介" extra="可选,留空时自动读取"><Input maxLength={500} autoComplete="off" /></Form.Item>
</div>
</>
)}
<div className="grid gap-x-3 sm:grid-cols-2">
<Form.Item name="tag" label="技能分类" rules={[{ required: true, message: "请选择技能分类" }]}>
<Select options={fallbackSkillCategories} />
</Form.Item>
<Form.Item name="is_public" label="公开状态" valuePropName="checked" extra="公开后其他用户可以加入使用。">
<Switch checkedChildren="公开" unCheckedChildren="私有" />
</Form.Item>
</div>
{mode === "github" ? <Form.Item name="auto_update" label="自动同步" valuePropName="checked" extra="后台每 6 小时检查一次提交版本,并记录最近检查与同步时间。"><Switch checkedChildren="开启" unCheckedChildren="关闭" /></Form.Item> : null}
</Form>
</Modal>
);
}