feat(plugin): 插件系统 - 新增插件中心与可展开配置

This commit is contained in:
QAyong
2026-08-21 19:13:34 +08:00
parent 2d8d72d1c4
commit ce5af1b6c8
12 changed files with 2659 additions and 16 deletions

View File

@@ -26,6 +26,7 @@
"@streamdown/code": "^1.1.1",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-virtual": "^3.14.8",
"@tiptap/core": "3.30.1",
"@tiptap/extension-character-count": "^3.28.0",
"@tiptap/extension-color": "^3.28.0",
"@tiptap/extension-highlight": "^3.28.0",
@@ -69,11 +70,11 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@vitejs/plugin-react": "^6.0.3",
"@types/file-saver": "^2.0.7",
"@types/node": "^20.19.43",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"prettier": "^3.9.5",
"typescript": "^7.0.2",
"vite": "^8.1.5"

2244
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
heading: "工作台管理",
items: [
toolItem("skills", "/skills"),
toolItem("plugins", "/plugins"),
...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : []),
],
},

View File

@@ -1,4 +1,4 @@
import { CircleDollarSign, Clapperboard, Images, LibraryBig, ListTodo, PanelsTopLeft, Settings, WandSparkles } from "lucide-react";
import { Blocks, CircleDollarSign, Clapperboard, Images, LibraryBig, ListTodo, PanelsTopLeft, Settings, WandSparkles } from "lucide-react";
export const navigationTools = [
{
@@ -37,6 +37,12 @@ export const navigationTools = [
icon: LibraryBig,
section: "工作台管理",
},
{
slug: "plugins",
label: "插件中心",
icon: Blocks,
section: "工作台管理",
},
{
slug: "wallet",
label: "积分中心",

View File

@@ -0,0 +1,22 @@
import { registerPlugin } from "../plugin-registry";
import type { RegisteredPlugin } from "../plugin-types";
export const EAGLE_PLUGIN_ID = "eagle-asset-connector";
export const eagleAssetPlugin: RegisteredPlugin = {
manifest: {
id: EAGLE_PLUGIN_ID,
name: "Eagle 素材库",
version: "0.1.0",
apiVersion: "1",
category: "asset-source",
description: "把 Eagle 作为影策素材管理中的外部素材来源。",
author: "影策社区",
surfaces: ["asset-source", "hybrid"],
permissions: ["asset.read", "asset.search", "asset.import", "asset.upload", "external.open"],
trusted: true,
},
};
registerPlugin(eagleAssetPlugin);

View File

@@ -0,0 +1,2 @@
import "./eagle";

View File

@@ -0,0 +1,36 @@
import type { PluginManifest, RegisteredPlugin } from "./plugin-types";
const registeredPlugins = new Map<string, RegisteredPlugin>();
function assertManifest(manifest: PluginManifest) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.id)) throw new Error("插件 ID 必须使用 kebab-case");
if (!manifest.name.trim() || !manifest.version.trim() || !manifest.apiVersion.trim()) throw new Error("插件清单缺少名称、版本或 API 版本");
if (!manifest.surfaces.length) throw new Error("插件至少需要声明一种界面形态");
if (new Set(manifest.permissions).size !== manifest.permissions.length) throw new Error("插件权限不能重复");
}
export function registerPlugin(plugin: RegisteredPlugin) {
assertManifest(plugin.manifest);
const existing = registeredPlugins.get(plugin.manifest.id);
if (existing && existing.manifest.version !== plugin.manifest.version) {
throw new Error(`插件 ${plugin.manifest.id} 已注册其他版本`);
}
registeredPlugins.set(plugin.manifest.id, plugin);
}
export function unregisterPlugin(pluginId: string) {
registeredPlugins.delete(pluginId);
}
export function getRegisteredPlugin(pluginId: string) {
return registeredPlugins.get(pluginId);
}
export function listRegisteredPlugins() {
return [...registeredPlugins.values()];
}
export function listRegisteredManifests(): PluginManifest[] {
return listRegisteredPlugins().map(({ manifest }) => manifest);
}

View File

