import { App, Button, Input, Modal, Select, Switch, Typography } from "antd"; import { AudioLines, CalendarDays, CheckCircle2, Clock3, ExternalLink, Film, FolderOpen, Image as ImageIcon, MessageSquareText, PlugZap, RefreshCw, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry"; import "@/lib/plugins/builtin"; import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle"; import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types"; import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle"; import { fetchPlugins, setUserPluginEnabled, type BackendPlugin, type PluginState } from "@/services/api/plugins"; import { usePluginStore } from "@/stores/use-plugin-store"; import { useUserStore } from "@/stores/use-user-store"; import { PluginDetailsModal } from "./plugin-documentation-modals"; import "./plugins.css"; const categoryLabels: Record = { provider: "模型渠道", "canvas-node": "画布节点", workflow: "工作流", transform: "媒体转换", "asset-source": "素材来源", "ai-capability": "AI 能力", "usage-observer": "用量观察", agent: "智能体", "import-export": "导入导出", }; const surfaceLabels: Record = { node: "画布节点", fullscreen: "全屏工作台", hybrid: "混合接入", "asset-source": "素材库", }; const permissionLabels: Record = { "canvas.read": "读取画布", "canvas.write": "修改画布", "asset.read": "读取素材", "asset.search": "搜索素材", "asset.import": "导入素材", "asset.upload": "上传素材", "generation.run": "调用生成", "ai.text": "调用已配置的文本/视觉理解模型", "external.open": "打开外部详情", }; const pluginDateFormatter = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "short", day: "numeric" }); const protocolSectionMeta = [ { key: "text", label: "文本协议", description: "对话、推理与流式响应", icon: MessageSquareText }, { key: "image", label: "图片协议", description: "生成、编辑与参考图", icon: ImageIcon }, { key: "video", label: "视频协议", description: "创建任务、轮询与媒体结果", icon: Film }, { key: "audio", label: "音频协议", description: "语音与异步音频任务", icon: AudioLines }, ] as const; export default function PluginsPage() { const { message } = App.useApp(); const navigate = useNavigate(); const user = useUserStore((state) => state.user); const features = useUserStore((state) => state.features); const installations = usePluginStore((state) => state.installations); const ensurePlugin = usePluginStore((state) => state.ensurePlugin); const setEnabled = usePluginStore((state) => state.setEnabled); const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses); const setPluginStates = usePluginStore((state) => state.setPluginStates); const pluginStates = usePluginStore((state) => state.pluginStates); const updateConfig = usePluginStore((state) => state.updateConfig); const builtinPlugins = useMemo(() => listRegisteredPlugins(), []); const [backendPlugins, setBackendPlugins] = useState([]); const [backendPluginsLoading, setBackendPluginsLoading] = useState(false); const [settingsPluginId, setSettingsPluginId] = useState(null); const [detailsPluginId, setDetailsPluginId] = useState(null); const [detailsRestoreFocus, setDetailsRestoreFocus] = useState(false); const [search, setSearch] = useState(""); const [categoryFilter, setCategoryFilter] = useState("all"); const [scrollTarget, setScrollTarget] = useState(null); const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all"); const [trustFilter, setTrustFilter] = useState<"all" | "trusted">("all"); const [eagleBaseUrl, setEagleBaseUrl] = useState("http://localhost:41595"); const [eagleAutoUploadGenerated, setEagleAutoUploadGenerated] = useState(true); const [eagleGeneratedFolderId, setEagleGeneratedFolderId] = useState(""); const [eagleFolders, setEagleFolders] = useState([]); const [eagleFoldersLoading, setEagleFoldersLoading] = useState(false); const [eagleFoldersError, setEagleFoldersError] = useState(""); const sectionRefs = useRef>({}); useEffect(() => { for (const plugin of builtinPlugins) ensurePlugin(plugin.manifest); }, [builtinPlugins, ensurePlugin]); const reloadBackendPlugins = async () => { setBackendPluginsLoading(true); try { const result = await fetchPlugins(); setBackendPlugins(result.plugins); setPluginStates(result.states); } catch (error) { message.error(error instanceof Error ? error.message : "读取插件中心失败"); setBackendPlugins([]); } finally { setBackendPluginsLoading(false); } }; useEffect(() => { void reloadBackendPlugins(); }, [user?.id]); const remotePlugins = useMemo(() => backendPlugins.map(toRegisteredPlugin), [backendPlugins]); const registeredPlugins = useMemo(() => { const byId = new Map(builtinPlugins.map((plugin) => [plugin.manifest.id, plugin])); for (const plugin of remotePlugins) byId.set(plugin.manifest.id, plugin); return [...byId.values()]; }, [builtinPlugins, remotePlugins]); const backendPluginById = useMemo(() => new Map(backendPlugins.map((plugin) => [plugin.manifest.id, plugin])), [backendPlugins]); const eagle = installations.find((item) => item.manifest.id === EAGLE_PLUGIN_ID); useEffect(() => { const configured = eagle?.config.baseUrl; if (typeof configured === "string" && configured.trim()) setEagleBaseUrl(configured); const autoUpload = eagle?.config.autoUploadGenerated; setEagleAutoUploadGenerated(autoUpload !== false && autoUpload !== "false"); const folderId = eagle?.config.generatedFolderId; setEagleGeneratedFolderId(typeof folderId === "string" ? folderId : ""); }, [eagle?.config.baseUrl, eagle?.config.autoUploadGenerated, eagle?.config.generatedFolderId]); const filteredPlugins = useMemo(() => { const normalizedSearch = search.trim().toLocaleLowerCase(); return registeredPlugins.filter((plugin) => { const state = pluginStates[plugin.manifest.id]; const isApplicationPlugin = backendPluginById.get(plugin.manifest.id)?.management.kind === "application" || isOfficialApplicationPlugin(plugin.manifest.id); if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && !isApplicationPlugin) return false; const installation = installations.find((item) => item.manifest.id === plugin.manifest.id); const enabled = state?.effectiveEnabled ?? Boolean(installation?.enabled); const manifest = plugin.manifest; const contributionKinds = contributionKindsFor(manifest); const searchableText = [manifest.name, manifest.description, manifest.author, manifest.id, ...contributionKinds.map((kind) => categoryLabels[kind] || kind)].filter(Boolean).join(" ").toLocaleLowerCase(); if (normalizedSearch && !searchableText.includes(normalizedSearch)) return false; if (categoryFilter !== "all") { const providerCapabilities = providerCapabilitiesFor(manifest); const matchesCapability = providerCapabilities.includes(categoryFilter as "text" | "image" | "video" | "audio"); const matchesApp = categoryFilter === "other" && contributionKinds.length > 0 && providerCapabilities.length === 0; if (!matchesCapability && !matchesApp) return false; } if (trustFilter === "trusted" && !manifest.trusted) return false; if (statusFilter === "enabled" && !enabled) return false; if (statusFilter === "disabled" && enabled) return false; return true; }); }, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, pluginStates, registeredPlugins, search, statusFilter, trustFilter, user?.role]); const pluginSections = useMemo( () => [ ...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => providerCapabilitiesFor(plugin.manifest).includes(section.key)) })), { key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => !providerCapabilitiesFor(plugin.manifest).length) }, ], [filteredPlugins], ); const selectCategory = (key: string) => { setCategoryFilter(key); setScrollTarget(key === "all" ? null : key); }; useEffect(() => { if (!scrollTarget) return; const section = sectionRefs.current[scrollTarget]; if (!section) return; const frame = window.requestAnimationFrame(() => { const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; section.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "start" }); setScrollTarget(null); }); return () => window.cancelAnimationFrame(frame); }, [pluginSections, scrollTarget]); const categoryCounts = useMemo(() => { const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPlugin(plugin.manifest.id)); const counts: Record = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, other: 0 }; for (const plugin of visiblePlugins) { const capabilities = providerCapabilitiesFor(plugin.manifest); if (!capabilities.length) counts.other += 1; for (const capability of capabilities) { counts[capability] = (counts[capability] || 0) + 1; } } return counts; }, [features.systemPluginsVisibleToUsers, registeredPlugins, user?.role]); const settingsPlugin = settingsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === settingsPluginId) : undefined; const settingsInstallation = settingsPlugin ? installations.find((item) => item.manifest.id === settingsPlugin.manifest.id) : undefined; const settingsEnabled = settingsPlugin ? (pluginStates[settingsPlugin.manifest.id]?.effectiveEnabled ?? Boolean(settingsInstallation?.enabled)) : false; const detailsPlugin = detailsPluginId ? registeredPlugins.find((plugin) => plugin.manifest.id === detailsPluginId) : undefined; const hasPluginConfiguration = (plugin: RegisteredPlugin) => Boolean(plugin.manifest.configuration?.fields?.length); const canConfigurePlugin = (plugin: RegisteredPlugin) => Boolean(pluginStates[plugin.manifest.id]?.canConfigure) && (hasPluginConfiguration(plugin) || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID || plugin.manifest.id === COMFYUI_PLUGIN_ID); const isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) => pluginStates[plugin.manifest.id]?.effectiveEnabled ?? Boolean(installation?.enabled); const togglePlugin = async (plugin: RegisteredPlugin, enabled: boolean) => { try { const next = await setUserPluginEnabled(plugin.manifest.id, enabled); setEnabled(plugin.manifest.id, enabled); setPluginStates({ ...usePluginStore.getState().pluginStates, [next.pluginId]: next }); if (next.pluginId === RUNNINGHUB_PLUGIN_ID || next.pluginId === COMFYUI_PLUGIN_ID) { setRuntimeStatuses({ ...usePluginStore.getState().runtimeStatuses, [next.pluginId]: next.effectiveEnabled ? "enabled" : "disabled" }); } message.success(`${plugin.manifest.name}${enabled ? "已启用" : "已停用"}`); } catch (error) { message.error(error instanceof Error ? error.message : "更新插件状态失败"); } }; const loadEagleFolders = async (url = eagleBaseUrl) => { setEagleFoldersLoading(true); setEagleFoldersError(""); try { const result = await getEagleLibrary(url.trim().replace(/\/$/, "")); setEagleFolders(result.library.folders || []); } catch (reason) { setEagleFoldersError(reason instanceof Error ? reason.message : "读取 Eagle 文件夹失败"); setEagleFolders([]); } finally { setEagleFoldersLoading(false); } }; const saveEagleConfig = () => { const baseUrl = eagleBaseUrl.trim().replace(/\/$/, ""); if (!/^https?:\/\//i.test(baseUrl)) { message.error("Eagle 地址必须以 http:// 或 https:// 开头"); return; } updateConfig(EAGLE_PLUGIN_ID, { baseUrl, autoUploadGenerated: eagleAutoUploadGenerated, generatedFolderId: eagleGeneratedFolderId }); message.success("Eagle 插件配置已保存"); }; return (
} value={search} allowClear placeholder="搜索插件名称、描述或作者" onChange={(event) => setSearch(event.target.value)} /> setTrustFilter(value as "all" | "trusted")} aria-label="按来源筛选" />
{user?.role === "admin" ? : null}
{filteredPlugins.length ? (
{pluginSections.map((section) => { if (!section.plugins.length) return null; const SectionIcon = section.icon; return (
{ sectionRefs.current[section.key] = element; }} className="plugin-section" >

{section.label}

{section.description}

{section.plugins.length}
{section.plugins.map((plugin) => { const installation = installations.find((item) => item.manifest.id === plugin.manifest.id); const remote = backendPluginById.get(plugin.manifest.id); const enabled = isPluginEnabled(plugin, installation); const trusted = Boolean(plugin.manifest.trusted); const state = pluginStates[plugin.manifest.id]; const sourceLabel = pluginSourceLabel(plugin, state); const canConfigure = canConfigurePlugin(plugin); return (