fix(*): 插件工作流 - 统一启停状态并闭环校验生成链路

This commit is contained in:
ddCat
2026-08-26 21:36:10 +08:00
parent 3a4a295523
commit c79e984bc3
23 changed files with 351 additions and 65 deletions

View File

@@ -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"`
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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" {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 注册、心跳、领取、完成、取消和大文件结果回传。

View File

@@ -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 (
<div className="canvas-config-node-panel thin-scrollbar flex h-full w-full cursor-move flex-col gap-3.5 overflow-y-auto px-4 pb-4 pt-8 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="flex min-h-9 items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : "工作流生成"}</div>
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : workflowProvider === "model" ? "生成配置" : "工作流生成"}</div>
{simpleMode ? <span className="rounded-md px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.node.fill, color: theme.node.muted }}>自动配置</span> : <div className="cursor-default" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<Segmented
size="small"
@@ -231,15 +234,23 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
) : (
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-center gap-3" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>工作流来源</span>
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>生成来源</span>
<Segmented
block
size="small"
className="canvas-config-provider min-w-0 flex-1"
value={workflowProvider}
options={[{ label: "RunningHub", value: "runninghub" }, { label: "ComfyUI", value: "comfyui" }]}
options={[
{ label: "模型", value: "model" },
...(workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? [{ label: "RunningHub", value: "runninghub" }] : []),
...(workflowProviderPluginEnabled(runtimeStatuses, "comfyui") ? [{ label: "ComfyUI", value: "comfyui" }] : []),
]}
onChange={(value) => {
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 {

View File

@@ -221,7 +221,7 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<List className="size-4" />} title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Clapperboard className="size-4" />} title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<ImageIcon className="size-4" />} title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="工作流生成" description="在同一节点切换 RunningHub 或 ComfyUI" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<WorkflowIcon className="size-4" />} title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} />
{canCreateDrawing ? <ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Pencil className="size-4" />} title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null}
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Video className="size-4" />} title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption motionEnabled={!reducedMotion} icon={<Music2 className="size-4" />} title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />

View File

@@ -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;

View File

@@ -10,7 +10,7 @@ function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" |
}
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
// 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
// 项目级动作不占用节点网格,创作节点保持统一排列。
{ id: "style", label: "项目画风", icon: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
// 创作节点
nodeCommand(CanvasNodeType.Text, { defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() }),

View File

@@ -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) {

View File

@@ -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<string, string>, provider: WorkflowProvider) {
return statuses[workflowPluginId(provider)] === "enabled";
}
function workflowContributions(providerId: string, label: string) {
return (["image", "video", "audio"] as const).map((capability) => ({
id: `${providerId}-${capability}`,

View File

@@ -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<ConnectionHandle | null>(null);
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
@@ -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);

View File

@@ -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<CanvasClipboard | null>(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(), {

View File

@@ -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<BackendPlugin[]>([]);
@@ -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);
}

View File

@@ -226,30 +226,34 @@ export function ChannelSettingsPane({ onOpenModels, onOpenRunningHub, onOpenComf
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>新增渠道</Button>
</div>
</div>
<section className="settings-section mb-3">
{onOpenRunningHub || onOpenComfyUI ? <section className="settings-section mb-3">
<div className="mb-3">
<h3 className="text-sm font-semibold">个人工作流渠道</h3>
<p className="mt-1 text-xs text-foreground/55">RunningHub 和 ComfyUI 使用各自的工作流参数与执行通道,配置入口统一放在个人渠道中。</p>
</div>
<div className="grid gap-2 lg:grid-cols-2">
<WorkflowChannelEntry
icon={<Workflow className="size-4" />}
title="RunningHub"
description="云端工作流和 RunningHub App"
status={runningHubReady ? `${config.runningHub.workflows.length} 个工作流已配置` : config.runningHub.enabled ? "待完成连接和工作流配置" : "未启用"}
ready={runningHubReady}
onOpen={onOpenRunningHub}
/>
<WorkflowChannelEntry
icon={<MonitorUp className="size-4" />}
title="ComfyUI"
description="通过 Bridge 连接本机或远程 ComfyUI"
status={comfyBridgeReady ? `${config.comfyBridge.workflows.length} 个工作流已配置` : config.comfyBridge.enabled ? "待选择 Bridge 和工作流" : "未启用"}
ready={comfyBridgeReady}
onOpen={onOpenComfyUI}
/>
{onOpenRunningHub ? (
<WorkflowChannelEntry
icon={<Workflow className="size-4" />}
title="RunningHub"
description="云端工作流和 RunningHub App"
status={runningHubReady ? `${config.runningHub.workflows.length} 个工作流已配置` : config.runningHub.enabled ? "待完成连接和工作流配置" : "未启用"}
ready={runningHubReady}
onOpen={onOpenRunningHub}
/>
) : null}
{onOpenComfyUI ? (
<WorkflowChannelEntry
icon={<MonitorUp className="size-4" />}
title="ComfyUI"
description="通过 Bridge 连接本机或远程 ComfyUI"
status={comfyBridgeReady ? `${config.comfyBridge.workflows.length} 个工作流已配置` : config.comfyBridge.enabled ? "待选择 Bridge 和工作流" : "未启用"}
ready={comfyBridgeReady}
onOpen={onOpenComfyUI}
/>
) : null}
</div>
</section>
</section> : null}
{userChannels.length ? (
<div className="settings-channel-list space-y-2">
{userChannels.map((channel) => {

View File

@@ -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<ConfigSectionKey>(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<ConfigSectionKey, ReactNode> = {
"local-cli": <SettingsPane><LocalCliSettings /></SettingsPane>,
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} onOpenRunningHub={() => selectSection("runninghub")} onOpenComfyUI={() => selectSection("comfyui")} /></SettingsPane>,
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} onOpenRunningHub={runningHubPluginEnabled ? () => selectSection("runninghub") : undefined} onOpenComfyUI={comfyUIPluginEnabled ? () => selectSection("comfyui") : undefined} /></SettingsPane>,
models: (
<SettingsPane>
<div className="settings-pane-header">

View File

@@ -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<string, WorkflowPluginStatus> }>(apiClient.get("/plugins/status"));
return result.statuses;
}
export async function uploadPlugin(file: File) {
const body = new FormData();
body.append("file", file);

View File

@@ -9,7 +9,9 @@ export const PLUGIN_STORE_KEY = "infinite-canvas:plugin-store";
type PluginStore = {
hydrated: boolean;
installations: PluginInstallation[];
runtimeStatuses: Record<string, string>;
ensurePlugin: (manifest: PluginManifest) => void;
setRuntimeStatuses: (statuses: Record<string, string>) => void;
setEnabled: (pluginId: string, enabled: boolean) => void;
updateConfig: (pluginId: string, config: Record<string, string | number | boolean>) => void;
setError: (pluginId: string, error?: string) => void;
@@ -25,6 +27,7 @@ export const usePluginStore = create<PluginStore>()(
(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<PluginStore>()(
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),