feat(plugins): 插件管理 - 区分系统与官方插件权限 (#329)
This commit is contained in:
@@ -262,11 +262,12 @@ export function CanvasNodeContextMenu({
|
||||
function AddNodeContextMenu({ parentPosition, workspaceMode, isProjectLinked, reducedMotion, onAddNode, onAddFolder, onChooseStyle, onOpenDirector, onUpload, onOpenAssets, onOpenProjectCharacters }: { parentPosition: { left: number; top: number }; workspaceMode: CanvasWorkspaceMode; isProjectLinked: boolean; reducedMotion: boolean; onAddNode: (type: CanvasNodeTypeId) => void; onAddFolder: () => void; onChooseStyle: () => void; onOpenDirector: () => void; onUpload: () => void; onOpenAssets: () => void; onOpenProjectCharacters: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const pluginStates = usePluginStore((state) => state.pluginStates);
|
||||
const left = getSubmenuLeft(parentPosition.left);
|
||||
const createContext: AddNodeMenuContext = {
|
||||
workspaceMode,
|
||||
isProjectLinked,
|
||||
enabledPluginIds: new Set(installations.filter((item) => item.enabled).map((item) => item.manifest.id)),
|
||||
enabledPluginIds: new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id)),
|
||||
handlers: {
|
||||
onAddText: () => onAddNode(CanvasNodeType.Text),
|
||||
onAddImage: () => onAddNode(CanvasNodeType.Image),
|
||||
|
||||
@@ -59,6 +59,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const theme = canvasThemes[themeName];
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
|
||||
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const mode = defaultMode(node.type);
|
||||
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
|
||||
@@ -102,9 +103,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
};
|
||||
const config = buildNodeConfig(globalConfig, node, mode, requirements);
|
||||
const promptOptimizerProvider = useMemo(() => {
|
||||
if (!promptOptimizerInstallation?.enabled || !promptOptimizerPlugin.createPromptOptimizer) return null;
|
||||
if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null;
|
||||
return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig));
|
||||
}, [globalConfig, promptOptimizerInstallation]);
|
||||
}, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]);
|
||||
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({
|
||||
|
||||
@@ -85,6 +85,7 @@ export function CanvasToolbar({
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const pluginStates = usePluginStore((state) => state.pluginStates);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -173,7 +174,7 @@ export function CanvasToolbar({
|
||||
handlers,
|
||||
};
|
||||
|
||||
const enabledPluginIds = new Set(installations.filter((item) => item.enabled).map((item) => item.manifest.id));
|
||||
const enabledPluginIds = new Set(installations.filter((item) => pluginStates[item.manifest.id]?.effectiveEnabled ?? item.enabled).map((item) => item.manifest.id));
|
||||
|
||||
const items = resolveToolbarEntries("main", ctx, prefs ?? defaultToolbarPrefs("main"));
|
||||
|
||||
|
||||
@@ -23,11 +23,10 @@ const roleLabels: Record<PortraitClearanceInputRole, string> = {
|
||||
|
||||
export function PortraitClearanceNodeContent({ node }: PortraitClearanceNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const enabled = usePluginStore((state) => state.pluginStates[PORTRAIT_CLEARANCE_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled));
|
||||
const { openPortraitClearance } = useCanvasNodeActions();
|
||||
const upstream = useUpstreamNodes(node.id).filter((item) => isPortraitImageInput(item));
|
||||
const state = node.metadata?.portraitClearance;
|
||||
const enabled = Boolean(installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled);
|
||||
const bindings = state?.inputBindings || [];
|
||||
const roleByNodeId = new Map(bindings.map((binding) => [binding.nodeId, binding.role]));
|
||||
const query = upstream.find((item) => roleByNodeId.get(item.id) === "query") || upstream[0];
|
||||
|
||||
@@ -22,9 +22,8 @@ type PortraitClearanceModalProps = {
|
||||
};
|
||||
|
||||
export function PortraitClearanceModal({ projectId, node, upstreamNodes, open, onClose, onUpdateState, onAddCandidate }: PortraitClearanceModalProps) {
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const enabled = Boolean(installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled);
|
||||
const enabled = usePluginStore((state) => state.pluginStates[PORTRAIT_CLEARANCE_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PORTRAIT_CLEARANCE_PLUGIN_ID)?.enabled));
|
||||
const state = node?.metadata?.portraitClearance || createDefaultPortraitClearanceState();
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [installingModels, setInstallingModels] = useState(false);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { appQueryClient } from "@/lib/query-client";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { fetchWorkflowPluginStatuses } from "@/services/api/plugins";
|
||||
import { fetchPluginRuntimeState, setUserPluginEnabled } from "@/services/api/plugins";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
@@ -19,29 +19,52 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const dark = theme === "dark";
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses);
|
||||
const setPluginStates = usePluginStore((state) => state.setPluginStates);
|
||||
const pluginStoreHydrated = usePluginStore((state) => state.hydrated);
|
||||
const userId = useUserStore((state) => state.user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginStoreHydrated) return;
|
||||
for (const plugin of listRegisteredPlugins()) ensurePlugin(plugin.manifest);
|
||||
}, [ensurePlugin]);
|
||||
}, [ensurePlugin, pluginStoreHydrated, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setRuntimeStatuses({});
|
||||
setPluginStates({});
|
||||
return;
|
||||
}
|
||||
if (!pluginStoreHydrated) return;
|
||||
let cancelled = false;
|
||||
void fetchWorkflowPluginStatuses()
|
||||
.then((statuses) => {
|
||||
if (!cancelled) setRuntimeStatuses(statuses);
|
||||
void fetchPluginRuntimeState()
|
||||
.then(async ({ statuses, states }) => {
|
||||
const legacyEnabledIds = usePluginStore.getState().installations
|
||||
.filter((installation) => installation.enabled && states[installation.manifest.id]?.canToggle && !states[installation.manifest.id]?.userConfigured)
|
||||
.map((installation) => installation.manifest.id);
|
||||
if (legacyEnabledIds.length) {
|
||||
try {
|
||||
const migrated = await Promise.all(legacyEnabledIds.map((pluginId) => setUserPluginEnabled(pluginId, true)));
|
||||
for (const state of migrated) states[state.pluginId] = state;
|
||||
for (const pluginId of legacyEnabledIds) statuses[pluginId] = states[pluginId]?.effectiveEnabled ? "enabled" : "disabled";
|
||||
} catch (error) {
|
||||
console.warn("迁移用户插件启用状态失败,已保留服务端状态", error);
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setRuntimeStatuses(statuses);
|
||||
setPluginStates(states);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRuntimeStatuses({});
|
||||
if (!cancelled) {
|
||||
setRuntimeStatuses({});
|
||||
setPluginStates({});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [setRuntimeStatuses, userId]);
|
||||
}, [pluginStoreHydrated, setPluginStates, setRuntimeStatuses, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/st
|
||||
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync";
|
||||
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
|
||||
|
||||
@@ -29,16 +30,18 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。
|
||||
if (previousUserId !== nextUserId) appQueryClient.clear();
|
||||
await switchUserStorageScope(payload.user?.id);
|
||||
const [persistedCanvas, persistedAssets] = await Promise.all([localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY)]);
|
||||
const [persistedCanvas, persistedAssets, persistedPlugins] = await Promise.all([localForageStorage.getItem(CANVAS_STORE_KEY), localForageStorage.getItem(ASSET_STORE_KEY), localForageStorage.getItem(PLUGIN_STORE_KEY)]);
|
||||
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
|
||||
usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} });
|
||||
useUserStore.getState().setUser(payload.user);
|
||||
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
|
||||
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
|
||||
useUserStore.getState().setFeatures(payload.features);
|
||||
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate()]);
|
||||
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate()]);
|
||||
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
|
||||
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
|
||||
if (!persistedAssets) useAssetStore.setState({ assets: [] });
|
||||
if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
|
||||
if (!persistedConfig) {
|
||||
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
|
||||
// 使用统一模型目录接口
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Mail,
|
||||
MessageSquareText,
|
||||
Paintbrush,
|
||||
PlugZap,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RadioTower,
|
||||
@@ -54,6 +55,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
|
||||
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
|
||||
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
|
||||
{ path: "/admin/plugins", label: "插件管理", description: "平台可用性、上传与卸载", icon: <PlugZap className="size-4" /> },
|
||||
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ const userFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; des
|
||||
{ key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: <Coins className="size-4" /> },
|
||||
{ key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: <RadioTower className="size-4" /> },
|
||||
{ key: "pluginCenterEnabled", title: "插件中心", menu: "/plugins", description: "关闭后普通用户无法进入插件中心或调用插件中心相关接口;管理员仍可进入后台恢复开关。已有插件不会删除。", icon: <PlugZap className="size-4" /> },
|
||||
{ key: "systemPluginsVisibleToUsers", title: "向普通用户显示系统插件(不可编辑)", menu: "/plugins", description: "仅在插件中心开启时生效。关闭后普通用户看不到系统插件,用户插件不受影响。", icon: <PlugZap className="size-4" /> },
|
||||
{ key: "systemPluginsVisibleToUsers", title: "向普通用户显示系统插件(只读)", menu: "/plugins", description: "仅在插件中心开启时生效。关闭后普通用户看不到协议类系统插件和管理员上传的自定义插件;官方应用插件仍可由用户自行启用。", icon: <PlugZap className="size-4" /> },
|
||||
];
|
||||
|
||||
const adminFeatureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
|
||||
|
||||
245
web/src/pages/admin/plugins/plugins-page.tsx
Normal file
245
web/src/pages/admin/plugins/plugins-page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { App, Button, Input, Select, Switch, Tag } from "antd";
|
||||
import { CloudUpload, PlugZap, RefreshCw, Search, ShieldCheck, Trash2, UsersRound } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
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 { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
|
||||
import type { PluginManifest } from "@/lib/plugins/plugin-types";
|
||||
import { fetchAdminPlugins, setPluginPlatformAvailability, uninstallPlugin, uploadPlugin, type AdminPluginState, type BackendPlugin, type PluginManagement } from "@/services/api/plugins";
|
||||
import { UploadPluginModal } from "@/pages/plugins/plugin-documentation-modals";
|
||||
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
|
||||
type AdminPluginItem = {
|
||||
manifest: PluginManifest;
|
||||
source: string;
|
||||
management: PluginManagement;
|
||||
status?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance"]);
|
||||
|
||||
export default function AdminPluginsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [plugins, setPlugins] = useState<BackendPlugin[]>([]);
|
||||
const [states, setStates] = useState<Record<string, AdminPluginState>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [kind, setKind] = useState<"all" | "application" | "protocol" | "uploaded">("all");
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchAdminPlugins();
|
||||
setPlugins(result.plugins);
|
||||
setStates(result.states);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取插件管理数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => mergePlugins(plugins), [plugins]);
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = search.trim().toLocaleLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (kind === "uploaded" && item.management.origin !== "uploaded") return false;
|
||||
if (kind !== "all" && kind !== "uploaded" && item.management.kind !== kind) return false;
|
||||
if (!keyword) return true;
|
||||
return [item.manifest.name, item.manifest.id, item.manifest.description, item.manifest.author].filter(Boolean).join(" ").toLocaleLowerCase().includes(keyword);
|
||||
});
|
||||
}, [items, kind, search]);
|
||||
|
||||
const changeAvailability = async (item: AdminPluginItem, available: boolean) => {
|
||||
setSavingId(item.manifest.id);
|
||||
try {
|
||||
const state = await setPluginPlatformAvailability(item.manifest.id, available);
|
||||
setStates((current) => ({ ...current, [item.manifest.id]: state }));
|
||||
message.success(`${item.manifest.name}${available ? "已开放" : "已停用"}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "更新插件可用状态失败");
|
||||
} finally {
|
||||
setSavingId("");
|
||||
}
|
||||
};
|
||||
|
||||
const upload = async (file: File) => {
|
||||
try {
|
||||
await uploadPlugin(file);
|
||||
setUploadOpen(false);
|
||||
message.success("自定义插件已安装");
|
||||
await reload();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "安装插件失败");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (item: AdminPluginItem) => {
|
||||
modal.confirm({
|
||||
title: `卸载 ${item.manifest.name}?`,
|
||||
content: "插件包、平台状态和所有用户的启用记录都会删除。此操作不可撤销。",
|
||||
okText: "确认卸载",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await uninstallPlugin(item.manifest.id);
|
||||
message.success("自定义插件已卸载");
|
||||
await reload();
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "卸载插件失败");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const applicationCount = items.filter((item) => item.management.kind === "application").length;
|
||||
const protocolCount = items.filter((item) => item.management.kind === "protocol").length;
|
||||
const unavailableCount = items.filter((item) => states[item.manifest.id] && !states[item.manifest.id].platformAvailable).length;
|
||||
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="插件管理"
|
||||
description="管理平台级可用性、自定义插件安装与用户启用范围"
|
||||
scroll
|
||||
actions={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<CloudUpload className="size-4" />} onClick={() => setUploadOpen(true)}>
|
||||
上传插件
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 py-4 sm:grid-cols-3">
|
||||
<SummaryCard label="官方应用插件" value={applicationCount} detail="用户可自行启用" />
|
||||
<SummaryCard label="协议与自定义插件" value={protocolCount} detail="管理员统一控制" />
|
||||
<SummaryCard label="平台已停用" value={unavailableCount} detail="所有用户均不可使用" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-border/70 bg-card/60 p-3">
|
||||
<Input className="min-w-[240px] flex-1" allowClear prefix={<Search className="size-4 text-foreground/40" />} value={search} placeholder="搜索插件名称、ID 或作者" onChange={(event) => setSearch(event.target.value)} />
|
||||
<Select
|
||||
className="w-44"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "application", label: "官方应用插件" },
|
||||
{ value: "protocol", label: "系统协议插件" },
|
||||
{ value: "uploaded", label: "上传的自定义插件" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 pb-6 xl:grid-cols-2">
|
||||
{filtered.map((item) => {
|
||||
const state = states[item.manifest.id];
|
||||
const available = state?.platformAvailable ?? item.status === "enabled";
|
||||
return (
|
||||
<section key={item.manifest.id} className="rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-foreground/65">
|
||||
<PlugZap className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate font-semibold">{item.manifest.name}</h2>
|
||||
<span className="text-xs text-foreground/42">v{item.manifest.version}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
<Tag color={item.management.origin === "uploaded" ? "orange" : item.management.kind === "application" ? "blue" : "default"}>{managementLabel(item.management)}</Tag>
|
||||
{item.manifest.trusted ? (
|
||||
<Tag color="green" icon={<ShieldCheck className="mr-1 inline size-3" />}>
|
||||
可信
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Switch loading={savingId === item.manifest.id} checked={available} aria-label={`${item.manifest.name}平台可用性`} onChange={(checked) => void changeAvailability(item, checked)} />
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-foreground/58">{item.manifest.description || "未提供插件说明"}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3 text-xs text-foreground/48">
|
||||
<span className="truncate" title={item.manifest.id}>
|
||||
{item.manifest.id}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.management.activationScope === "user" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<UsersRound className="size-3.5" />
|
||||
{state?.enabledUserCount || 0} 位用户启用
|
||||
</span>
|
||||
) : (
|
||||
<span>全局生效</span>
|
||||
)}
|
||||
{item.management.origin === "uploaded" ? (
|
||||
<Button danger type="text" size="small" icon={<Trash2 className="size-3.5" />} onClick={() => remove(item)}>
|
||||
卸载
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{item.error ? <p className="mt-2 text-xs text-red-500">{item.error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!loading && filtered.length === 0 ? <div className="py-16 text-center text-sm text-foreground/45">没有匹配的插件</div> : null}
|
||||
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={(file) => void upload(file)} />
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, detail }: { label: string; value: number; detail: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card p-4">
|
||||
<div className="text-xs text-foreground/45">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-1 text-xs text-foreground/40">{detail}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] {
|
||||
const byId = new Map<string, AdminPluginItem>();
|
||||
for (const plugin of listRegisteredPlugins()) {
|
||||
const application = officialApplicationIds.has(plugin.manifest.id);
|
||||
byId.set(plugin.manifest.id, {
|
||||
manifest: plugin.manifest,
|
||||
source: plugin.source || "bundled",
|
||||
management: {
|
||||
origin: "official",
|
||||
kind: application ? "application" : "protocol",
|
||||
activationScope: application ? "user" : "system",
|
||||
configurationScope: application ? (plugin.manifest.id === EAGLE_PLUGIN_ID || plugin.manifest.id === RUNNINGHUB_PLUGIN_ID || plugin.manifest.id === COMFYUI_PLUGIN_ID ? "user" : "none") : "system",
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const plugin of remote) byId.set(plugin.manifest.id, plugin);
|
||||
return [...byId.values()].sort((left, right) => managementOrder(left.management) - managementOrder(right.management) || left.manifest.name.localeCompare(right.manifest.name, "zh-CN"));
|
||||
}
|
||||
|
||||
function managementOrder(value: PluginManagement) {
|
||||
if (value.kind === "application") return 0;
|
||||
if (value.origin === "official") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function managementLabel(value: PluginManagement) {
|
||||
if (value.origin === "uploaded") return "自定义插件";
|
||||
return value.kind === "application" ? "官方插件" : "系统插件";
|
||||
}
|
||||
@@ -135,10 +135,11 @@ export default function CreatePage() {
|
||||
const { message: toast, modal } = App.useApp();
|
||||
const config = useEffectiveConfig();
|
||||
const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
|
||||
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
|
||||
const promptOptimizerProvider = useMemo<PromptOptimizerProvider | null>(() => {
|
||||
if (!promptOptimizerInstallation?.enabled || !promptOptimizerPlugin.createPromptOptimizer) return null;
|
||||
if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null;
|
||||
return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, config));
|
||||
}, [config, promptOptimizerInstallation]);
|
||||
}, [config, promptOptimizerEnabled, promptOptimizerInstallation]);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Input, Modal, Select, Switch, Typography } from "antd";
|
||||
import { AudioLines, CalendarDays, CheckCircle2, Clock3, CloudUpload, ExternalLink, Film, FolderOpen, Image as ImageIcon, MessageSquareText, PlugZap, RefreshCw, Search, Settings2, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||
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";
|
||||
|
||||
@@ -10,11 +10,11 @@ import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimiz
|
||||
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, setPluginEnabled, uploadPlugin, type BackendPlugin } from "@/services/api/plugins";
|
||||
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, UploadPluginModal } from "./plugin-documentation-modals";
|
||||
import { PluginDetailsModal } from "./plugin-documentation-modals";
|
||||
import "./plugins.css";
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
@@ -66,7 +66,8 @@ export default function PluginsPage() {
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
const setEnabled = usePluginStore((state) => state.setEnabled);
|
||||
const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses);
|
||||
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
|
||||
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<BackendPlugin[]>([]);
|
||||
@@ -74,7 +75,6 @@ export default function PluginsPage() {
|
||||
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
|
||||
const [detailsPluginId, setDetailsPluginId] = useState<string | null>(null);
|
||||
const [detailsRestoreFocus, setDetailsRestoreFocus] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [scrollTarget, setScrollTarget] = useState<string | null>(null);
|
||||
@@ -95,8 +95,9 @@ export default function PluginsPage() {
|
||||
const reloadBackendPlugins = async () => {
|
||||
setBackendPluginsLoading(true);
|
||||
try {
|
||||
const plugins = await fetchPlugins();
|
||||
setBackendPlugins(plugins);
|
||||
const result = await fetchPlugins();
|
||||
setBackendPlugins(result.plugins);
|
||||
setPluginStates(result.states);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取插件中心失败");
|
||||
setBackendPlugins([]);
|
||||
@@ -107,7 +108,7 @@ export default function PluginsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void reloadBackendPlugins();
|
||||
}, []);
|
||||
}, [user?.id]);
|
||||
|
||||
const remotePlugins = useMemo(() => backendPlugins.map(toRegisteredPlugin), [backendPlugins]);
|
||||
const registeredPlugins = useMemo(() => {
|
||||
@@ -131,11 +132,11 @@ export default function PluginsPage() {
|
||||
const filteredPlugins = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
return registeredPlugins.filter((plugin) => {
|
||||
const isSystemPlugin = plugin.source !== "uploaded";
|
||||
if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && isSystemPlugin) return false;
|
||||
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 remote = backendPluginById.get(plugin.manifest.id);
|
||||
const enabled = remote ? remote.status === "enabled" : Boolean(installation?.enabled);
|
||||
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();
|
||||
@@ -151,7 +152,7 @@ export default function PluginsPage() {
|
||||
if (statusFilter === "disabled" && enabled) return false;
|
||||
return true;
|
||||
});
|
||||
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
|
||||
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, pluginStates, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
|
||||
|
||||
const pluginSections = useMemo(
|
||||
() => [
|
||||
@@ -180,7 +181,7 @@ export default function PluginsPage() {
|
||||
}, [pluginSections, scrollTarget]);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || plugin.source === "uploaded");
|
||||
const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPlugin(plugin.manifest.id));
|
||||
const counts: Record<string, number> = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, other: 0 };
|
||||
for (const plugin of visiblePlugins) {
|
||||
const capabilities = providerCapabilitiesFor(plugin.manifest);
|
||||
@@ -194,23 +195,22 @@ export default function PluginsPage() {
|
||||
|
||||
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 ? (backendPluginById.has(settingsPlugin.manifest.id) ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" : Boolean(settingsInstallation?.enabled)) : false;
|
||||
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) => hasPluginConfiguration(plugin) && user?.role === "admin";
|
||||
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)) =>
|
||||
backendPluginById.has(plugin.manifest.id) ? backendPluginById.get(plugin.manifest.id)?.status === "enabled" : Boolean(installation?.enabled);
|
||||
pluginStates[plugin.manifest.id]?.effectiveEnabled ?? Boolean(installation?.enabled);
|
||||
|
||||
const togglePlugin = async (plugin: RegisteredPlugin, enabled: boolean) => {
|
||||
try {
|
||||
if (backendPluginById.has(plugin.manifest.id)) {
|
||||
const next = await setPluginEnabled(plugin.manifest.id, enabled);
|
||||
setBackendPlugins((items) => items.map((item) => (item.manifest.id === next.manifest.id ? next : item)));
|
||||
setRuntimeStatuses({ ...runtimeStatuses, [next.manifest.id]: next.status });
|
||||
} else {
|
||||
setEnabled(plugin.manifest.id, enabled);
|
||||
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) {
|
||||
@@ -218,17 +218,6 @@ export default function PluginsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
const plugin = await uploadPlugin(file);
|
||||
setBackendPlugins((items) => [...items.filter((item) => item.manifest.id !== plugin.manifest.id), plugin]);
|
||||
setUploadModalOpen(false);
|
||||
message.success("插件已安装并立即生效");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "安装插件失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadEagleFolders = async (url = eagleBaseUrl) => {
|
||||
setEagleFoldersLoading(true);
|
||||
setEagleFoldersError("");
|
||||
@@ -332,16 +321,12 @@ export default function PluginsPage() {
|
||||
<span className="plugins-filter-icon" aria-hidden="true">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
</span>
|
||||
{user?.role === "admin" ? (
|
||||
<div className="plugins-toolbar-actions">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={backendPluginsLoading} onClick={() => void reloadBackendPlugins()}>
|
||||
刷新插件
|
||||
</Button>
|
||||
<Button type="primary" icon={<CloudUpload className="size-4" />} onClick={() => setUploadModalOpen(true)}>
|
||||
上传插件
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="plugins-toolbar-actions">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={backendPluginsLoading} onClick={() => void reloadBackendPlugins()}>
|
||||
刷新插件
|
||||
</Button>
|
||||
{user?.role === "admin" ? <Button type="primary" onClick={() => navigate("/admin/plugins")}>管理员插件管理</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPlugins.length ? (
|
||||
@@ -374,7 +359,8 @@ export default function PluginsPage() {
|
||||
const remote = backendPluginById.get(plugin.manifest.id);
|
||||
const enabled = isPluginEnabled(plugin, installation);
|
||||
const trusted = Boolean(plugin.manifest.trusted);
|
||||
const isSystemPlugin = plugin.source !== "uploaded";
|
||||
const state = pluginStates[plugin.manifest.id];
|
||||
const sourceLabel = pluginSourceLabel(plugin, state);
|
||||
const canConfigure = canConfigurePlugin(plugin);
|
||||
return (
|
||||
<section key={plugin.manifest.id} className={`plugin-card library-card-surface${trusted ? " is-trusted" : ""}`}>
|
||||
@@ -399,8 +385,8 @@ export default function PluginsPage() {
|
||||
<span className="plugin-version">v{plugin.manifest.version}</span>
|
||||
</div>
|
||||
<div className="plugin-card-labels">
|
||||
<span className={`plugin-source-label${isSystemPlugin ? " is-system" : ""}`}>
|
||||
{isSystemPlugin ? "系统插件" : "用户插件"}
|
||||
<span className={`plugin-source-label${sourceLabel === "系统插件" ? " is-system" : ""}`}>
|
||||
{sourceLabel}
|
||||
</span>
|
||||
{trusted ? (
|
||||
<span className="plugin-trust-label">
|
||||
@@ -441,9 +427,9 @@ export default function PluginsPage() {
|
||||
<div className="plugin-card-actions">
|
||||
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
|
||||
<i aria-hidden="true" />
|
||||
{enabled ? "已启用" : "未启用"}
|
||||
{!state?.platformAvailable && state?.blockedReason ? state.blockedReason : enabled ? "已启用" : "未启用"}
|
||||
</span>
|
||||
<Switch disabled={user?.role !== "admin"} checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} onChange={(checked) => void togglePlugin(plugin, checked)} />
|
||||
<Switch disabled={!state?.canToggle} checked={enabled} aria-label={`${plugin.manifest.name}${enabled ? "停用" : "启用"}`} title={state?.blockedReason} onChange={(checked) => void togglePlugin(plugin, checked)} />
|
||||
{canConfigure ? (
|
||||
<Button
|
||||
className="plugin-settings-button"
|
||||
@@ -488,7 +474,6 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadPluginModal open={uploadModalOpen} onClose={() => setUploadModalOpen(false)} onUpload={(file) => void handleUpload(file)} />
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-wide plugin-settings-modal"
|
||||
title={settingsPlugin ? `${settingsPlugin.manifest.name} 设置` : null}
|
||||
@@ -619,6 +604,16 @@ function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin {
|
||||
return { manifest: plugin.manifest, source: plugin.source };
|
||||
}
|
||||
|
||||
function isOfficialApplicationPlugin(pluginId: string) {
|
||||
return [RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance"].includes(pluginId);
|
||||
}
|
||||
|
||||
function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) {
|
||||
if (plugin.source === "uploaded") return "自定义插件";
|
||||
if (state?.canToggle || isOfficialApplicationPlugin(plugin.manifest.id)) return "官方插件";
|
||||
return "系统插件";
|
||||
}
|
||||
|
||||
function contributionKindsFor(manifest: PluginManifest): string[] {
|
||||
const contributions = manifest.contributes;
|
||||
const kinds: string[] = [];
|
||||
|
||||
@@ -18,6 +18,7 @@ const EmailSettingsPage = lazy(() => import("@/pages/admin/admin-route-pages").t
|
||||
const FeatureAvailabilityPage = lazy(() => import("@/pages/admin/admin-route-pages").then((module) => ({ default: module.FeatureAvailabilityPage })));
|
||||
const ChannelsPage = lazy(() => import("@/pages/admin/channels/channels-page"));
|
||||
const LogicalModelsPage = lazy(() => import("@/pages/admin/logical-models/logical-models-page"));
|
||||
const AdminPluginsPage = lazy(() => import("@/pages/admin/plugins/plugins-page"));
|
||||
const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page"));
|
||||
const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page"));
|
||||
const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page"));
|
||||
@@ -158,6 +159,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "users", element: deferred(<UsersPage />) },
|
||||
{ path: "channels", element: deferred(<ChannelsPage />) },
|
||||
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled">{deferred(<LogicalModelsPage />)}</RequireFeature> },
|
||||
{ path: "plugins", element: deferred(<AdminPluginsPage />) },
|
||||
{ path: "prompt-templates", element: deferred(<StoryboardPromptsPage />) },
|
||||
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
|
||||
{ path: "announcements", element: deferred(<AnnouncementsPage />) },
|
||||
|
||||
@@ -11,18 +11,37 @@ export type BackendPlugin = {
|
||||
updatedAt: string;
|
||||
status: "enabled" | "disabled" | "invalid" | string;
|
||||
error?: string;
|
||||
management: PluginManagement;
|
||||
};
|
||||
|
||||
export type WorkflowPluginStatus = "enabled" | "disabled" | "invalid" | string;
|
||||
|
||||
export type PluginManagement = {
|
||||
origin: "official" | "uploaded";
|
||||
kind: "protocol" | "application";
|
||||
activationScope: "system" | "user";
|
||||
configurationScope: "none" | "system" | "user";
|
||||
};
|
||||
|
||||
export type PluginState = {
|
||||
pluginId: string;
|
||||
platformAvailable: boolean;
|
||||
userEnabled: boolean;
|
||||
userConfigured: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
canToggle: boolean;
|
||||
canConfigure: boolean;
|
||||
blockedReason?: string;
|
||||
};
|
||||
|
||||
export type AdminPluginState = PluginState & { enabledUserCount: number };
|
||||
|
||||
export async function fetchPlugins() {
|
||||
const result = await request<{ plugins: BackendPlugin[] }>(apiClient.get("/plugins"));
|
||||
return result.plugins;
|
||||
return request<{ plugins: BackendPlugin[]; states: Record<string, PluginState> }>(apiClient.get("/plugins"));
|
||||
}
|
||||
|
||||
export async function fetchWorkflowPluginStatuses() {
|
||||
const result = await request<{ statuses: Record<string, WorkflowPluginStatus> }>(apiClient.get("/plugins/status"));
|
||||
return result.statuses;
|
||||
export async function fetchPluginRuntimeState() {
|
||||
return request<{ statuses: Record<string, WorkflowPluginStatus>; states: Record<string, PluginState> }>(apiClient.get("/plugins/status"));
|
||||
}
|
||||
|
||||
export async function uploadPlugin(file: File) {
|
||||
@@ -32,9 +51,18 @@ export async function uploadPlugin(file: File) {
|
||||
return result.plugin;
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(id: string, enabled: boolean) {
|
||||
const result = await request<{ plugin: BackendPlugin }>(apiClient.post(`/plugins/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`));
|
||||
return result.plugin;
|
||||
export async function setUserPluginEnabled(id: string, enabled: boolean) {
|
||||
const result = await request<{ state: PluginState }>(apiClient.put(`/plugins/${encodeURIComponent(id)}/activation`, { enabled }));
|
||||
return result.state;
|
||||
}
|
||||
|
||||
export async function fetchAdminPlugins() {
|
||||
return request<{ plugins: BackendPlugin[]; states: Record<string, AdminPluginState> }>(apiClient.get("/admin/plugins"));
|
||||
}
|
||||
|
||||
export async function setPluginPlatformAvailability(id: string, available: boolean) {
|
||||
const result = await request<{ state: AdminPluginState }>(apiClient.put(`/admin/plugins/${encodeURIComponent(id)}/availability`, { available }));
|
||||
return result.state;
|
||||
}
|
||||
|
||||
export async function uninstallPlugin(id: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import "@/lib/plugins/builtin";
|
||||
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
|
||||
import { pluginStorageFor } from "@/lib/plugins/plugin-storage";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { isPluginEffectivelyEnabled, usePluginStore } from "@/stores/use-plugin-store";
|
||||
import type { AssetSourceProvider, ExternalAssetItem, PluginInstallation } from "@/lib/plugins/plugin-types";
|
||||
|
||||
export type HostedAssetSource = {
|
||||
@@ -30,7 +30,7 @@ export type ExternalAssetSyncResult = {
|
||||
|
||||
export function createHostedAssetSources(installations: PluginInstallation[]): HostedAssetSource[] {
|
||||
return installations.flatMap((installation) => {
|
||||
if (!installation.enabled) return [];
|
||||
if (!isPluginEffectivelyEnabled(installation.manifest.id, installation.enabled)) return [];
|
||||
const plugin = getRegisteredPlugin(installation.manifest.id);
|
||||
if (!plugin?.createAssetSource) return [];
|
||||
try {
|
||||
@@ -54,7 +54,7 @@ export function createHostedAssetSources(installations: PluginInstallation[]): H
|
||||
|
||||
export async function uploadGeneratedAssetToConfiguredSources(asset: Asset, signal?: AbortSignal): Promise<ExternalAssetSyncResult> {
|
||||
const installations = usePluginStore.getState().installations;
|
||||
const autoUploadInstallations = installations.filter((installation) => installation.enabled && isAutoUploadEnabled(installation.config.autoUploadGenerated));
|
||||
const autoUploadInstallations = installations.filter((installation) => isPluginEffectivelyEnabled(installation.manifest.id, installation.enabled) && isAutoUploadEnabled(installation.config.autoUploadGenerated));
|
||||
if (!autoUploadInstallations.length) return { records: readSyncRecords(asset), uploaded: [], failures: [] };
|
||||
|
||||
const sources = createHostedAssetSources(installations);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import type { PluginInstallation, PluginManifest } from "@/lib/plugins/plugin-types";
|
||||
import type { PluginState } from "@/services/api/plugins";
|
||||
|
||||
export const PLUGIN_STORE_KEY = "infinite-canvas:plugin-store";
|
||||
|
||||
@@ -10,8 +11,10 @@ type PluginStore = {
|
||||
hydrated: boolean;
|
||||
installations: PluginInstallation[];
|
||||
runtimeStatuses: Record<string, string>;
|
||||
pluginStates: Record<string, PluginState>;
|
||||
ensurePlugin: (manifest: PluginManifest) => void;
|
||||
setRuntimeStatuses: (statuses: Record<string, string>) => void;
|
||||
setPluginStates: (states: Record<string, PluginState>) => void;
|
||||
setEnabled: (pluginId: string, enabled: boolean) => void;
|
||||
updateConfig: (pluginId: string, config: Record<string, string | number | boolean>) => void;
|
||||
setError: (pluginId: string, error?: string) => void;
|
||||
@@ -28,6 +31,7 @@ export const usePluginStore = create<PluginStore>()(
|
||||
hydrated: false,
|
||||
installations: [],
|
||||
runtimeStatuses: {},
|
||||
pluginStates: {},
|
||||
ensurePlugin: (manifest) =>
|
||||
set((state) => {
|
||||
const current = state.installations.find((item) => item.manifest.id === manifest.id);
|
||||
@@ -43,6 +47,7 @@ export const usePluginStore = create<PluginStore>()(
|
||||
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, enabled, updatedAt: now(), lastError: undefined } : item),
|
||||
})),
|
||||
setRuntimeStatuses: (runtimeStatuses) => set({ runtimeStatuses }),
|
||||
setPluginStates: (pluginStates) => set({ pluginStates }),
|
||||
updateConfig: (pluginId, config) =>
|
||||
set((state) => ({
|
||||
installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, config: { ...item.config, ...config }, updatedAt: now() } : item),
|
||||
@@ -58,8 +63,17 @@ export const usePluginStore = create<PluginStore>()(
|
||||
storage: createJSONStorage(() => localForageStorageForScope()),
|
||||
partialize: (state) => ({ installations: state.installations }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) state.hydrated = true;
|
||||
if (state) usePluginStore.setState({ hydrated: true });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function isPluginEffectivelyEnabled(pluginId: string, fallbackEnabled?: boolean) {
|
||||
const state = usePluginStore.getState();
|
||||
const serverState = state.pluginStates[pluginId];
|
||||
if (serverState) return serverState.effectiveEnabled;
|
||||
if (pluginId in state.runtimeStatuses) return state.runtimeStatuses[pluginId] === "enabled";
|
||||
if (fallbackEnabled !== undefined) return fallbackEnabled;
|
||||
return Boolean(state.installations.find((item) => item.manifest.id === pluginId)?.enabled);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user