Files
ttstd-infinite-canvas/web/src/pages/plugins/index.tsx
爱笑的毛毛虫 05d1bce876 feat(editor): AI 编辑助手悬浮面板、插件权限体系与时间线渲染管线(M4–M6.3) (#411)
* feat(editor): 编辑器实施 M0-M1 - 剪辑入口占位与插件 SDK v2 垂直切片

- detail.tsx 新增剪辑成片视图(editor tab,常驻),概览页插入剪辑步骤卡
- plugin-types.ts 新增 v2 类型(EditorSlotKind/EditorPluginPermission/PluginManifestV2),修复权限数组交叉归约 bug
- editor-slot-registry.ts 插槽注册表(优先级排序、幂等注册、订阅机制)
- plugin-registry.ts 注册器 v1/v2 分支校验,v1 路径零改动
- editor-shell.tsx 垂直切片插件:timeline-panel 插槽真实渲染,验证插件链路全通
- 测试:插槽注册表 5 用例 + 命令测试骨架,v1 插件回归 21 例零回归

* feat(editor): 命令协议黄金文件 - editor-commands 纯函数 reducer + 8 个黄金 op

* feat(editor): 快照撤销历史 - editor-history 有界 200 层 undo/redo

* feat(editor): 命令状态机 store - dispatch/手势预览/1.5s 防抖保存/串行保存链

* fix(editor): createEditorStore 返回 zustand hook 类型修复 typecheck

* feat(editor): 手势 echo 最小件 - 时间线面板拖拽移动/裁剪 + store 接线与本地持久化

* docs(editor): M2 完成状态记录

* feat(editor): M3.1 时间线面板选中联动 - store 选中状态 + 片段高亮/点击取消

* feat(editor): M3.2 预览监视器 - preview-renderer 插槽 + 真实媒体帧/播放头/时间码

* feat(editor): M3.3 片段检查器 - inspector 插槽 + 属性编辑入队可撤销

* feat(editor): M3.4 asset-ingest 素材库插件(addClip 命令入轨)

* feat(editor): M3.4 补充 - host 上下文(项目信息+资产)、editor.tsx 接线、asset-ingest 纯函数、死代码清理

* feat(editor): M3.5 subtitle-tools - SRT import/export + rebuild from node snapshot

* feat(editor): M3.6 transcription - deterministic mock ASR to subtitle track

* feat(editor): M3.7 export - render plan + ffmpeg.wasm fallback export

* feat(editor): M3.8 - render all 8 editor slots in three-pane layout + ai-assistant placeholder

* docs(editor): M3 完成状态 - 8 插件 + 全插槽接线,冒烟待确认

* feat(editor): 编辑器媒体与时间线打磨 - 资产来源分组筛选、时间线全宽布局、拖拽末片段修复、OSS 降级本地存储、Concat 风格控制条与检查器

* fix(plugins): 合并上游后修复插件分类筛选对 v2 清单的类型兼容

* feat(editor): 时间线工具态与轨道操作打磨 - 工具按钮选中仅图标变色、监视器 Concat 式控制条、removeTrack 黄金命令与轨道行头移除按钮、maskClosable 迁移

* fix(api): 资源上传超 MaxBytesReader 上限时报中文大小限制而非英文裸 400

* fix(editor): 监视器控制条 - 修复播放按钮 JSX 模板闭合花括号缺失导致的编译错误

* feat(api): 本地媒体导入分片化对齐 Concat - >50MB 视频不再失败 - 分片会话三接口(POST开始/PUT单片/complete合并)、前端超50MB自动切8MB分片并失败整传重试、默认配额放宽(日200MB→2GB、总2GB→20GB)、UploadResourceFile 豁免单文件限

* feat(editor): 轨道行头新增可见性/静音开关按钮 - 复用现有图标资源,按轨道类型显隐;命令层新增 setTrackFlag 黄金命令(flag 白名单 fail-closed)并补单测

* docs(progress): 记录轨道可见/静音开关改动与复测项

* docs(editor): M4 转写与渲染任务设计 - 复用 Task 表与状态机、独立创建 API、worker 领取类型过滤、whisper HTTP provider(OpenAI 兼容 /v1/audio)

* fix(api): OSS 上传失败自动降级本地存储时 body 已被 Transport 关闭 - newOSSRequest 用 NopCloser 包装请求体,服务端提前返回(签名 403)时不再关闭调用方持有的文件,降级分支 Seek 重读生效;补回归测试

* docs(progress): 记录媒体导入根治(分片+OSS 降级修复)复测项

* feat(api): 视频播放副本转码 H.265→H.264 + variant=playback 服务与启动回填 - 上传视频后异步探测 codec,H.265/AV1 走 moov 快速探测并转码 H.264 播放副本;GET /api/user-data/...?variant=playback 就绪时服务副本(独立 ETag :pb),未就绪回退原件并撤销后缀防 304 黑屏;storeResource 触发、启动幂等回填存量;前端播放失败自动轮询副本就绪后刷新

* fix(editor): 轨道至少保留一条 - 不可移除轨道点击 X 时居中浮现原因提示(2.2s 自动消失, aria-live);修正可见性 toggle 传参 (visible===false 语义) ;轨道行内移除按钮改为常驻(不可移除时点击给提示)

* feat(timeline): M4.1 字幕转写后台任务 - whisper.cpp 本地执行 - 新增 timeline_transcription 任务类型,worker 按类型分叉独立执行器(不走计费与模型渠道);输入 resourceId+language,读取用户资源后 ffmpeg 预处理 16k 单声道 wav,POST 本地 whisper.cpp /inference,结果写回 segments+srt+language;未配置 CANVAS_WHISPER_BASE_URL 等失败均落明确终态;feature 门控;进度分阶段;含解析/SRT/快速失败单元测试

* feat(media): 素材卡片时长与媒体面板打磨 - 导入媒体解析时长(前端 media-metadata + 后端 project_asset durationMs 透出)随卡片展示;媒体面板/监视器/时间线相关样式细节收尾

* docs(progress): 阶段 C 监视器播放复检清单 - H.265 播放副本/真实播放驱动/时长链路待浏览器确认

* feat(timeline): M4.1 转写任务创建 API - POST /api/timeline/transcriptions - service CreateTimelineTranscriptionTask:IsDraining 守护 + feature 门控 + 资源归属与可转写类型校验 + 排队计 active 限额(含任务级/项目级存储估算);local/whisper.cpp 标记,任务不经过模型路由与计费;createTaskWithinStorageQuota 复用与 ErrActiveTaskLimit 用户可读映射;测试覆盖排队成功(owner/type/provider 断言)、跨用户资源拒绝、非音视频拒绝

* feat(timeline): M4.3 字幕转写前端接线 - 真实任务替换 mock - 新增 services/api/timeline-tasks.ts(POST /timeline/transcriptions,复用 apiClient+request);EditorTranscription 改为 创建任务→waitForGenerationTask 轮询(25min)→结果 segments 映射 SrtEntry[]→rebuildSubtitleClips 写入字幕轨道;实时展示 stage/progress,失败/空识别/无字幕轨道分别提示;素材选择支持音频与视频(取 storageKey 中的 resource id 提交)

* fix(media): 播放副本状态收敛与轮询终态 - 无ffmpeg/远端存储/编码不可处理落 none 避免重复探测;前端 none 停轮询回原生播放并加 processing 上限

* feat(timeline): M4.2 时间线渲染任务 - POST /api/timeline/renders 创建(buildRenderPlan 校验含可渲染媒体、配额入队、local/ffmpeg、60min 超时);worker 顶部按类型分叉 processTimelineRender;ffmpeg 合成产物写资源存储并经 ResultJSON 记 resourceId;render 计划/结果测试 PASS

* fix(resource): 转码原子抢占与 OSS 请求体加固 - ClaimPlaybackTranscode 空/none→processing 单实例抢占避免重复转码,runPlaybackTranscode defer recover 落 failed;If-Range 与带 :pb 后缀的 serveETag 比较;无请求体的 OSS 请求不再包 NopCloser(nil)(防 Go 1.26 body 探测 nil 解引用)

* style(editor): 修正字幕回写面板缩进

* docs(timeline): M4 设计文档按实现回写 - 单一 worker 池 + processClaimedTask 顶部按类型分叉(决策 3/4),端点统一为复数 /api/timeline/transcriptions 与 /api/timeline/renders(注册于 RegisterTaskRoutes),ResultJSON/feature gate/ffmpeg env 同步;Runbook M4 表格与状态行更新;auth.go API 前缀白名单补 timeline

* fix(service): 修复上游 v1.2.5 公告配图删除保护自锁与测试夹具缺口

上游 #385 资源引用走查器对公告配图/草稿做了硬性引用检查,导致三个问题:
- UpdateAnnouncement 在更新公告前对旧配图走查,必然命中当前公告自身引用而自锁,
  公告替换配图功能实际不可用;DiscardAnnouncementImage 同理被草稿自身引用阻塞。
  修复:ensureResourceHasNoBusinessReferences 支持忽略指定引用(当前公告/当前草稿),
  其余引用仍按原语义拦截。
- admin DeleteAdminResources 将公告草稿引用视为阻塞,但删除事务会级联清理草稿与
  资源绑定,service 层永不进入事务,回滚审计注入测试无法到达预期路径。修复:service
  预检时过滤公告草稿引用,与 repository 级联语义对齐。
- project_settings_test.go 夹具未建 ResourceReferenceSnapshot 走查的 Task/Session/
  Message/TaskLog/Result/Announcement/AnnouncementImageDraft 表,报 no such table。
  修复:夹具 AutoMigrate 补齐。

后端全量 go test ./... 通过。

* feat(editor): 成片导出接入后端渲染任务(M4.2 前端接线)- timeline-tasks 增 createTimelineRenderTask;editor-export 主路径改 POST /timeline/renders 轮询取 resourceId 内嵌预览/下载,ffmpeg.wasm 降级保留

* feat(editor): M5 插件权限执行(fail-closed)- plugin-permission-check 新建(8 插槽最小权限表/判定/断言);SlotStack 渲染前过滤缺权限插槽改诊断条;editor-shell manifest 补 timeline.command/export.run;测试 10 用例含 editor-shell 真实回归

* feat(editor): M6.1 AI 命令 schema + M6.2 时间线摘要 - ai-command-schema(parse/dry-run 校验/12-op 提示词契约/版本化+golden);timeline-summary(确定性中文摘要/折叠上限);各 20/10 用例全绿

* docs(progress): M6 质检清单 - M6.1 schema/M6.2 摘要标记完成(含测试计数),M6.3 待模型源确认;清理 M5.1 重复条目

* feat(editor): AI 命令 schema 支持只读问答 - commands 空数组合法化(无法执行/纯提问时 reasoning 直接作答),新增 AI_EDITING_MAX_COMMANDS=8 上限防失控编辑,提示词同步

* feat(editor): AI 助手重构为右上悬浮对话面板(M6.3)+ 素材入库原子化收尾

- editor-ai-assistant.tsx: 占位面板替换为 OpenAI Canvas 风格右上悬浮对话面板;
  面板内文本模型选择器(effective.textModels,选择即持久化并覆写请求模型),
  消息流 user/assistant/error/preview 四类气泡 + 思考中占位,空态引导示例 chips
  一键发送,底部输入条;顶部工具栏常驻 Sparkles 入口(aria-pressed 切换)。
  修复上一版重复/截断函数声明(appendToEntry 双定义、settleAnswer 三声明等)。
  宿主链路:buildAiEditingSystemPrompt(summarizeTimeline(project)) 喂上下文 →
  parseAiCommandPlan → validateAiCommandBatch dry-run(任一非法整批拒绝)→
  ≤3 条 dispatch 直执行 / >3 条预览确认卡片;宿主拒绝时 failTurn 回填 FAILED_MARK 可重试。
- editor-asset-ingest/editor-preview-monitor/detail/editor.tsx: 配套接线与打磨
  (编辑器顶栏 AI 入口、素材导入面板、预览监视、项目页顶栏整理)。
- backend: LinkProjectAsset 事务内原子创建资产 + 项目链接(OnConflict 幂等),
  资产首次入库不再预落库,失败整体回滚,消除"有资产无链接"孤儿记录。
- docs: pending-test.mdx / runbook M6.3 状态更新(tsc + 测试 + 构建通过,
  浏览器冒烟待真实文本渠道)。
2026-09-04 14:03:33 +08:00

681 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Input, Modal, Select, Switch, Typography } from "antd";
import { AudioLines, CalendarDays, CheckCircle2, Clock3, CreditCard, 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 { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts";
import type { PluginManifest, PluginManifestV2, 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 { useAppearanceStore } from "@/stores/use-appearance-store";
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<string, string> = {
provider: "模型渠道",
"payment-provider": "支付协议",
"canvas-node": "画布节点",
workflow: "工作流",
transform: "媒体转换",
"asset-source": "素材来源",
"ai-capability": "AI 能力",
"usage-observer": "用量观察",
agent: "智能体",
"import-export": "导入导出",
};
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": "调用生成",
"ai.text": "调用已配置的文本/视觉理解模型",
"media.read": "读取输入媒体",
"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 },
{ key: "payment", label: "支付协议", description: "支付、查单、关单与对账", icon: CreditCard },
] as const;
export default function PluginsPage() {
const { message } = App.useApp();
const navigate = useNavigate();
const brandName = useAppearanceStore((state) => state.appearance.brandName);
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<BackendPlugin[]>([]);
const [backendPluginsLoading, setBackendPluginsLoading] = useState(false);
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
const [detailsPluginId, setDetailsPluginId] = useState<string | null>(null);
const [detailsRestoreFocus, setDetailsRestoreFocus] = useState(false);
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [scrollTarget, setScrollTarget] = useState<string | null>(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<EagleFolder[]>([]);
const [eagleFoldersLoading, setEagleFoldersLoading] = useState(false);
const [eagleFoldersError, setEagleFoldersError] = useState("");
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
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" && !pluginMatchesCategory(manifest, categoryFilter)) 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) => pluginMatchesCategory(plugin.manifest, section.key)) })),
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
],
[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<string, number> = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, payment: 0, other: 0 };
for (const plugin of visiblePlugins) {
for (const section of protocolSectionMeta) {
if (pluginMatchesCategory(plugin.manifest, section.key)) counts[section.key] += 1;
}
if (pluginMatchesCategory(plugin.manifest, "other")) counts.other += 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 (
<main className="app-workspace-page plugins-page flex h-full min-h-0 flex-col text-foreground">
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto">
<div className="plugins-page-layout">
<aside className="plugins-sidebar" aria-label="插件分类">
<div className="plugins-sidebar-heading">
<span className="plugins-sidebar-kicker">PLUGIN CENTER</span>
<h1>插件中心</h1>
<p>统一管理 provider、工作流、画布节点和其他扩展能力。</p>
</div>
<nav className="plugins-sidebar-nav">
<button type="button" className={`plugins-sidebar-item${categoryFilter === "all" ? " is-active" : ""}`} aria-current={categoryFilter === "all" ? "page" : undefined} onClick={() => selectCategory("all")}>
<span className="plugins-sidebar-item-label">
<PlugZap className="size-4" />
全部插件
</span>
<span>{categoryCounts.all}</span>
</button>
{protocolSectionMeta.map((section) => {
const SectionIcon = section.icon;
return (
<button
key={section.key}
type="button"
className={`plugins-sidebar-item${categoryFilter === section.key ? " is-active" : ""}`}
aria-current={categoryFilter === section.key ? "page" : undefined}
onClick={() => selectCategory(section.key)}
>
<span className="plugins-sidebar-item-label">
<SectionIcon className="size-4" />
{section.label}
</span>
<span>{categoryCounts[section.key] || 0}</span>
</button>
);
})}
<button type="button" className={`plugins-sidebar-item${categoryFilter === "other" ? " is-active" : ""}`} aria-current={categoryFilter === "other" ? "page" : undefined} onClick={() => selectCategory("other")}>
<span className="plugins-sidebar-item-label">
<PlugZap className="size-4" />
应用插件
</span>
<span>{categoryCounts.other}</span>
</button>
</nav>
</aside>
<div className="plugins-page-content">
<div className="plugins-toolbar" aria-label="插件筛选">
<Input
className="plugins-search"
prefix={<Search className="size-4 text-foreground/38" aria-hidden="true" />}
value={search}
allowClear
placeholder="搜索插件名称、描述或作者"
onChange={(event) => setSearch(event.target.value)}
/>
<Select
className="plugins-filter"
value={statusFilter}
options={[
{ value: "all", label: "全部状态" },
{ value: "enabled", label: "已启用" },
{ value: "disabled", label: "已停用" },
]}
onChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
aria-label="按状态筛选"
/>
<Select
className="plugins-filter"
value={trustFilter}
options={[
{ value: "all", label: "全部来源" },
{ value: "trusted", label: "可信插件" },
]}
onChange={(value) => setTrustFilter(value as "all" | "trusted")}
aria-label="按来源筛选"
/>
<span className="plugins-filter-icon" aria-hidden="true">
<SlidersHorizontal className="size-4" />
</span>
<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 ? (
<div className="plugins-sections">
{pluginSections.map((section) => {
if (!section.plugins.length) return null;
const SectionIcon = section.icon;
return (
<section
key={section.key}
id={`plugin-section-${section.key}`}
ref={(element) => {
sectionRefs.current[section.key] = element;
}}
className="plugin-section"
>
<header className="plugin-section-heading">
<span className="plugin-section-icon">
<SectionIcon className="size-4" />
</span>
<div>
<h2>{section.label}</h2>
<p>{section.description}</p>
</div>
<span className="plugin-section-count">{section.plugins.length}</span>
</header>
<div className="plugins-grid">
{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 (
<section key={plugin.manifest.id} className={`plugin-card library-card-surface${trusted ? " is-trusted" : ""}`}>
<button
type="button"
className="plugin-card-main"
aria-label={`查看${plugin.manifest.name}文档`}
onClick={(event) => {
const openedByKeyboard = event.detail === 0;
setDetailsRestoreFocus(openedByKeyboard);
setDetailsPluginId(plugin.manifest.id);
if (!openedByKeyboard) event.currentTarget.blur();
}}
>
<div className="plugin-card-heading">
<span className={`plugin-icon-tile${trusted ? " is-trusted" : ""}`} aria-hidden="true">
<PlugZap className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="plugin-card-title-row">
<h3>{plugin.manifest.name}</h3>
<span className="plugin-version">v{plugin.manifest.version}</span>
</div>
<div className="plugin-card-labels">
<span className={`plugin-source-label${sourceLabel === "系统插件" ? " is-system" : ""}`}>{sourceLabel}</span>
{trusted ? (
<span className="plugin-trust-label">
<ShieldCheck className="size-3.5" />
可信插件
</span>
) : null}
<span className="plugin-category-label">
{contributionKindsFor(plugin.manifest)
.map((kind) => categoryLabels[kind] ?? kind)
.join(" · ")}
</span>
</div>
</div>
</div>
<p className="plugin-card-description">{plugin.manifest.description}</p>
<div className="plugin-card-meta">
<span>
<CalendarDays className="size-3.5" />
发布 {formatPluginDate(plugin.manifest.publishedAt)}
</span>
<span>
<Clock3 className="size-3.5" />
更新 {formatPluginDate(plugin.manifest.updatedAt)}
</span>
</div>
<div className="plugin-card-tags">
{(plugin.manifest.surfaces || []).map((surface) => (
<span key={surface}>{surfaceLabels[surface] ?? surface}</span>
))}
{providerCapabilitiesFor(plugin.manifest).map((capability) => (
<span key={capability}>{capabilityLabel(capability)}</span>
))}
{plugin.manifest.contributes.providers?.some((provider) => provider.poll) ? <span>异步轮询</span> : null}
<span>{plugin.manifest.permissions.length} 项能力</span>
</div>
</button>
<div className="plugin-card-actions">
<span role="status" className={`settings-channel-status ${enabled ? "is-ready" : ""}`}>
<i aria-hidden="true" />
{!state?.platformAvailable && state?.blockedReason ? state.blockedReason : enabled ? "已启用" : "已停用"}
</span>
<Switch
className="plugin-state-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"
icon={<Settings2 className="size-4" />}
aria-expanded={settingsPluginId === plugin.manifest.id}
aria-haspopup="dialog"
onClick={() => setSettingsPluginId(plugin.manifest.id)}
>
设置
</Button>
) : null}
</div>
{installation?.lastError || remote?.error ? (
<Typography.Text type="danger" className="plugin-error" role="alert">
{installation?.lastError || remote?.error}
</Typography.Text>
) : null}
</section>
);
})}
</div>
</section>
);
})}
</div>
) : (
<div className="plugins-empty-state">
<SlidersHorizontal className="size-7" aria-hidden="true" />
<h3>没有匹配的插件</h3>
<p>试试清空搜索词,或放宽筛选条件。</p>
<Button
onClick={() => {
setSearch("");
setCategoryFilter("all");
setStatusFilter("all");
setTrustFilter("all");
}}
>
清除筛选
</Button>
</div>
)}
<Modal
className="workspace-modal workspace-modal-wide plugin-settings-modal"
title={settingsPlugin ? `${settingsPlugin.manifest.name} 设置` : null}
open={Boolean(settingsPlugin)}
centered
footer={null}
destroyOnHidden
onCancel={() => setSettingsPluginId(null)}
styles={{ body: { maxHeight: "min(72vh, 760px)", overflowY: "auto", overscrollBehavior: "contain" } }}
>
{settingsPlugin ? (
<div className="plugin-settings-panel plugin-settings-modal-panel">
<div className="plugin-settings-heading">
<div>
<p>只展示这个插件实际支持的配置项。</p>
</div>
{settingsPlugin.manifest.trusted ? (
<span className="plugin-trust-label">
<ShieldCheck className="size-3.5" />
可信插件
</span>
) : (
<span className="plugin-category-label">第三方插件</span>
)}
</div>
{settingsPlugin.manifest.id === EAGLE_PLUGIN_ID ? (
<>
<div className="plugin-settings-fields">
<div className="min-w-0">
<label htmlFor="eagle-base-url">Eagle 本地 API 地址</label>
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" />
<p>Eagle 必须在本机运行;{brandName}通过插件直接读取和写入 Eagle 原始文件。</p>
</div>
<div className="min-w-0">
<div className="plugin-setting-label-row">
<label htmlFor="eagle-auto-upload-generated">自动归档生成结果</label>
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
</div>
<p>图片、视频和音频生成成功后,自动写入 Eagle;{brandName}本地素材仍会保留。</p>
</div>
<div className="min-w-0">
<div className="plugin-setting-label-row">
<label htmlFor="eagle-generated-folder">生成结果写入文件夹</label>
<Button type="link" size="small" loading={eagleFoldersLoading} onClick={() => void loadEagleFolders()}>
读取文件夹
</Button>
</div>
<Select
id="eagle-generated-folder"
aria-label="生成结果写入文件夹"
showSearch
allowClear
value={eagleGeneratedFolderId || undefined}
placeholder="Eagle 根目录"
optionFilterProp="label"
options={[{ value: "__root__", label: "Eagle 根目录" }, ...eagleFolderOptions(eagleFolders)]}
onChange={(value) => setEagleGeneratedFolderId(value === "__root__" || !value ? "" : value)}
/>
<p>{eagleFoldersError || "默认写入 Eagle 根目录;选择文件夹后按 Eagle 原始目录归档。"}</p>
</div>
</div>
<div className="plugin-settings-actions">
<Button type="primary" icon={<CheckCircle2 className="size-4" />} onClick={saveEagleConfig}>
保存配置
</Button>
<Button icon={<FolderOpen className="size-4" />} disabled={!settingsEnabled} onClick={() => navigate("/plugins/eagle")}>
打开 Eagle 素材库
</Button>
<Button icon={<ExternalLink className="size-4" />} href="https://api.eagle.cool/" target="_blank">
查看 API
</Button>
</div>
</>
) : settingsPlugin.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID ? (
<div className="rounded-[var(--r-md)] border border-border/60 bg-muted/25 px-3 py-3 text-[var(--fs-body)] leading-6 text-foreground/70">
<p>在创作页或图片、视频节点的提示词编辑器中使用“优化”按钮,即可让当前文本模型整理提示词。</p>
<p className="mt-2 text-[var(--fs-micro)] text-foreground/50">插件不会自动覆盖原提示词,只有点击“采用”后才会回填到当前输入框。</p>
</div>
) : settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID || settingsPlugin.manifest.id === COMFYUI_PLUGIN_ID ? (
<div className="plugin-settings-empty">
<p>{settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID ? "RunningHub 的 API Key、Workflow / App 和字段映射在宿主设置页维护。" : "ComfyUI Bridge 的设备、服务地址和工作流字段在宿主设置页维护。"}</p>
<Button
type="primary"
icon={<ExternalLink className="size-4" />}
onClick={() => {
setSettingsPluginId(null);
navigate(`/settings?section=${settingsPlugin.manifest.id === RUNNINGHUB_PLUGIN_ID ? "runninghub" : "comfyui"}`);
}}
>
打开工作流设置
</Button>
</div>
) : (
<div className="plugin-settings-empty">
{`贡献能力:${
contributionKindsFor(settingsPlugin.manifest)
.map((kind) => categoryLabels[kind] || kind)
.join("、") || "未声明"
}。当前接入位置和权限会根据插件清单自动生效。`}
</div>
)}
<div className="plugin-permissions">
<div>
<span>接入位置</span>
{(settingsPlugin.manifest.surfaces || []).map((surface) => surfaceLabels[surface] ?? surface).join("、") || "由贡献点决定"}
</div>
<div>
<span>插件能力</span>
{settingsPlugin.manifest.permissions.map((permission) => permissionLabels[permission] ?? permission).join("、")}
</div>
</div>
</div>
) : null}
</Modal>
<PluginDetailsModal plugin={detailsPlugin} restoreFocus={detailsRestoreFocus} onClose={() => setDetailsPluginId(null)} />
</div>
</div>
</div>
</main>
);
}
function formatPluginDate(value?: string) {
if (!value) return "未记录";
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? pluginDateFormatter.format(timestamp) : "未记录";
}
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", ART_CRITIQUE_PLUGIN_ID].includes(pluginId);
}
function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) {
if (plugin.source === "uploaded") return "自定义插件";
if (plugin.source === "system") return "系统插件";
if (state?.canToggle || isOfficialApplicationPlugin(plugin.manifest.id)) return "官方插件";
return "系统插件";
}
function contributionKindsFor(manifest: PluginManifest | PluginManifestV2): string[] {
const contributions = manifest.contributes;
const kinds: string[] = [];
if (contributions.providers?.length) kinds.push("provider");
if (contributions.paymentProviders?.length) kinds.push("payment-provider");
if (contributions.workflows?.length) kinds.push("workflow");
if (contributions.canvasNodes?.length) kinds.push("canvas-node");
if (contributions.transforms?.length) kinds.push("transform");
if (contributions.assetSources?.length) kinds.push("asset-source");
if (contributions.aiCapabilities?.length) kinds.push("ai-capability");
if (contributions.usageObservers?.length) kinds.push("usage-observer");
if (contributions.agents?.length) kinds.push("agent");
if (contributions.importExport?.length) kinds.push("import-export");
return kinds;
}
function providerCapabilitiesFor(manifest: PluginManifest | PluginManifestV2) {
return [...new Set((manifest.contributes.providers || []).flatMap((provider) => provider.capabilities))];
}
function pluginMatchesCategory(manifest: PluginManifest | PluginManifestV2, category: string) {
const providerCapabilities = providerCapabilitiesFor(manifest);
const isPaymentProtocol = Boolean(manifest.contributes.paymentProviders?.length);
if (category === "payment") return isPaymentProtocol;
if (category === "other") return !isPaymentProtocol && providerCapabilities.length === 0;
return providerCapabilities.includes(category as "text" | "image" | "video" | "audio");
}
function capabilityLabel(value: string) {
return ({ text: "文本", image: "图片", video: "视频", audio: "音频" } as Record<string, string>)[value] || value;
}
function eagleFolderOptions(folders: EagleFolder[]) {
const byId = new Map(folders.map((folder) => [folder.id, folder]));
const pathFor = (folder: EagleFolder) => {
const path: string[] = [];
const seen = new Set<string>();
let current: EagleFolder | undefined = folder;
while (current && !seen.has(current.id)) {
seen.add(current.id);
path.unshift(current.name);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return path.join(" / ");
};
return folders.map((folder) => ({ value: folder.id, label: pathFor(folder) })).sort((left, right) => left.label.localeCompare(right.label, "zh-CN"));
}