feat(*): 短剧创作工作台 - 完善章节分镜、资产管理、技能运行与生成任务恢复
This commit is contained in:
1 parent
4f07daae9e
commit
2f6832f99c
113 files changed
+7016
-723
No files matched your search
@@ -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(); }} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user