import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Input } from "antd"; import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react"; import { AppModal } from "@/components/ui/product/app-modal"; import type { CanvasTheme } from "@/lib/canvas-theme"; import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills"; type SkillLibraryTab = "enabled" | "installed" | "market"; type CanvasAgentSkillLibraryModalProps = { open: boolean; theme: CanvasTheme; installedSkills: Skill[]; marketSkills: Skill[]; selectedSkillIds: string[]; categories: SkillCategory[]; libraryCategories: SkillLibraryCategory[]; category: string; search: string; loading: boolean; hasMore: boolean; onClose: () => void; onCategoryChange: (value: string) => void; onSearch: (value: string) => void; onToggle: (skillId: string) => void; onInstall: (skill: Skill) => Promise; onLoadMore: () => Promise; }; export function CanvasAgentSkillLibraryModal({ open, theme, installedSkills, marketSkills, selectedSkillIds, categories, libraryCategories, category, search, loading, hasMore, onClose, onCategoryChange, onSearch, onToggle, onInstall, onLoadMore, }: CanvasAgentSkillLibraryModalProps) { const [tab, setTab] = useState("market"); const [libraryCategoryId, setLibraryCategoryId] = useState("all"); const listRef = useRef(null); const loadMoreRef = useRef(null); const wasOpenRef = useRef(false); const selectedCount = selectedSkillIds.length; useEffect(() => { if (open && !wasOpenRef.current) { setTab("market"); setLibraryCategoryId("all"); onCategoryChange("all"); } wasOpenRef.current = open; }, [onCategoryChange, open]); useEffect(() => { listRef.current?.scrollTo({ top: 0 }); }, [category, libraryCategoryId, search, tab]); const librarySource = useMemo(() => tab === "enabled" ? installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)) : installedSkills, [installedSkills, selectedSkillIds, tab]); const libraryCategoryCounts = useMemo(() => { const counts = new Map(); librarySource.forEach((skill) => { const categoryId = skill.libraryCategoryId || "__uncategorized__"; counts.set(categoryId, (counts.get(categoryId) || 0) + 1); }); return counts; }, [librarySource]); const visibleSkills = useMemo(() => { const keyword = search.trim().toLocaleLowerCase("zh-CN"); const source = tab === "market" ? marketSkills : librarySource; return source.filter((skill) => { if (tab === "market" && category !== "all" && skill.tag !== category) return false; if (tab !== "market") { if (libraryCategoryId === "__uncategorized__" && skill.libraryCategoryId) return false; if (libraryCategoryId !== "all" && libraryCategoryId !== "__uncategorized__" && skill.libraryCategoryId !== libraryCategoryId) return false; } if (!keyword) return true; return `${skill.skillName} ${skill.description || ""} ${skill.instruction || ""} ${skill.extraInfo || ""} ${skill.effectiveUser?.name || ""} ${categories.find((item) => item.value === skill.tag)?.label || ""}` .toLocaleLowerCase("zh-CN") .includes(keyword); }); }, [categories, category, libraryCategoryId, librarySource, marketSkills, search, tab]); useEffect(() => { const target = loadMoreRef.current; if (tab !== "market" || !target || !hasMore || loading || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) void onLoadMore(); }, { root: listRef.current, rootMargin: "180px 0px" }); observer.observe(target); return () => observer.disconnect(); }, [hasMore, loading, onLoadMore, tab, visibleSkills.length]); const categoryItems = useMemo(() => { const unique = new Map(); categories.forEach((item) => { if (item.value && item.value !== "all" && item.label) unique.set(item.value, item); }); return [...unique.values()]; }, [categories]); const marketplaceCategoryTotal = useMemo( () => categoryItems.reduce((total, item) => total + (item.count || 0), 0), [categoryItems], ); const emptyText = tab === "enabled" ? selectedCount === 0 ? "本轮还没有启用技能,可在“我的技能”中选择" : "没有匹配的已启用技能" : tab === "installed" ? "还没有匹配的已加入技能,可前往“全部”添加" : "没有匹配的公开技能,换个关键词或分类试试"; const changeTab = (value: SkillLibraryTab) => { setTab(value); setLibraryCategoryId("all"); onCategoryChange("all"); }; const selectMarketplaceCategory = (value: string) => { setTab("market"); setLibraryCategoryId("all"); onCategoryChange(value); }; const selectLibraryCategory = (value: string) => { setTab((current) => current === "enabled" ? "enabled" : "installed"); setLibraryCategoryId(value); onCategoryChange("all"); }; return ( (
{modal}
)} onCancel={onClose} flush >

技能商店

{visibleSkills.map((skill) => ( onToggle(skill.skillId)} onInstall={() => onInstall(skill)} /> ))} {loading && visibleSkills.length === 0 ? (
) : null} {!loading && visibleSkills.length === 0 ? (
) : null} {tab === "market" && hasMore ?
{tab === "enabled" ? `${visibleSkills.length} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`} {tab === "market" && hasMore ? ( ) : {tab === "market" ? "已加载全部" : "最多启用 8 个"}}
); } function SkillTab({ active, label, count, onClick }: { active: boolean; label: string; count?: number; onClick: () => void }) { return ( ); } function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onToggle, onInstall }: { skill: Skill; theme: CanvasTheme; categories: SkillCategory[]; selected: boolean; canSelect: boolean; onToggle: () => void; onInstall: () => Promise; }) { const [installing, setInstalling] = useState(false); const [coverFailed, setCoverFailed] = useState(false); const cover = skill.showcaseMedia?.find((item) => item.type === "image" && item.showcaseUrl) || skill.showcaseMedia?.find((item) => item.showcaseUrl); const coverUrl = cover?.showcaseUrl; const hasCover = Boolean(coverUrl && !coverFailed); const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他"; const author = skill.effectiveUser?.name || "影策创作者"; const addedCount = formatSkillCount(skill.addedCount || 0); useEffect(() => setCoverFailed(false), [coverUrl]); const install = async () => { if (installing) return; setInstalling(true); try { await onInstall(); } finally { setInstalling(false); } }; return (
{hasCover ? (
{cover?.type === "video" ? (
) : null}
{!hasCover ? {categoryLabel} : null}

{skill.skillName}

{skill.isAdded ? ( ) : ( )}
{author} {skill.version ? <>v{skill.version} : null}

{skill.description || "暂无技能说明"}

); } function formatSkillCount(value: number) { return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(value); }