diff --git a/backend/internal/handler/comfy_bridge.go b/backend/internal/handler/comfy_bridge.go index 07fcfa670..a4447610b 100644 --- a/backend/internal/handler/comfy_bridge.go +++ b/backend/internal/handler/comfy_bridge.go @@ -38,6 +38,10 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + failService(c, err) + return + } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) var req service.CreateComfyBridgeRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -57,6 +61,10 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + failService(c, err) + return + } bridges, err := svc.ComfyBridges(user.ID) if err != nil { fail(c, http.StatusInternalServerError, err) @@ -70,6 +78,10 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + failService(c, err) + return + } if err := svc.RevokeComfyBridge(user.ID, c.Param("id")); err != nil { failService(c, err) return @@ -85,6 +97,10 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + failService(c, err) + return + } wait, err := comfyBridgeWait(c.Query("wait")) if err != nil { fail(c, http.StatusBadRequest, err) @@ -106,6 +122,10 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + failService(c, err) + return + } if c.Request.ContentLength > maxComfyBridgeRequestBytes { fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB")) return @@ -143,6 +163,10 @@ func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("comfyui-bridge-image"); err != nil { + failService(c, err) + return + } var req struct { Capabilities map[string]any `json:"capabilities,omitempty"` } diff --git a/backend/internal/handler/plugin.go b/backend/internal/handler/plugin.go index 67be8a58c..950dbc435 100644 --- a/backend/internal/handler/plugin.go +++ b/backend/internal/handler/plugin.go @@ -14,6 +14,14 @@ import ( ) func RegisterPluginRoutes(r *gin.RouterGroup, svc *service.Service) { + statusRoutes := r.Group("/plugins") + statusRoutes.GET("/status", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"statuses": svc.WorkflowPluginStatuses()}) + }) pluginRoutes := r.Group("/plugins") pluginRoutes.Use(requirePluginCenterAccess(svc)) // The frontend plugin center is the single management surface. Protocol diff --git a/backend/internal/handler/runninghub.go b/backend/internal/handler/runninghub.go index ac63008e3..ccf8c97b0 100644 --- a/backend/internal/handler/runninghub.go +++ b/backend/internal/handler/runninghub.go @@ -15,6 +15,10 @@ func RegisterRunningHubRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } + if err := svc.RequireWorkflowPluginForInterface("runninghub-workflow-image"); err != nil { + failService(c, err) + return + } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10) var req service.RunningHubWorkflowFetchRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index 44de17b05..bd25ad737 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -113,7 +113,7 @@ func (c *pluginRuntime) bootstrapBundledPlugins() error { byID[record.ID] = record } items := protocol.Builtins().List("", "", true) - bundledIDs := make(map[string]struct{}, len(items)) + bundledIDs := make(map[string]struct{}, len(items)+2) for _, metadata := range items { bundledIDs[metadata.ID] = struct{}{} protocol.AttachDocumentation(&metadata) @@ -168,6 +168,34 @@ func (c *pluginRuntime) bootstrapBundledPlugins() error { record.ID, record.Raw, record.Source, record.PackagePath = metadata.ID, data, "bundled", "" byID[metadata.ID] = record } + for _, workflow := range bundledWorkflowPluginManifests() { + bundledIDs[workflow.Metadata.ID] = struct{}{} + data, err := json.Marshal(workflow) + if err != nil { + return fmt.Errorf("encode bundled workflow plugin %s: %w", workflow.Metadata.ID, err) + } + record := byID[workflow.Metadata.ID] + if len(record.Raw) > 0 { + var installed protocol.Manifest + if err := json.Unmarshal(record.Raw, &installed); err != nil { + return fmt.Errorf("decode bundled workflow plugin %s: %w", workflow.Metadata.ID, err) + } + workflow.Metadata.Enabled = installed.Metadata.Enabled + data, err = json.Marshal(workflow) + if err != nil { + return fmt.Errorf("encode bundled workflow plugin %s: %w", workflow.Metadata.ID, err) + } + } + if !bytes.Equal(record.Raw, data) { + now := time.Now().UTC() + if record.InstalledAt.IsZero() { + record.InstalledAt = now + } + record.UpdatedAt = now + } + record.ID, record.Raw, record.Source, record.PackagePath = workflow.Metadata.ID, data, "bundled", "" + byID[workflow.Metadata.ID] = record + } result := make([]pluginRegistryRecord, 0, len(byID)) for _, record := range byID { if record.Source == "bundled" { diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index b5d6010fb..36298188e 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -21,8 +21,8 @@ func TestPluginViewIncludesDocumentationForEveryBundledProtocol(t *testing.T) { t.Fatal(err) } plugins := center.list() - if len(plugins) != len(protocol.Builtins().List("", "", true)) { - t.Fatalf("plugin views = %d, builtins = %d", len(plugins), len(protocol.Builtins().List("", "", true))) + if len(plugins) != len(protocol.Builtins().List("", "", true))+2 { + t.Fatalf("plugin views = %d, builtins plus workflow plugins = %d", len(plugins), len(protocol.Builtins().List("", "", true))+2) } for _, plugin := range plugins { if plugin.Source != "bundled" { @@ -35,6 +35,32 @@ func TestPluginViewIncludesDocumentationForEveryBundledProtocol(t *testing.T) { } } +func TestBundledWorkflowPluginsControlAvailability(t *testing.T) { + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + svc := &Service{pluginRuntime: center} + if err := svc.RequireWorkflowPluginForInterface("runninghub-workflow-image"); err == nil { + t.Fatal("bundled RunningHub plugin was enabled by default") + } + if _, err := center.setEnabled(WorkflowPluginRunningHub, true); err != nil { + t.Fatal(err) + } + if err := svc.RequireWorkflowPluginForInterface("runninghub-workflow-image"); err != nil { + t.Fatalf("enabled RunningHub plugin rejected: %v", err) + } + if _, err := center.setEnabled(WorkflowPluginRunningHub, false); err != nil { + t.Fatal(err) + } + if err := svc.RequireWorkflowPluginForInterface("runninghub-workflow-video"); err == nil { + t.Fatal("disabled RunningHub plugin remained available") + } + if got := svc.WorkflowPluginStatuses()[WorkflowPluginRunningHub]; got != "disabled" { + t.Fatalf("RunningHub status = %q, want disabled", got) + } +} + func TestBundledProviderCatalogExposesUpstreamOperation(t *testing.T) { center, err := newPluginRuntime(t.TempDir()) if err != nil { diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 3b57d59d9..efa6edb5e 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -295,6 +295,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string input.Config = config ctx = withProviderOutboundPolicy(ctx, input.Config) if isWorkflowProviderInterface(input.Config.InterfaceType) { + if err := s.RequireWorkflowPluginForInterface(input.Config.InterfaceType); err != nil { + return nil, err + } if err := validateWorkflowProviderConfig(input.Mode, input.Config); err != nil { return nil, err } diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index 138229256..c9869bcd0 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -30,7 +30,12 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, logicalModelID := strings.TrimSpace(req.LogicalModelID) workflowProviderTask := taskInputUsesWorkflowProvider(normalizedInput) frontendEnabled := false - if !workflowProviderTask { + if workflowProviderTask { + config, _ := normalizedInput["config"].(map[string]any) + if err := s.RequireWorkflowPluginForInterface(strings.TrimSpace(fmt.Sprint(config["interfaceType"]))); err != nil { + return nil, err + } + } else { // 工作流是独立执行器;普通模型仍严格使用主线的目录和路由校验。 frontendEnabled, err = s.FeatureEnabled(FeatureFrontendModels) if err != nil { diff --git a/backend/internal/service/workflow_plugins.go b/backend/internal/service/workflow_plugins.go new file mode 100644 index 000000000..088539b67 --- /dev/null +++ b/backend/internal/service/workflow_plugins.go @@ -0,0 +1,95 @@ +package service + +import ( + "strings" + + "infinite-canvas/backend/internal/protocol" +) + +const ( + WorkflowPluginRunningHub = "runninghub-workflow-provider" + WorkflowPluginComfyUI = "comfyui-workflow-provider" +) + +// bundledWorkflowPluginManifests keeps workflow capabilities in the same +// runtime registry as protocol plugins. The execution implementation remains +// in the workflow provider adapter for now, but lifecycle and availability are +// governed by the plugin runtime rather than by individual UI consumers. +func bundledWorkflowPluginManifests() []protocol.Manifest { + return []protocol.Manifest{ + workflowPluginManifest(WorkflowPluginRunningHub, "RunningHub 工作流", "在画布中拉取并执行 RunningHub Workflow 与 App。"), + workflowPluginManifest(WorkflowPluginComfyUI, "ComfyUI Bridge 工作流", "通过本机或云端 Bridge 发现、映射并执行 ComfyUI API 工作流。"), + } +} + +func workflowPluginManifest(id, name, description string) protocol.Manifest { + capabilities := []protocol.Capability{protocol.CapabilityImage, protocol.CapabilityVideo, protocol.CapabilityAudio} + workflows := make([]protocol.ManifestWorkflow, 0, len(capabilities)) + for _, capability := range capabilities { + workflows = append(workflows, protocol.ManifestWorkflow{ + ID: id + "-" + string(capability), + Label: name + " · " + string(capability), + ProviderID: id, + Capability: capability, + Parameters: []protocol.Parameter{}, + }) + } + return protocol.Manifest{ + APIVersion: "yingce.plugin/v1", + Metadata: protocol.Metadata{ + ID: id, + Version: "1.0.0", + Name: name, + Vendor: "影策", + Description: description, + Documentation: "# " + name + "\n\n## 影策运行时合同\n\n该工作流能力由插件运行时统一管理。", + Enabled: false, + Installable: true, + }, + Surfaces: []string{"node", "settings"}, + Runtime: protocol.ManifestRuntime{Backend: "trusted-backend", Web: "trusted-backend"}, + Permissions: []string{"generation.run", "external.open"}, + Contributes: protocol.ManifestContributions{Workflows: workflows}, + } +} + +func workflowPluginIDForInterface(value string) (string, bool) { + switch value { + case "runninghub-workflow-image", "runninghub-workflow-video", "runninghub-workflow-audio": + return WorkflowPluginRunningHub, true + case "comfyui-bridge-image", "comfyui-bridge-video", "comfyui-bridge-audio": + return WorkflowPluginComfyUI, true + default: + return "", false + } +} + +func (s *Service) WorkflowPluginStatuses() map[string]string { + statuses := make(map[string]string, 2) + for _, plugin := range s.Plugins() { + if plugin.Manifest.ID == WorkflowPluginRunningHub || plugin.Manifest.ID == WorkflowPluginComfyUI { + statuses[plugin.Manifest.ID] = plugin.Status + } + } + return statuses +} + +func (s *Service) RequireWorkflowPluginForInterface(interfaceType string) error { + pluginID, ok := workflowPluginIDForInterface(normalizeWorkflowInterfaceType(interfaceType)) + if !ok { + return Forbidden("未知工作流插件") + } + status, exists := s.WorkflowPluginStatuses()[pluginID] + if !exists || status != "enabled" { + if pluginID == WorkflowPluginRunningHub { + return Forbidden("RunningHub 工作流插件未启用") + } + return Forbidden("ComfyUI Bridge 工作流插件未启用") + } + return nil +} + +func normalizeWorkflowInterfaceType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + return value +} diff --git a/backend/internal/service/workflow_provider.go b/backend/internal/service/workflow_provider.go index 027af3615..e1ed175c3 100644 --- a/backend/internal/service/workflow_provider.go +++ b/backend/internal/service/workflow_provider.go @@ -297,29 +297,19 @@ func workflowFieldRawBool(raw map[string]json.RawMessage, fallback bool, keys .. } func isRunningHubInterface(value string) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "runninghub-workflow-") + pluginID, ok := workflowPluginIDForInterface(strings.ToLower(strings.TrimSpace(value))) + return ok && pluginID == WorkflowPluginRunningHub } func isComfyBridgeInterface(value string) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "comfyui-bridge-") + pluginID, ok := workflowPluginIDForInterface(strings.ToLower(strings.TrimSpace(value))) + return ok && pluginID == WorkflowPluginComfyUI } func isWorkflowProviderInterface(value string) bool { return isRunningHubInterface(value) || isComfyBridgeInterface(value) } -func normalizeWorkflowInterface(value string, modelName string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if isWorkflowProviderInterface(value) { - return value - } - // 仅在调用方没有明确协议时用于 Bridge 的安全默认值;普通模型不会走这里。 - if strings.TrimSpace(modelName) != "" { - return string(model.ChannelInterfaceComfyBridgeImage) - } - return value -} - func validateWorkflowProviderConfig(mode string, config providerConfig) error { if mode != "image" && mode != "video" && mode != "audio" { return fmt.Errorf("工作流协议暂不支持%s生成", mode) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 2273d2ae7..5791eef7f 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -8,6 +8,7 @@ description: 影策已实现功能的手工验证清单与未确认边界 ## RunningHub 与 ComfyUI Bridge 工作流 - [x] 后端全量测试、前端类型检查、Canvas Agent 构建和原生 Bridge Go 测试通过。 +- [x] 工作流插件停用状态覆盖设置入口、画布创建、配置面板、任务创建、管理 API 和 worker 执行校验。 - [x] 覆盖后端重启后请求领取、结果先落库再恢复、原请求 ID 幂等复用,以及 RunningHub API Key 不进入 URL 的自动化测试。 - [ ] 使用真实 RunningHub Workflow 和 App 分别验证参数发现、图片/视频/音频输入映射、提交、轮询、取消和结果下载。 - [ ] 使用真实 ComfyUI API 工作流验证 Bridge 注册、心跳、领取、完成、取消和大文件结果回传。 diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 6404d600d..032fcbf14 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -10,6 +10,8 @@ import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOption import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { useThemeStore } from "@/stores/use-theme-store"; +import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; +import { usePluginStore } from "@/stores/use-plugin-store"; import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasVideoEditOperation, CanvasWorkspaceMode } from "@/types/canvas"; type CanvasConfigNodePanelProps = { @@ -58,11 +60,12 @@ function runningHubWorkflowEntryKey(workflow: RunningHubWorkflow): string { export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) { const globalConfig = useEffectiveConfig(); + const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const mode = node.metadata?.generationMode === "video" || node.metadata?.generationMode === "audio" ? node.metadata.generationMode : "image"; const simpleMode = workspaceMode === "simple"; const resolvedProvider = resolveCanvasWorkflowProvider(node.metadata); - const workflowProvider = resolvedProvider === "comfyui" ? "comfyui" : "runninghub"; + const workflowProvider = resolvedProvider; const workflowCapability: RunningHubCapability = mode === "video" ? "video" : mode === "audio" ? "audio" : "image"; const requirements: ModelRequirements = { capability: mode, @@ -148,16 +151,16 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); const workflowParameterError = firstWorkflowParameterError(dynamicWorkflowFields, node.metadata?.workflowParameters || {}); const capabilityError = workflowParameterError || (workflowProvider === "runninghub" - ? (!globalConfig.runningHub.enabled ? "请先在设置中启用 RunningHub" : !node.metadata?.runningHubWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流或 App` : !selectedRunningHubWorkflow ? "当前画布引用的 RunningHub 条目已不存在,请重新选择" : selectedRunningHubCapability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedRunningHubCapability || "image")},请切换画布模式或重新选择条目` : undefined) + ? (!workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "RunningHub 工作流插件未启用" : !globalConfig.runningHub.enabled ? "请先在设置中启用 RunningHub" : !node.metadata?.runningHubWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流或 App` : !selectedRunningHubWorkflow ? "当前画布引用的 RunningHub 条目已不存在,请重新选择" : selectedRunningHubCapability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedRunningHubCapability || "image")},请切换画布模式或重新选择条目` : undefined) : workflowProvider === "comfyui" - ? (!globalConfig.comfyBridge.enabled ? "请先在设置中启用 ComfyUI Bridge" : !globalConfig.comfyBridge.bridgeId ? "请选择在线 Bridge" : !node.metadata?.comfyBridgeWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流` : !selectedComfyBridgeWorkflow ? "当前画布引用的 ComfyUI 条目已不存在,请重新选择" : selectedComfyBridgeWorkflow.capability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedComfyBridgeWorkflow.capability)},请切换画布模式或重新选择条目` : undefined) + ? (!workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "ComfyUI Bridge 工作流插件未启用" : !globalConfig.comfyBridge.enabled ? "请先在设置中启用 ComfyUI Bridge" : !globalConfig.comfyBridge.bridgeId ? "请选择在线 Bridge" : !node.metadata?.comfyBridgeWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流` : !selectedComfyBridgeWorkflow ? "当前画布引用的 ComfyUI 条目已不存在,请重新选择" : selectedComfyBridgeWorkflow.capability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedComfyBridgeWorkflow.capability)},请切换画布模式或重新选择条目` : undefined) : undefined); const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError; return (
event.stopPropagation()}>
-
{simpleMode ? "快速生成" : "工作流生成"}
+
{simpleMode ? "快速生成" : workflowProvider === "model" ? "生成配置" : "工作流生成"}
{simpleMode ? 自动配置 :
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> - 工作流来源 + 生成来源 { - const nextProvider = value as "runninghub" | "comfyui"; + const nextProvider = value as "model" | "runninghub" | "comfyui"; + if (nextProvider === "model") { + onConfigChange(node.id, { workflowProvider: "model", workflowTitle: undefined, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} }); + return; + } if (nextProvider === "runninghub") { onConfigChange(node.id, { workflowProvider: "runninghub", workflowTitle: "RunningHub 工作流", comfyBridgeWorkflowId: undefined, workflowParameters: {} }); } else { diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index 2b3728799..042a6c2a5 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -221,7 +221,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co } title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> } title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> } title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> - } title="工作流生成" description="在同一节点切换 RunningHub 或 ComfyUI" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> + } title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> {canCreateDrawing ? } title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} } title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> } title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} /> diff --git a/web/src/components/layout/app-providers.tsx b/web/src/components/layout/app-providers.tsx index 42d59c0ab..d1a5d77cb 100644 --- a/web/src/components/layout/app-providers.tsx +++ b/web/src/components/layout/app-providers.tsx @@ -11,16 +11,38 @@ 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 { useUserStore } from "@/stores/use-user-store"; export function AppProviders({ children }: { children: ReactNode }) { const theme = useThemeStore((state) => state.theme); const dark = theme === "dark"; const ensurePlugin = usePluginStore((state) => state.ensurePlugin); + const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses); + const userId = useUserStore((state) => state.user?.id); useEffect(() => { for (const plugin of listRegisteredPlugins()) ensurePlugin(plugin.manifest); }, [ensurePlugin]); + useEffect(() => { + if (!userId) { + setRuntimeStatuses({}); + return; + } + let cancelled = false; + void fetchWorkflowPluginStatuses() + .then((statuses) => { + if (!cancelled) setRuntimeStatuses(statuses); + }) + .catch(() => { + if (!cancelled) setRuntimeStatuses({}); + }); + return () => { + cancelled = true; + }; + }, [setRuntimeStatuses, userId]); + useEffect(() => { document.documentElement.classList.toggle("dark", dark); document.documentElement.style.colorScheme = theme; diff --git a/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx b/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx index 88c31fdcc..a8b9d16ee 100644 --- a/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx +++ b/web/src/lib/canvas/tool-registry/definitions/add-node-menu-tools.tsx @@ -10,7 +10,7 @@ function nodeCommand(type: CanvasNodeType, rest: Omit, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() }, // 创作节点 nodeCommand(CanvasNodeType.Text, { defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() }), diff --git a/web/src/lib/generation-workflow-execution.ts b/web/src/lib/generation-workflow-execution.ts index c7a69974e..4690f5a8c 100644 --- a/web/src/lib/generation-workflow-execution.ts +++ b/web/src/lib/generation-workflow-execution.ts @@ -1,4 +1,6 @@ import { normalizeRunningHubCapability, type AiConfig, type RunningHubCapability, type RunningHubWorkflowKind, type WorkflowFieldMapping } from "@/stores/use-config-store"; +import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; +import { usePluginStore } from "@/stores/use-plugin-store"; export type GenerationWorkflowMode = "text" | "image" | "video" | "audio"; @@ -23,6 +25,7 @@ export function resolveGenerationWorkflowExecution(config: AiConfig, mode: Gener if (workflowProvider === "model") return null; if (workflowProvider === "runninghub") { + if (!workflowProviderPluginEnabled(usePluginStore.getState().runtimeStatuses, "runninghub")) throw new Error("RunningHub 工作流插件未启用"); const runningHub = config.runningHub; const workflowId = runningHub.workflowId.trim(); const kind = runningHub.selectedKind === "app" ? "app" : "workflow"; @@ -53,6 +56,7 @@ export function resolveGenerationWorkflowExecution(config: AiConfig, mode: Gener }; } + if (!workflowProviderPluginEnabled(usePluginStore.getState().runtimeStatuses, "comfyui")) throw new Error("ComfyUI Bridge 工作流插件未启用"); const comfyBridge = config.comfyBridge; const workflowId = comfyBridge.workflowId.trim(); if (!comfyBridge.enabled || !comfyBridge.bridgeId.trim() || !workflowId) { diff --git a/web/src/lib/plugins/builtin/workflows.ts b/web/src/lib/plugins/builtin/workflows.ts index 1ab87e652..5bcb43294 100644 --- a/web/src/lib/plugins/builtin/workflows.ts +++ b/web/src/lib/plugins/builtin/workflows.ts @@ -4,6 +4,16 @@ import { PLUGIN_API_VERSION, type PluginManifest, type RegisteredPlugin } from " export const RUNNINGHUB_PLUGIN_ID = "runninghub-workflow-provider"; export const COMFYUI_PLUGIN_ID = "comfyui-workflow-provider"; +export type WorkflowProvider = "runninghub" | "comfyui"; + +export function workflowPluginId(provider: WorkflowProvider) { + return provider === "runninghub" ? RUNNINGHUB_PLUGIN_ID : COMFYUI_PLUGIN_ID; +} + +export function workflowProviderPluginEnabled(statuses: Record, provider: WorkflowProvider) { + return statuses[workflowPluginId(provider)] === "enabled"; +} + function workflowContributions(providerId: string, label: string) { return (["image", "video", "audio"] as const).map((capability) => ({ id: `${providerId}-${capability}`, diff --git a/web/src/pages/canvas/use-canvas-connection-controller.ts b/web/src/pages/canvas/use-canvas-connection-controller.ts index 53da9883f..022525c11 100644 --- a/web/src/pages/canvas/use-canvas-connection-controller.ts +++ b/web/src/pages/canvas/use-canvas-connection-controller.ts @@ -13,6 +13,8 @@ import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-f import { normalizeRunningHubCapability, type AiConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas"; +import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; +import { usePluginStore } from "@/stores/use-plugin-store"; type UseCanvasConnectionControllerOptions = { projectId: string; @@ -78,6 +80,7 @@ export function useCanvasConnectionController({ }: UseCanvasConnectionControllerOptions) { const { message } = App.useApp(); const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey); + const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); const [connectingParams, setConnectingParams] = useState(null); const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState(); @@ -210,8 +213,13 @@ export function useCanvasConnectionController({ ? batchSourceNodeIds.length ? batchScriptPrompt : sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : "" : ""; const selectedWorkflowProvider = nodeType === CanvasNodeType.Config - ? workflowProvider || (config.runningHub.enabled && config.runningHub.workflows.length ? "runninghub" : config.comfyBridge.enabled && config.comfyBridge.workflows.length ? "comfyui" : "runninghub") + ? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "comfyui" : undefined) : undefined; + if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) { + message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`); + closeConnectionCreateMenu(); + return; + } const runningHubWorkflow = selectedWorkflowProvider === "runninghub" ? selectRunningHubWorkflow(config) : undefined; const comfyBridgeWorkflow = selectedWorkflowProvider === "comfyui" ? selectComfyBridgeWorkflow(config) : undefined; const workflowCapability = selectedWorkflowProvider === "runninghub" @@ -338,10 +346,13 @@ export function useCanvasConnectionController({ else if (nodeType !== CanvasNodeType.Text && nodeType !== CanvasNodeType.Script && nodeType !== CanvasNodeType.Audio) setDialogNodeId(newNode.id); closeConnectionCreateMenu(); setConnecting(null); - }, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]); + }, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, runtimeStatuses, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]); const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config, pending: PendingConnectionCreate, workflowProvider?: "runninghub" | "comfyui") => { const nodeType = type; + if (nodeType === CanvasNodeType.Config) { + if (workflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, workflowProvider)) return `${workflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`; + } if (pending.batchSourceNodeIds?.length) { if (nodeType === CanvasNodeType.Drawing || nodeType === CanvasNodeType.Config) return "批量连接暂不支持此节点类型"; const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type: nodeType, title: "", position: pending.position, width: getNodeSpec(nodeType).width, height: getNodeSpec(nodeType).height }; @@ -354,7 +365,7 @@ export function useCanvasConnectionController({ const connection = normalizeConnection(pending.connection.nodeId, pendingNode.id, pendingNodes, pending.connection.handleType); if (!connection) return "当前节点类型不能这样连接"; return canvasConnectionError(config, pendingNodes, connectionsRef.current, connection); - }, [config, connectionsRef, nodesRef]); + }, [config, connectionsRef, nodesRef, runtimeStatuses]); const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => { const world = screenToCanvas(clientX, clientY); diff --git a/web/src/pages/canvas/use-canvas-node-operations.ts b/web/src/pages/canvas/use-canvas-node-operations.ts index 9c64bdf48..e5dff9846 100644 --- a/web/src/pages/canvas/use-canvas-node-operations.ts +++ b/web/src/pages/canvas/use-canvas-node-operations.ts @@ -14,6 +14,8 @@ import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas import { useUserStore } from "@/stores/use-user-store"; import { useEffectiveConfig } from "@/stores/use-config-store"; import { createDefaultPortraitClearanceState, PORTRAIT_CLEARANCE_NODE_TYPE } from "@/lib/portrait-clearance/contracts"; +import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; +import { usePluginStore } from "@/stores/use-plugin-store"; type CanvasClipboard = { nodes: CanvasNodeData[]; @@ -58,6 +60,7 @@ export function useCanvasNodeOperations({ const { message } = App.useApp(); const effectiveConfig = useEffectiveConfig(); const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey); + const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); const clipboardRef = useRef(null); const preferCopiedNodesRef = useRef(false); const markerWritePendingRef = useRef(false); @@ -131,13 +134,17 @@ export function useCanvasNodeOperations({ return; } const selectedWorkflowProvider = type === CanvasNodeType.Config - ? workflowProvider || "runninghub" + ? workflowProvider || (workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "runninghub" : workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? "comfyui" : undefined) : undefined; + if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) { + message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`); + return; + } const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : type === CanvasNodeType.Config && selectedWorkflowProvider === "comfyui" ? "ComfyUI Bridge" : undefined; const metadata: CanvasNodeMetadata | undefined = type === CanvasNodeType.Drawing ? { drawingEngine: defaultDrawingEngine } : type === CanvasNodeType.Config - ? { generationMode: "image", workflowProvider: selectedWorkflowProvider } + ? { generationMode: "image", workflowProvider: selectedWorkflowProvider || "model" } : type === PORTRAIT_CLEARANCE_NODE_TYPE ? { portraitClearance: createDefaultPortraitClearanceState() } : undefined; @@ -146,7 +153,7 @@ export function useCanvasNodeOperations({ commitNodes([...nodesRef.current, node]); selectNodes(new Set([node.id])); if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing && type !== PORTRAIT_CLEARANCE_NODE_TYPE) setDialogNodeId(node.id); - }, [commitNodes, defaultDrawingEngine, effectiveConfig.comfyBridge.enabled, effectiveConfig.comfyBridge.workflows.length, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]); + }, [commitNodes, defaultDrawingEngine, effectiveConfig.comfyBridge.enabled, effectiveConfig.comfyBridge.workflows.length, effectiveConfig.runningHub.enabled, effectiveConfig.runningHub.workflows.length, getCanvasCenter, message, nodesRef, runtimeStatuses, selectNodes, setDialogNodeId, tldrawLicenseKey]); const createFolder = useCallback((position?: Position, linked?: { id: string; projectId: string; title: string; style: CanvasFolderStyle; theme: CanvasFolderTheme; createdAt: string }) => { const folder = createCanvasNode(CanvasNodeType.Frame, position || getCanvasCenter(), { diff --git a/web/src/pages/plugins/index.tsx b/web/src/pages/plugins/index.tsx index 994e1f12e..64b7106c4 100644 --- a/web/src/pages/plugins/index.tsx +++ b/web/src/pages/plugins/index.tsx @@ -65,6 +65,8 @@ export default function PluginsPage() { 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 runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); const updateConfig = usePluginStore((state) => state.updateConfig); const builtinPlugins = useMemo(() => listRegisteredPlugins(), []); const [backendPlugins, setBackendPlugins] = useState([]); @@ -93,7 +95,8 @@ export default function PluginsPage() { const reloadBackendPlugins = async () => { setBackendPluginsLoading(true); try { - setBackendPlugins(await fetchPlugins()); + const plugins = await fetchPlugins(); + setBackendPlugins(plugins); } catch (error) { message.error(error instanceof Error ? error.message : "读取插件中心失败"); setBackendPlugins([]); @@ -107,7 +110,11 @@ export default function PluginsPage() { }, []); const remotePlugins = useMemo(() => backendPlugins.map(toRegisteredPlugin), [backendPlugins]); - const registeredPlugins = useMemo(() => [...builtinPlugins, ...remotePlugins], [builtinPlugins, remotePlugins]); + 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); @@ -127,7 +134,8 @@ export default function PluginsPage() { const isSystemPlugin = plugin.source !== "uploaded"; if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && isSystemPlugin) return false; const installation = installations.find((item) => item.manifest.id === plugin.manifest.id); - const enabled = backendPluginById.get(plugin.manifest.id)?.status === "enabled" || Boolean(installation?.enabled); + const remote = backendPluginById.get(plugin.manifest.id); + const enabled = remote ? remote.status === "enabled" : 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(); @@ -186,20 +194,21 @@ 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.get(settingsPlugin.manifest.id)?.status === "enabled" || Boolean(settingsInstallation?.enabled) : false; + const settingsEnabled = settingsPlugin ? (backendPluginById.has(settingsPlugin.manifest.id) ? backendPluginById.get(settingsPlugin.manifest.id)?.status === "enabled" : 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 isPluginEnabled = (plugin: RegisteredPlugin, installation = installations.find((item) => item.manifest.id === plugin.manifest.id)) => - backendPluginById.get(plugin.manifest.id)?.status === "enabled" || Boolean(installation?.enabled); + backendPluginById.has(plugin.manifest.id) ? backendPluginById.get(plugin.manifest.id)?.status === "enabled" : 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); } diff --git a/web/src/pages/settings/channel-settings-pane.tsx b/web/src/pages/settings/channel-settings-pane.tsx index 0afcea386..5c64139af 100644 --- a/web/src/pages/settings/channel-settings-pane.tsx +++ b/web/src/pages/settings/channel-settings-pane.tsx @@ -226,30 +226,34 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
-
+ {onOpenRunningHub || onOpenComfyUI ?

个人工作流渠道

RunningHub 和 ComfyUI 使用各自的工作流参数与执行通道,配置入口统一放在个人渠道中。

- } - title="RunningHub" - description="云端工作流和 RunningHub App" - status={runningHubReady ? `${config.runningHub.workflows.length} 个工作流已配置` : config.runningHub.enabled ? "待完成连接和工作流配置" : "未启用"} - ready={runningHubReady} - onOpen={onOpenRunningHub} - /> - } - title="ComfyUI" - description="通过 Bridge 连接本机或远程 ComfyUI" - status={comfyBridgeReady ? `${config.comfyBridge.workflows.length} 个工作流已配置` : config.comfyBridge.enabled ? "待选择 Bridge 和工作流" : "未启用"} - ready={comfyBridgeReady} - onOpen={onOpenComfyUI} - /> + {onOpenRunningHub ? ( + } + title="RunningHub" + description="云端工作流和 RunningHub App" + status={runningHubReady ? `${config.runningHub.workflows.length} 个工作流已配置` : config.runningHub.enabled ? "待完成连接和工作流配置" : "未启用"} + ready={runningHubReady} + onOpen={onOpenRunningHub} + /> + ) : null} + {onOpenComfyUI ? ( + } + title="ComfyUI" + description="通过 Bridge 连接本机或远程 ComfyUI" + status={comfyBridgeReady ? `${config.comfyBridge.workflows.length} 个工作流已配置` : config.comfyBridge.enabled ? "待选择 Bridge 和工作流" : "未启用"} + ready={comfyBridgeReady} + onOpen={onOpenComfyUI} + /> + ) : null}
-
+
: null} {userChannels.length ? (
{userChannels.map((channel) => { diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 13efc7edf..f606ce539 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -1,6 +1,6 @@ import { App, Button, Form, Input, InputNumber, Select } from "antd"; import { ArrowLeft, Boxes, Bug, Cloud, MessageSquareText, MonitorUp, RadioTower, SlidersHorizontal, SquareTerminal, Workflow } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form"; @@ -16,6 +16,8 @@ import { LocalCliSettings } from "./local-cli-settings"; import { PromptPreferencesPane } from "./prompt-preferences-pane"; import DiagnosticsPanel from "./diagnostics-panel"; import { RunningHubSettingsPane } from "./runninghub-settings-pane"; +import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; +import { usePluginStore } from "@/stores/use-plugin-store"; type ConfigSectionKey = "local-cli" | "channels" | "models" | "runninghub" | "comfyui" | "preferences" | "prompts" | "storage" | "diagnostics"; @@ -41,7 +43,13 @@ export default function SettingsPage() { const [searchParams, setSearchParams] = useSearchParams(); const requestedSection = searchParams.get("section"); const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled); - const initialSection = isConfigSection(requestedSection) ? requestedSection : customChannelsEnabled ? "channels" : "models"; + const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses); + const runningHubPluginEnabled = runtimeStatuses[RUNNINGHUB_PLUGIN_ID] === "enabled"; + const comfyUIPluginEnabled = runtimeStatuses[COMFYUI_PLUGIN_ID] === "enabled"; + const requestedSectionEnabled = requestedSection !== "runninghub" && requestedSection !== "comfyui" + || requestedSection === "runninghub" && runningHubPluginEnabled + || requestedSection === "comfyui" && comfyUIPluginEnabled; + const initialSection = isConfigSection(requestedSection) && requestedSectionEnabled ? requestedSection : customChannelsEnabled ? "channels" : "models"; const [activeTab, setActiveTab] = useState(initialSection === "channels" && !customChannelsEnabled ? "models" : initialSection); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); @@ -49,15 +57,19 @@ export default function SettingsPage() { const shouldPromptContinue = searchParams.get("continue") === "1"; const userId = useUserStore((state) => state.user?.id); const userChannels = config.channels.filter((channel) => channel.scope !== "system"); - const visibleConfigSections = customChannelsEnabled ? configSections : configSections.filter((section) => section.key !== "channels"); + const visibleConfigSections = useMemo(() => (customChannelsEnabled ? configSections : configSections.filter((section) => section.key !== "channels")) + .filter((section) => section.key !== "runninghub" || runningHubPluginEnabled) + .filter((section) => section.key !== "comfyui" || comfyUIPluginEnabled), [comfyUIPluginEnabled, customChannelsEnabled, runningHubPluginEnabled]); + + const isVisibleConfigSection = (value: string | null): value is ConfigSectionKey => isConfigSection(value) && visibleConfigSections.some((section) => section.key === value); useEffect(() => { - if (isConfigSection(requestedSection) && (requestedSection !== "channels" || customChannelsEnabled)) { + if (isVisibleConfigSection(requestedSection)) { setActiveTab(requestedSection); return; } - if (!customChannelsEnabled) setActiveTab((current) => (current === "channels" ? "models" : current)); - }, [customChannelsEnabled, requestedSection]); + setActiveTab((current) => visibleConfigSections.some((section) => section.key === current) ? current : customChannelsEnabled ? "channels" : "models"); + }, [customChannelsEnabled, requestedSection, visibleConfigSections]); useEffect(() => { if (!userId) return; @@ -71,6 +83,7 @@ export default function SettingsPage() { }, [message, userId]); const selectSection = (section: ConfigSectionKey) => { + if ((section === "runninghub" && !runningHubPluginEnabled) || (section === "comfyui" && !comfyUIPluginEnabled)) return; setActiveTab(section); const next = new URLSearchParams(searchParams); next.set("section", section); @@ -87,8 +100,8 @@ export default function SettingsPage() { } const hasReadyLocalRuntime = effectiveConfig.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false && Boolean(channel.localModels?.length)); const workflowReady = Boolean( - (config.runningHub.enabled && config.runningHub.workflowId.trim() && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim()) - || (config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim()), + (runningHubPluginEnabled && config.runningHub.enabled && config.runningHub.workflowId.trim() && config.runningHub.baseUrl.trim() && config.runningHub.apiKey.trim()) + || (comfyUIPluginEnabled && config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim()), ); if (!effectiveConfig.channels.some(isChannelReady) && !hasReadyLocalRuntime && !workflowReady) { selectSection(customChannelsEnabled ? "channels" : "models"); @@ -101,7 +114,7 @@ export default function SettingsPage() { const panes: Record = { "local-cli": , - channels: selectSection("models")} onOpenRunningHub={() => selectSection("runninghub")} onOpenComfyUI={() => selectSection("comfyui")} />, + channels: selectSection("models")} onOpenRunningHub={runningHubPluginEnabled ? () => selectSection("runninghub") : undefined} onOpenComfyUI={comfyUIPluginEnabled ? () => selectSection("comfyui") : undefined} />, models: (
diff --git a/web/src/services/api/plugins.ts b/web/src/services/api/plugins.ts index 8ce20b541..d123129ca 100644 --- a/web/src/services/api/plugins.ts +++ b/web/src/services/api/plugins.ts @@ -13,11 +13,18 @@ export type BackendPlugin = { error?: string; }; +export type WorkflowPluginStatus = "enabled" | "disabled" | "invalid" | string; + export async function fetchPlugins() { const result = await request<{ plugins: BackendPlugin[] }>(apiClient.get("/plugins")); return result.plugins; } +export async function fetchWorkflowPluginStatuses() { + const result = await request<{ statuses: Record }>(apiClient.get("/plugins/status")); + return result.statuses; +} + export async function uploadPlugin(file: File) { const body = new FormData(); body.append("file", file); diff --git a/web/src/stores/use-plugin-store.ts b/web/src/stores/use-plugin-store.ts index 44f959041..47fb7f679 100644 --- a/web/src/stores/use-plugin-store.ts +++ b/web/src/stores/use-plugin-store.ts @@ -9,7 +9,9 @@ export const PLUGIN_STORE_KEY = "infinite-canvas:plugin-store"; type PluginStore = { hydrated: boolean; installations: PluginInstallation[]; + runtimeStatuses: Record; ensurePlugin: (manifest: PluginManifest) => void; + setRuntimeStatuses: (statuses: Record) => void; setEnabled: (pluginId: string, enabled: boolean) => void; updateConfig: (pluginId: string, config: Record) => void; setError: (pluginId: string, error?: string) => void; @@ -25,6 +27,7 @@ export const usePluginStore = create()( (set) => ({ hydrated: false, installations: [], + runtimeStatuses: {}, ensurePlugin: (manifest) => set((state) => { const current = state.installations.find((item) => item.manifest.id === manifest.id); @@ -39,6 +42,7 @@ export const usePluginStore = create()( set((state) => ({ installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, enabled, updatedAt: now(), lastError: undefined } : item), })), + setRuntimeStatuses: (runtimeStatuses) => set({ runtimeStatuses }), updateConfig: (pluginId, config) => set((state) => ({ installations: state.installations.map((item) => item.manifest.id === pluginId ? { ...item, config: { ...item.config, ...config }, updatedAt: now() } : item),