@@ -0,0 +1,20 @@
import { localForageStorageForScope } from "@/lib/localforage-storage";
export function createPluginStorage(pluginId: string) {
const storage = localForageStorageForScope();
const keyFor = (key: string) => `infinite-canvas:plugin:${pluginId}:${key}`;
return {
get: async <T>(key: string) => {
const value = await storage.getItem(keyFor(key));
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
throw new Error(`插件 ${pluginId} 的配置数据损坏`);
}
},
set: async <T>(key: string, value: T) => storage.setItem(keyFor(key), JSON.stringify(value)),
remove: (key: string) => storage.removeItem(keyFor(key)),
};
}

View File

@@ -0,0 +1,91 @@
import type { Asset } from "@/stores/use-asset-store";
export const PLUGIN_API_VERSION = "1" as const;
export type PluginCategory = "asset-source" | "canvas-node" | "workflow" | "ai-capability" | "import-export" | "agent";
export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source";
export type PluginPermission =
| "canvas.read"
| "canvas.write"
| "asset.read"
| "asset.search"
| "asset.import"
| "asset.upload"
| "generation.run"
| "external.open";
export type PluginManifest = {
id: string;
name: string;
version: string;
apiVersion: string;
category: PluginCategory;
description: string;
author?: string;
entry?: string;
surfaces: PluginSurface[];
permissions: PluginPermission[];
trusted?: boolean;
};
export type PluginStorage = {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T): Promise<void>;
remove(key: string): Promise<void>;
};
export type PluginHostContext = {
manifest: PluginManifest;
permissions: ReadonlySet<PluginPermission>;
storage: PluginStorage;
};
export type AssetSourceQuery = {
keyword?: string;
folderId?: string;
tags?: string[];
kind?: Asset["kind"];
limit?: number;
offset?: number;
signal?: AbortSignal;
};
export type ExternalAssetItem = {
id: string;
title: string;
kind: Asset["kind"];
thumbnailUrl?: string;
fileUrl?: string;
mimeType?: string;
width?: number;
height?: number;
bytes?: number;
tags?: string[];
folderId?: string;
description?: string;
metadata?: Record<string, unknown>;
};
export type AssetSourceProvider = {
list?: (query: AssetSourceQuery) => Promise<ExternalAssetItem[]>;
importAsset?: (item: ExternalAssetItem, signal?: AbortSignal) => Promise<Asset>;
uploadAsset?: (asset: Asset, signal?: AbortSignal) => Promise<ExternalAssetItem>;
openAsset?: (item: ExternalAssetItem) => Promise<void>;
};
export type RegisteredPlugin = {
manifest: PluginManifest;
activate?: (context: PluginHostContext) => Promise<void> | void;
deactivate?: (context: PluginHostContext) => Promise<void> | void;
createAssetSource?: (context: PluginHostContext) => AssetSourceProvider;
};
export type PluginInstallation = {
manifest: PluginManifest;
enabled: boolean;
config: Record<string, string | number | boolean>;
installedAt: string;
updatedAt: string;
lastError?: string;
};

View File

