fix(*): 插件工作流 - 统一启停状态并闭环校验生成链路
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
95
backend/internal/service/workflow_plugins.go
Normal file
95
backend/internal/service/workflow_plugins.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user