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)