@@ -0,0 +1,184 @@
import { App, Button, Input, Switch, Tag, Typography } from "antd";
import { CheckCircle2, ChevronDown, ExternalLink, PlugZap, Settings2, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { usePluginStore } from "@/stores/use-plugin-store";
const categoryLabels: Record<string, string> = {
"asset-source": "素材来源",
"canvas-node": "画布节点",
workflow: "工作流",
"ai-capability": "AI 能力",
"import-export": "导入导出",
agent: "智能体",
};
const surfaceLabels: Record<string, string> = {
node: "画布节点",
fullscreen: "全屏工作台",
hybrid: "混合接入",
"asset-source": "素材库",
};
const permissionLabels: Record<string, string> = {
"canvas.read": "读取画布",
"canvas.write": "修改画布",
"asset.read": "读取素材",
"asset.search": "搜索素材",
"asset.import": "导入素材",
"asset.upload": "上传素材",
"generation.run": "调用生成",
"external.open": "打开外部详情",
};
export default function PluginsPage() {
const { message } = App.useApp();
const installations = usePluginStore((state) => state.installations);
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
const setEnabled = usePluginStore((state) => state.setEnabled);
const updateConfig = usePluginStore((state) => state.updateConfig);
const registeredPlugins = useMemo(() => listRegisteredPlugins(), []);
const [expandedPluginId, setExpandedPluginId] = useState<string | null>(null);
const [eagleBaseUrl, setEagleBaseUrl] = useState("http://localhost:41595");
useEffect(() => {
for (const plugin of registeredPlugins) ensurePlugin(plugin.manifest);
}, [ensurePlugin, registeredPlugins]);
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);
}, [eagle?.config.baseUrl]);
const saveEagleConfig = () => {
const baseUrl = eagleBaseUrl.trim().replace(/\/$/, "");
if (!/^https?:\/\//i.test(baseUrl)) {
message.error("Eagle 地址必须以 http:// 或 https:// 开头");
return;
}
updateConfig(EAGLE_PLUGIN_ID, { baseUrl });
message.success("Eagle 插件配置已保存");
};
return (
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
<header className="shrink-0 border-b border-[var(--workspace-border)] px-5 py-4 md:px-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="mb-2 flex items-center gap-2 text-[var(--fs-label)] text-foreground/45">
<PlugZap className="size-3.5" aria-hidden="true" />
<span>工作台管理</span>
<span>/</span>
<span>插件中心</span>
</div>
<h1 className="text-xl font-semibold tracking-tight md:text-2xl">插件中心</h1>
<p className="mt-1 text-sm text-foreground/55">管理可连接到影策的素材来源、画布能力和创作工作流。</p>
</div>
<Tag icon={<ShieldCheck className="size-3.5" />} color="gold">可信插件模式</Tag>
</div>
</header>
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto px-5 py-5 md:px-7 md:py-6">
<div className="mx-auto w-full max-w-5xl">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold">已接入插件</h2>
<p className="mt-1 text-xs text-foreground/48">插件状态和配置按当前账号隔离保存。</p>
</div>
<Tag className="m-0">{registeredPlugins.length} 个插件</Tag>
</div>
<div className="flex flex-col gap-3">
{registeredPlugins.map((plugin) => {
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
const enabled = Boolean(installation?.enabled);
const expanded = expandedPluginId === plugin.manifest.id;
const settingsId = `plugin-settings-${plugin.manifest.id}`;
return (
<section key={plugin.manifest.id} className="library-card-surface rounded-[var(--r-xl)] p-4 transition-colors duration-200 md:p-5">
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]">
<PlugZap className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-base font-semibold">{plugin.manifest.name}</h3>
<span className="whitespace-nowrap text-[var(--fs-tiny)] text-foreground/42">v{plugin.manifest.version}</span>
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2 py-1 text-[var(--fs-tiny)] text-foreground/58">{categoryLabels[plugin.manifest.category] ?? plugin.manifest.category}</span>
</div>
<p className="mt-1 max-w-3xl text-sm leading-6 text-foreground/55">{plugin.manifest.description}</p>
<div className="mt-3 flex flex-wrap gap-2">
{plugin.manifest.surfaces.map((surface) => <span key={surface} className="rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/62">{surfaceLabels[surface] ?? surface}</span>)}
<span className="rounded-full bg-[var(--library-icon-surface)] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.permissions.length} 项能力</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-3 lg:justify-end">
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
<i aria-hidden="true" />
{enabled ? "已启用" : "未启用"}
</span>
<Switch checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => setEnabled(plugin.manifest.id, checked)} />
<Button
className="min-h-9"
icon={<Settings2 className="size-4" />}
aria-expanded={expanded}
aria-controls={settingsId}
onClick={() => setExpandedPluginId((current) => current === plugin.manifest.id ? null : plugin.manifest.id)}
>
{expanded ? "收起设置" : "设置"}
<ChevronDown className={`ml-1 size-3.5 transition-transform duration-200${expanded ? " rotate-180" : ""}`} aria-hidden="true" />
</Button>
</div>
</div>
{expanded ? (
<div id={settingsId} className="mt-4 border-t border-[var(--workspace-border)] pt-4">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold">{plugin.manifest.name} 设置</h4>
<p className="mt-1 text-xs leading-5 text-foreground/48">只展示这个插件实际支持的配置项。</p>
</div>
<span className="whitespace-nowrap rounded-full bg-foreground/[.06] px-2.5 py-1 text-[var(--fs-tiny)] text-foreground/52">{plugin.manifest.trusted ? "可信插件" : "第三方插件"}</span>
</div>
{plugin.manifest.id === EAGLE_PLUGIN_ID ? (
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-end">
<div className="min-w-0 flex-1">
<label htmlFor="eagle-base-url" className="block text-xs font-medium text-foreground/58">Eagle 本地 API 地址</label>
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" className="mt-2 w-full" />
<p className="mt-2 text-xs leading-5 text-foreground/45">Eagle 必须在本机运行;网页直连可能受跨域限制,后续搜索和导入将通过本地桥接适配器完成。</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button type="primary" className="min-h-9" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>保存配置</Button>
<Button className="min-h-9" icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">查看 API</Button>
</div>
</div>
) : (
<div className="rounded-[var(--r-lg)] bg-foreground/[.035] p-4 text-sm text-foreground/55">该插件暂无可编辑设置项。当前接入位置和权限会根据插件清单自动生效。</div>
)}
<div className="mt-4 flex flex-wrap gap-x-6 gap-y-3 text-xs text-foreground/52">
<div><span className="mr-2 text-foreground/38">接入位置</span>{plugin.manifest.surfaces.map((surface) => surfaceLabels[surface] ?? surface).join("、")}</div>
<div><span className="mr-2 text-foreground/38">插件能力</span>{plugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}</div>
</div>
</div>
) : null}
{installation?.lastError ? <Typography.Text type="danger" className="mt-3 block text-xs" role="alert">{installation.lastError}</Typography.Text> : null}
</section>
);
})}
</div>
</div>
</div>
</main>
);
}

