feat(plugin): 插件系统 - 新增插件中心与可展开配置
This commit is contained in:
@@ -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
2244
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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: "积分中心",
|
||||
|
||||
22
web/src/lib/plugins/builtin/eagle.ts
Normal file
22
web/src/lib/plugins/builtin/eagle.ts
Normal 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);
|
||||
|
||||
2
web/src/lib/plugins/builtin/index.ts
Normal file
2
web/src/lib/plugins/builtin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "./eagle";
|
||||
|
||||
36
web/src/lib/plugins/plugin-registry.ts
Normal file
36
web/src/lib/plugins/plugin-registry.ts
Normal 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);
|
||||
}
|
||||
|
||||
20
web/src/lib/plugins/plugin-storage.ts
Normal file
20
web/src/lib/plugins/plugin-storage.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
||||
91
web/src/lib/plugins/plugin-types.ts
Normal file
91
web/src/lib/plugins/plugin-types.ts
Normal 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;
|
||||
};
|
||||
|
||||
184
web/src/pages/plugins/index.tsx
Normal file
184
web/src/pages/plugins/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
62
web/src/stores/use-plugin-store.ts
Normal file
62
web/src/stores/use-plugin-store.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user