View File

@@ -37,6 +37,7 @@ const CreatePage = lazy(loadCreatePage);
const HomePage = lazy(() => import("@/pages/home"));
const NotFound = lazy(() => import("@/pages/not-found"));
const SkillsPage = lazy(() => import("@/pages/skills"));
const PluginsPage = lazy(() => import("@/pages/plugins"));
const TasksPage = lazy(() => import("@/pages/tasks"));
const WalletPage = lazy(loadWalletPage);
const ProjectsPage = lazy(loadProjectsPage);
@@ -87,6 +88,7 @@ export const router = createBrowserRouter([
},
{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> },
{ path: "/skills", element: <RequireAuth>{deferred(<SkillsPage />)}</RequireAuth> },
{ path: "/plugins", element: <RequireAuth>{deferred(<PluginsPage />)}</RequireAuth> },
{
path: "/wallet",
element: (

View File

@@ -0,0 +1,62 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import type { PluginInstallation, PluginManifest } from "@/lib/plugins/plugin-types";
export const PLUGIN_STORE_KEY = "infinite-canvas:plugin-store";
type PluginStore = {
hydrated: boolean;
installations: PluginInstallation[];
ensurePlugin: (manifest: PluginManifest) => void;
setEnabled: (pluginId: string, enabled: boolean) => void;
updateConfig: (pluginId: string, config: Record<string, string | number | boolean>) => void;
setError: (pluginId: string, error?: string) => void;
removePlugin: (pluginId: string) => void;
};
function now() {
return new Date().toISOString();
}
export const usePluginStore = create<PluginStore>()(
persist(
(set) => ({
hydrated: false,
installations: [],
ensurePlugin: (manifest) =>
set((state) => {
const current = state.installations.find((item) => item.manifest.id === manifest.id);
if (current && current.manifest.version === manifest.version) return { hydrated: true };
const timestamp = now();
const next: PluginInstallation = current
? { ...current, manifest, updatedAt: timestamp }
: { manifest, enabled: false, config: {}, installedAt: timestamp, updatedAt: timestamp };
return { hydrated: true, installations: [...state.installations.filter((item) => item.manifest.id !== manifest.id), next] };
}),
setEnabled: (pluginId, enabled) =>
set((state) => ({
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, enabled, updatedAt: now(), lastError: undefined } : item),
})),
updateConfig: (pluginId, config) =>
set((state) => ({
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, config: { ...item.config, ...config }, updatedAt: now() } : item),
})),
setError: (pluginId, error) =>
set((state) => ({
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, lastError: error, updatedAt: now() } : item),
})),
removePlugin: (pluginId) => set((state) => ({ installations: state.installations.filter((item) => item.manifest.id !== pluginId) })),
}),
{
name: PLUGIN_STORE_KEY,
storage: createJSONStorage(() => localForageStorageForScope()),
partialize: (state) => ({ installations: state.installations }),
onRehydrateStorage: () => (state) => {
if (state) state.hydrated = true;
},
},
),
);