From 86fc3fda2f511c1aecdb2e48a044be9aedec1406 Mon Sep 17 00:00:00 2001 From: yepwait <1239738103@qq.com> Date: Wed, 26 Aug 2026 15:02:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=96=B9=20-=20=E9=9B=86=E6=88=90=20RunningH?= =?UTF-8?q?ub=20=E4=B8=8E=20ComfyUI=20Bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Dockerfile | 18 +- backend/cmd/server/main.go | 4 +- backend/internal/database/schema.go | 2 + backend/internal/handler/comfy_bridge.go | 187 ++ backend/internal/handler/runninghub.go | 39 + backend/internal/model/models.go | 6 + backend/internal/model/models_bridge.go | 36 + backend/internal/repository/comfy_bridge.go | 198 ++ backend/internal/repository/repository.go | 2 +- backend/internal/service/comfy_bridge.go | 583 +++++ .../service/comfy_bridge_persistence_test.go | 100 + backend/internal/service/model_capability.go | 9 +- backend/internal/service/provider.go | 28 + .../service/provider_task_cancellation.go | 16 + .../internal/service/runninghub_management.go | 561 +++++ backend/internal/service/settings.go | 13 +- .../service/task_cancellation_test.go | 26 +- backend/internal/service/task_creation.go | 65 +- .../internal/service/task_creation_test.go | 28 + backend/internal/service/task_execution.go | 25 +- backend/internal/service/task_lifecycle.go | 74 +- backend/internal/service/task_terminal.go | 15 + backend/internal/service/task_worker.go | 5 + backend/internal/service/workflow_provider.go | 2172 +++++++++++++++++ canvas-agent/README.md | 35 + canvas-agent/native/comfy-bridge/go.mod | 3 + canvas-agent/native/comfy-bridge/main.go | 668 +++++ canvas-agent/native/comfy-bridge/media.go | 381 +++ canvas-agent/native/comfy-bridge/workflow.go | 1055 ++++++++ canvas-agent/scripts/build-comfy-bridge.ts | 71 + .../content/docs/backend/backend-database.mdx | 19 + docs/content/docs/progress/pending-test.mdx | 9 + nginx.conf | 26 + web/package.json | 3 +- .../canvas/canvas-active-task-panel.tsx | 22 +- .../canvas/canvas-config-composer.tsx | 16 +- .../canvas/canvas-config-node-panel.tsx | 447 +++- .../components/canvas/canvas-context-menu.tsx | 1 + .../components/canvas/canvas-create-menu.tsx | 19 +- .../canvas/canvas-node-generation.ts | 60 +- web/src/components/canvas/canvas-toolbar.tsx | 3 + .../canvas/canvas-video-prompt-tools.tsx | 28 +- .../canvas/canvas-workspace-overlays.tsx | 7 +- web/src/components/canvas/infinite-canvas.tsx | 5 +- .../workflow-field-mapping-editor.tsx | 299 +++ web/src/components/workflow-graph-editor.tsx | 408 ++++ .../components/workflow-test-workbench.tsx | 569 +++++ web/src/constant/canvas.ts | 3 +- .../lib/canvas/canvas-generation-task-sync.ts | 16 +- .../lib/canvas/canvas-project-generation.ts | 169 +- web/src/lib/canvas/canvas-workflow.ts | 19 + .../definitions/add-node-menu-tools.tsx | 4 +- .../canvas/tool-registry/tool-definition.ts | 4 +- web/src/lib/generation-workflow-execution.ts | 100 + web/src/lib/model-capabilities.ts | 503 ++++ web/src/lib/plugins/builtin/index.ts | 1 + web/src/lib/plugins/builtin/workflows.ts | 54 + .../canvas-media-generation-executors.ts | 2 +- .../canvas/canvas-project-status-dialogs.tsx | 11 +- web/src/pages/canvas/project.tsx | 46 +- .../pages/canvas/use-canvas-active-tasks.ts | 10 +- .../use-canvas-connection-controller.ts | 82 +- .../canvas/use-canvas-generation-executor.ts | 13 +- .../canvas/use-canvas-generation-retry.ts | 19 +- web/src/pages/canvas/use-canvas-generation.ts | 37 +- .../pages/canvas/use-canvas-node-editor.ts | 7 +- .../canvas/use-canvas-node-operations.ts | 20 +- web/src/pages/canvas/use-canvas-storyboard.ts | 2 +- web/src/pages/plugins/index.tsx | 15 + .../pages/settings/channel-settings-pane.tsx | 64 +- .../settings/comfyui-bridge-settings-pane.tsx | 501 ++++ web/src/pages/settings/index.tsx | 20 +- .../settings/runninghub-settings-pane.tsx | 354 +++ web/src/pages/tasks/task-shared.tsx | 2 + web/src/services/api/comfy-bridge.ts | 31 + web/src/services/api/generation-task.ts | 76 +- web/src/services/api/runninghub.ts | 27 + web/src/services/api/task-center.ts | 11 + web/src/stores/use-config-store.ts | 390 ++- web/src/styles/globals.css | 665 +++++ web/src/types/canvas.ts | 6 + 82 files changed, 11405 insertions(+), 248 deletions(-) create mode 100644 backend/internal/handler/comfy_bridge.go create mode 100644 backend/internal/handler/runninghub.go create mode 100644 backend/internal/model/models_bridge.go create mode 100644 backend/internal/repository/comfy_bridge.go create mode 100644 backend/internal/service/comfy_bridge.go create mode 100644 backend/internal/service/comfy_bridge_persistence_test.go create mode 100644 backend/internal/service/runninghub_management.go create mode 100644 backend/internal/service/task_creation_test.go create mode 100644 backend/internal/service/workflow_provider.go create mode 100644 canvas-agent/native/comfy-bridge/go.mod create mode 100644 canvas-agent/native/comfy-bridge/main.go create mode 100644 canvas-agent/native/comfy-bridge/media.go create mode 100644 canvas-agent/native/comfy-bridge/workflow.go create mode 100644 canvas-agent/scripts/build-comfy-bridge.ts create mode 100644 docs/content/docs/backend/backend-database.mdx create mode 100644 web/src/components/workflow-field-mapping-editor.tsx create mode 100644 web/src/components/workflow-graph-editor.tsx create mode 100644 web/src/components/workflow-test-workbench.tsx create mode 100644 web/src/lib/canvas/canvas-workflow.ts create mode 100644 web/src/lib/generation-workflow-execution.ts create mode 100644 web/src/lib/plugins/builtin/workflows.ts create mode 100644 web/src/pages/settings/comfyui-bridge-settings-pane.tsx create mode 100644 web/src/pages/settings/runninghub-settings-pane.tsx create mode 100644 web/src/services/api/comfy-bridge.ts create mode 100644 web/src/services/api/runninghub.ts diff --git a/.gitignore b/.gitignore index 2dccf1f1b..bc3439a66 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ coverage/ dist/ *.log *.tsbuildinfo +/web/public/OpenAICanvas-ComfyBridge.exe +/web/public/OpenAICanvas-ComfyBridge-linux-amd64 +/web/public/OpenAICanvas-ComfyBridge-linux-arm64 *.db *.db-* *.sqlite diff --git a/Dockerfile b/Dockerfile index 926f587eb..013eaed5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,14 @@ # syntax=docker/dockerfile:1.7 +# Bridge 使用 Go 标准库交叉编译为原生 Windows/Linux 程序,避免把 Bun/Node 运行时打进下载文件。 +FROM golang:1.24-alpine AS comfy-bridge-build + +WORKDIR /src +COPY canvas-agent/native/comfy-bridge ./ +RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/OpenAICanvas-ComfyBridge.exe . \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/OpenAICanvas-ComfyBridge-linux-amd64 . \ + && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o /out/OpenAICanvas-ComfyBridge-linux-arm64 . + # 构建 Vite 前端产物。 FROM oven/bun:1.3.13 AS web-build @@ -10,8 +19,15 @@ COPY web/package.json web/bun.lock ./ RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --cache-dir=/root/.bun/install/cache COPY VERSION /app/VERSION COPY CHANGELOG.md /app/CHANGELOG.md +COPY canvas-agent /app/canvas-agent COPY web ./ -RUN bun run build +COPY --from=comfy-bridge-build /out/OpenAICanvas-ComfyBridge.exe /app/web/public/OpenAICanvas-ComfyBridge.exe +COPY --from=comfy-bridge-build /out/OpenAICanvas-ComfyBridge-linux-amd64 /app/web/public/OpenAICanvas-ComfyBridge-linux-amd64 +COPY --from=comfy-bridge-build /out/OpenAICanvas-ComfyBridge-linux-arm64 /app/web/public/OpenAICanvas-ComfyBridge-linux-arm64 +# Bun 1.3 与 TypeScript 7 的 tsc shim 在生产容器中解析路径异常;Bridge +# 已在上一步完成校验,生产镜像直接执行 Vite 打包,类型检查留给开发 CI。 +RUN CANVAS_PREBUILT_BRIDGE=1 bun run build:bridge \ + && bun --bun ./node_modules/vite/bin/vite.js build # 运行镜像:nginx 托管静态前端,并在 Compose 中把 /api 转发到后端服务。 FROM nginx:1.27-alpine diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a6b18bc15..ed5469ca3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -95,6 +95,8 @@ func main() { handler.RegisterSystemProxyRoutes(api, svc) handler.RegisterCustomRelayRoutes(api, svc) handler.RegisterTaskRoutes(api, svc) + handler.RegisterComfyBridgeRoutes(api, svc) + handler.RegisterRunningHubRoutes(api, svc) handler.RegisterSessionRoutes(api, svc) handler.RegisterSkillRoutes(api, svc) handler.RegisterUserDataRoutes(api, svc) @@ -157,7 +159,7 @@ func cors() (gin.HandlerFunc, error) { c.Header("Access-Control-Allow-Credentials", "true") c.Header("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers") } - c.Header("Access-Control-Allow-Headers", corsAllowedHeaders) + c.Header("Access-Control-Allow-Headers", corsAllowedHeaders+", X-Canvas-Comfy-Bridge-Token, X-Canvas-Bridge-Token") c.Header("Access-Control-Expose-Headers", "X-Request-ID, X-Canvas-Trace-ID, X-Diagnostic-Bundle-ID, X-Diagnostic-Schema-Version") c.Header("Access-Control-Allow-Methods", corsAllowedMethods) c.Header("Access-Control-Max-Age", "86400") diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 3f0d3d579..ab1fdea70 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -77,6 +77,8 @@ func Models() []any { &model.TaskLog{}, &model.SessionFile{}, &model.Result{}, + &model.ComfyBridge{}, + &model.ComfyBridgeRequest{}, } } diff --git a/backend/internal/handler/comfy_bridge.go b/backend/internal/handler/comfy_bridge.go new file mode 100644 index 000000000..07fcfa670 --- /dev/null +++ b/backend/internal/handler/comfy_bridge.go @@ -0,0 +1,187 @@ +package handler + +import ( + "context" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +// Bridge 结果通常包含 base64 媒体;保持和 service 层相同的硬上限,避免请求体无限增长。 +const maxComfyBridgeRequestBytes int64 = 64 << 20 + +// Bridge 心跳会携带本机工作流字段和拓扑,允许与 service 层一致的 4MB 上限。 +const maxComfyBridgeCapabilitiesBytes int64 = 4 << 20 + +type comfyBridgeResultRequest struct { + RequestID string `json:"requestId"` + ID string `json:"id,omitempty"` + Status string `json:"status"` + Result map[string]any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterComfyBridgeRoutes 同时注册用户管理接口和 Bridge 专用轮询接口。 +// 两套接口使用不同认证方式:用户接口依赖登录 Cookie,Bridge 接口只接受专用 Header Token。 +func RegisterComfyBridgeRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/comfy-bridges", func(c *gin.Context) { + user, err := currentUser(c, svc) + if 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 { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.CreateComfyBridge(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/comfy-bridges", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + bridges, err := svc.ComfyBridges(user.ID) + if err != nil { + fail(c, http.StatusInternalServerError, err) + return + } + ok(c, bridges) + }) + revoke := func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.RevokeComfyBridge(user.ID, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"revoked": true}) + } + r.DELETE("/comfy-bridges/:id", revoke) + r.POST("/comfy-bridges/:id/revoke", revoke) + + r.GET("/comfy-bridge/poll", func(c *gin.Context) { + bridge, err := authenticateComfyBridgeRequest(c, svc) + if err != nil { + failService(c, err) + return + } + wait, err := comfyBridgeWait(c.Query("wait")) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + request, err := svc.PollComfyBridgeRequest(c.Request.Context(), bridge, wait) + if err != nil { + if errors.Is(err, context.Canceled) { + return + } + failService(c, err) + return + } + ok(c, gin.H{"request": request}) + }) + r.POST("/comfy-bridge/result", func(c *gin.Context) { + bridge, err := authenticateComfyBridgeRequest(c, svc) + if err != nil { + failService(c, err) + return + } + if c.Request.ContentLength > maxComfyBridgeRequestBytes { + fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB")) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxComfyBridgeRequestBytes) + var req comfyBridgeResultRequest + if err := c.ShouldBindJSON(&req); err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + fail(c, http.StatusRequestEntityTooLarge, errors.New("Bridge 结果请求体超过 64MB")) + return + } + fail(c, http.StatusBadRequest, err) + return + } + requestID := strings.TrimSpace(req.RequestID) + if requestID == "" { + requestID = strings.TrimSpace(req.ID) + } + err = svc.CompleteComfyBridgeRequest(bridge.ID, service.ComfyBridgeCompletion{ + RequestID: requestID, + Status: req.Status, + Result: req.Result, + Error: req.Error, + }) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"accepted": true}) + }) + r.POST("/comfy-bridge/heartbeat", func(c *gin.Context) { + bridge, err := authenticateComfyBridgeRequest(c, svc) + if err != nil { + failService(c, err) + return + } + var req struct { + Capabilities map[string]any `json:"capabilities,omitempty"` + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxComfyBridgeCapabilitiesBytes) + if c.Request.ContentLength != 0 { + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + fail(c, http.StatusBadRequest, err) + return + } + } + if err := svc.TouchComfyBridgeHeartbeat(bridge.ID, req.Capabilities); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) +} + +func authenticateComfyBridgeRequest(c *gin.Context, svc *service.Service) (*model.ComfyBridge, error) { + token := strings.TrimSpace(c.GetHeader("X-Canvas-Comfy-Bridge-Token")) + if token == "" { + token = strings.TrimSpace(c.GetHeader("X-Canvas-Bridge-Token")) + } + if token == "" { + authorization := strings.TrimSpace(c.GetHeader("Authorization")) + if len(authorization) >= 7 && strings.EqualFold(authorization[:7], "Bearer ") { + token = strings.TrimSpace(authorization[7:]) + } + } + return svc.AuthenticateComfyBridge(token) +} + +func comfyBridgeWait(raw string) (time.Duration, error) { + if strings.TrimSpace(raw) == "" { + return 20 * time.Second, nil + } + seconds, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || seconds < 0 || seconds > 60 { + return 0, errors.New("wait 必须是 0 到 60 之间的整数秒") + } + return time.Duration(seconds) * time.Second, nil +} diff --git a/backend/internal/handler/runninghub.go b/backend/internal/handler/runninghub.go new file mode 100644 index 000000000..ac63008e3 --- /dev/null +++ b/backend/internal/handler/runninghub.go @@ -0,0 +1,39 @@ +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +// RegisterRunningHubRoutes 是独立工作流 Provider 的管理代理,不复用 ModelChannel。 +func RegisterRunningHubRoutes(r *gin.RouterGroup, svc *service.Service) { + fetch := func(c *gin.Context, app bool) { + if _, err := currentUser(c, svc); 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 { + fail(c, http.StatusBadRequest, err) + return + } + var result map[string]any + var err error + if app { + result, err = svc.FetchRunningHubAppInfo(c.Request.Context(), req) + } else { + result, err = svc.FetchRunningHubWorkflowInfo(c.Request.Context(), req) + } + if err != nil { + failService(c, err) + return + } + ok(c, result) + } + r.POST("/runninghub/workflow-info", func(c *gin.Context) { fetch(c, false) }) + r.POST("/runninghub/app-info", func(c *gin.Context) { fetch(c, true) }) +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 86f30968d..f6f2def51 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -83,6 +83,12 @@ const ( ChannelInterfaceNovitaVideo ChannelInterfaceType = "novita-video" ChannelInterfaceMiniMaxVideo ChannelInterfaceType = "minimax-video" ChannelInterfaceAgnesVideo ChannelInterfaceType = "agnes-video" + ChannelInterfaceRunningHubImage ChannelInterfaceType = "runninghub-workflow-image" + ChannelInterfaceRunningHubVideo ChannelInterfaceType = "runninghub-workflow-video" + ChannelInterfaceRunningHubAudio ChannelInterfaceType = "runninghub-workflow-audio" + ChannelInterfaceComfyBridgeImage ChannelInterfaceType = "comfyui-bridge-image" + ChannelInterfaceComfyBridgeVideo ChannelInterfaceType = "comfyui-bridge-video" + ChannelInterfaceComfyBridgeAudio ChannelInterfaceType = "comfyui-bridge-audio" ApiCallStatusSucceeded ApiCallStatus = "succeeded" ApiCallStatusFailed ApiCallStatus = "failed" diff --git a/backend/internal/model/models_bridge.go b/backend/internal/model/models_bridge.go new file mode 100644 index 000000000..a232e6d65 --- /dev/null +++ b/backend/internal/model/models_bridge.go @@ -0,0 +1,36 @@ +package model + +import "time" + +// ComfyBridge 是用户电脑上本地 Bridge 的持久化身份。Token 只在创建时返回明文,数据库只保存摘要。 +type ComfyBridge struct { + ID string `json:"id" gorm:"primaryKey;size:64"` + UserID string `json:"userId" gorm:"index;size:36"` + Name string `json:"name" gorm:"size:80"` + TokenHash string `json:"-" gorm:"uniqueIndex;size:128"` + Enabled bool `json:"enabled" gorm:"index"` + LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` + LastTaskAt *time.Time `json:"lastTaskAt,omitempty"` + CapabilitiesJSON string `json:"-" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ComfyBridgeRequest 是云端任务与本地 Bridge 之间的持久化交接记录。 +// Payload 和 Result 只保存工作流执行协议,不包含 Bridge Token。 +type ComfyBridgeRequest struct { + ID string `json:"id" gorm:"primaryKey;size:64"` + TaskID string `json:"taskId" gorm:"index;size:64"` + UserID string `json:"userId" gorm:"index;size:36"` + BridgeID string `json:"bridgeId" gorm:"index:idx_comfy_bridge_request_queue,priority:1;size:64"` + Kind string `json:"kind" gorm:"size:32"` + Status string `json:"status" gorm:"index:idx_comfy_bridge_request_queue,priority:2;size:24"` + PayloadJSON string `json:"-" gorm:"type:text"` + ResultJSON string `json:"-" gorm:"type:text"` + Error string `json:"error,omitempty" gorm:"type:text"` + ClaimedAt *time.Time `json:"claimedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/repository/comfy_bridge.go b/backend/internal/repository/comfy_bridge.go new file mode 100644 index 000000000..353fe9571 --- /dev/null +++ b/backend/internal/repository/comfy_bridge.go @@ -0,0 +1,198 @@ +package repository + +import ( + "errors" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrComfyBridgeRequestOwnership = errors.New("comfy bridge request belongs to another bridge") +var ErrComfyBridgeRequestState = errors.New("comfy bridge request state conflict") + +// ComfyBridgeForUser 按用户作用域读取 Bridge,避免管理接口意外越权访问其他用户的设备。 +func (r *Repository) ComfyBridgeForUser(userID string, id string) (*model.ComfyBridge, error) { + var bridge model.ComfyBridge + if err := r.db.First(&bridge, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &bridge, nil +} +func (r *Repository) ComfyBridgesForUser(userID string) ([]model.ComfyBridge, error) { + var bridges []model.ComfyBridge + // 已撤销的 Bridge 仍保留数据库记录用于审计,但不能再出现在可选设备列表中。 + err := r.db.Where("user_id = ? AND enabled = ?", userID, true).Order("created_at desc").Find(&bridges).Error + return bridges, err +} + +// ComfyBridgeByTokenHash 只返回启用的 Bridge。Token 明文不会进入数据库查询或日志。 +func (r *Repository) ComfyBridgeByTokenHash(tokenHash string) (*model.ComfyBridge, error) { + var bridge model.ComfyBridge + if err := r.db.First(&bridge, "token_hash = ? AND enabled = ?", tokenHash, true).Error; err != nil { + return nil, err + } + return &bridge, nil +} + +func (r *Repository) DisableComfyBridge(userID string, id string, now time.Time) error { + result := r.db.Model(&model.ComfyBridge{}). + Where("id = ? AND user_id = ? AND enabled = ?", id, userID, true). + Updates(map[string]any{"enabled": false, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) TouchComfyBridge(id string, now time.Time) error { + result := r.db.Model(&model.ComfyBridge{}).Where("id = ? AND enabled = ?", id, true). + Updates(map[string]any{"last_seen_at": now, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) UpdateComfyBridgeHeartbeat(id string, capabilitiesJSON string, now time.Time) error { + updates := map[string]any{"last_seen_at": now, "updated_at": now} + if capabilitiesJSON != "" { + updates["capabilities_json"] = capabilitiesJSON + } + result := r.db.Model(&model.ComfyBridge{}).Where("id = ? AND enabled = ?", id, true).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) MarkComfyBridgeTask(id string, now time.Time) error { + result := r.db.Model(&model.ComfyBridge{}).Where("id = ? AND enabled = ?", id, true). + Updates(map[string]any{"last_task_at": now, "last_seen_at": now, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) CreateOrGetComfyBridgeRequest(request *model.ComfyBridgeRequest) (*model.ComfyBridgeRequest, bool, error) { + result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(request) + if result.Error != nil { + return nil, false, result.Error + } + if result.RowsAffected == 1 { + return request, true, nil + } + var existing model.ComfyBridgeRequest + if err := r.db.First(&existing, "id = ?", request.ID).Error; err != nil { + return nil, false, err + } + return &existing, false, nil +} + +func (r *Repository) ComfyBridgeRequest(id string) (*model.ComfyBridgeRequest, error) { + var request model.ComfyBridgeRequest + if err := r.db.First(&request, "id = ?", id).Error; err != nil { + return nil, err + } + return &request, nil +} + +func (r *Repository) PendingComfyBridgeRequestCount(bridgeID string, now time.Time) (int64, error) { + var count int64 + err := r.db.Model(&model.ComfyBridgeRequest{}). + Where("bridge_id = ? AND status IN ? AND expires_at > ?", bridgeID, []string{"queued", "claimed"}, now). + Count(&count).Error + return count, err +} + +// ClaimNextComfyBridgeRequest 以数据库状态机为队列真相,后端重启后仍可继续投递未领取请求。 +func (r *Repository) ClaimNextComfyBridgeRequest(bridgeID string, now time.Time) (*model.ComfyBridgeRequest, error) { + var request model.ComfyBridgeRequest + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.ComfyBridgeRequest{}). + Where("status IN ? AND expires_at <= ?", []string{"queued", "claimed"}, now). + Updates(map[string]any{"status": "failed", "error": "本地 ComfyUI Bridge 请求已过期", "completed_at": now, "updated_at": now}).Error; err != nil { + return err + } + query := tx.Where("bridge_id = ? AND status = ? AND expires_at > ?", bridgeID, "queued", now). + Order("created_at asc").Limit(1) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}) + } + result := query.Find(&request) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + request = model.ComfyBridgeRequest{} + return nil + } + updated := tx.Model(&model.ComfyBridgeRequest{}). + Where("id = ? AND status = ?", request.ID, "queued"). + Updates(map[string]any{"status": "claimed", "claimed_at": now, "updated_at": now}) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + request = model.ComfyBridgeRequest{} + return nil + } + return tx.First(&request, "id = ?", request.ID).Error + }) + if err != nil || request.ID == "" { + return nil, err + } + return &request, nil +} + +func (r *Repository) CompleteComfyBridgeRequest(bridgeID string, id string, status string, resultJSON string, errorText string, now time.Time) (*model.ComfyBridgeRequest, error) { + var request model.ComfyBridgeRequest + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.First(&request, "id = ?", id).Error; err != nil { + return err + } + if request.BridgeID != bridgeID { + return ErrComfyBridgeRequestOwnership + } + if request.Status == "succeeded" || request.Status == "failed" || request.Status == "cancelled" { + return nil + } + if request.Status != "claimed" { + return ErrComfyBridgeRequestState + } + if err := tx.Model(&model.ComfyBridgeRequest{}).Where("id = ? AND status = ?", id, "claimed").Updates(map[string]any{ + "status": status, "result_json": resultJSON, "error": errorText, "completed_at": now, "updated_at": now, + }).Error; err != nil { + return err + } + return tx.First(&request, "id = ?", id).Error + }) + return &request, err +} + +func (r *Repository) CancelComfyBridgeRequest(id string, now time.Time) error { + return r.db.Model(&model.ComfyBridgeRequest{}). + Where("id = ? AND status IN ?", id, []string{"queued", "claimed"}). + Updates(map[string]any{"status": "cancelled", "error": "任务已取消", "completed_at": now, "updated_at": now}).Error +} + +func (r *Repository) FailComfyBridgeRequests(bridgeID string, errorText string, now time.Time) error { + return r.db.Model(&model.ComfyBridgeRequest{}). + Where("bridge_id = ? AND status IN ?", bridgeID, []string{"queued", "claimed"}). + Updates(map[string]any{"status": "failed", "error": errorText, "completed_at": now, "updated_at": now}).Error +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 8c82a29a3..18feefc6b 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -503,7 +503,7 @@ func (r *Repository) CancelTaskIfStatus(userID string, id string, expected model result := r.db.Model(&model.Task{}). Where("id = ? AND user_id = ? AND status = ?", id, userID, expected). Updates(map[string]any{ - "status": model.TaskStatusCancelled, "stage": "任务已取消", "completed_at": &now, + "status": model.TaskStatusCancelled, "stage": "任务已取消", "error": "任务已取消", "completed_at": &now, "lease_owner": "", "lease_expires_at": nil, "updated_at": now, }) return result.RowsAffected == 1, result.Error diff --git a/backend/internal/service/comfy_bridge.go b/backend/internal/service/comfy_bridge.go new file mode 100644 index 000000000..45c3364d5 --- /dev/null +++ b/backend/internal/service/comfy_bridge.go @@ -0,0 +1,583 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +const ( + // 心跳会携带本机工作流字段和拓扑;允许大型 ComfyUI 工作流,但保留硬上限。 + comfyBridgeCapabilitiesLimit = 4 << 20 + // 请求主要是工作流和参考素材描述,保持较小上限控制排队内存;结果可能含 base64 视频,单独放宽但仍有硬上限。 + comfyBridgeRequestPayloadLimit = 16 << 20 + comfyBridgeResultPayloadLimit = 64 << 20 + comfyBridgeMaxPending = 32 + comfyBridgeRequestTTL = 2 * time.Hour + comfyBridgePollMaxWait = 60 * time.Second +) + +const ( + // ComfyBridgeRequestKindGenerate 是现有生成任务的请求类型。空 Kind 会在入队时归一化为该值, + // 这样可以继续兼容已有调用方。 + ComfyBridgeRequestKindGenerate = "generate" + // ComfyBridgeRequestKindWorkflowList 和 ComfyBridgeRequestKindWorkflowGet 只用于管理页按需发现。 + ComfyBridgeRequestKindWorkflowList = "workflow.list" + ComfyBridgeRequestKindWorkflowGet = "workflow.get" +) + +// CreateComfyBridgeRequest 是用户注册本地 Bridge 时提交的公开信息。 +// Bridge Token 由服务端生成,不接受客户端自带 Token,避免复用或弱 Token。 +type CreateComfyBridgeRequest struct { + Name string `json:"name"` + Capabilities map[string]any `json:"capabilities,omitempty"` +} +type ComfyBridgeSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Online bool `json:"online"` + LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` + LastTaskAt *time.Time `json:"lastTaskAt,omitempty"` + Capabilities map[string]any `json:"capabilities,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ComfyBridgeRegistration struct { + Bridge ComfyBridgeSummary `json:"bridge"` + // Token 只在注册响应中出现一次,后续接口不会重新返回明文。 + Token string `json:"token"` +} + +// ComfyBridgeRequest 是云端后端交给本地 Bridge 的一次请求。 +// Payload 的协议由 Bridge 按 Kind 解释;生成请求和管理页发现请求共用同一条长轮询通道。 +type ComfyBridgeRequest struct { + ID string `json:"id"` + Kind string `json:"kind"` + TaskID string `json:"taskId,omitempty"` + BridgeID string `json:"bridgeId"` + Payload map[string]any `json:"payload"` + CreatedAt time.Time `json:"createdAt"` +} + +type ComfyBridgeCompletion struct { + RequestID string `json:"requestId,omitempty"` + Status string `json:"status"` + Result map[string]any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type comfyBridgeEnvelope struct { + request ComfyBridgeRequest + done chan ComfyBridgeCompletion +} + +// comfyBridgeQueue 只保存长轮询唤醒和当前进程等待者;请求状态与结果以数据库为准。 +type comfyBridgeQueue struct { + mu sync.Mutex + pending map[string]*comfyBridgeEnvelope + notify map[string]chan struct{} +} + +var comfyBridgeRuntimes sync.Map // map[*Service]*comfyBridgeQueue + +func (s *Service) comfyBridgeRuntime() *comfyBridgeQueue { + if value, ok := comfyBridgeRuntimes.Load(s); ok { + return value.(*comfyBridgeQueue) + } + runtime := &comfyBridgeQueue{ + pending: make(map[string]*comfyBridgeEnvelope), + notify: make(map[string]chan struct{}), + } + actual, _ := comfyBridgeRuntimes.LoadOrStore(s, runtime) + return actual.(*comfyBridgeQueue) +} + +func (s *Service) CreateComfyBridge(userID string, req CreateComfyBridgeRequest) (*ComfyBridgeRegistration, error) { + if strings.TrimSpace(userID) == "" { + return nil, Unauthorized("请先登录") + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = "本地 ComfyUI Bridge" + } + if len([]rune(name)) > 80 { + return nil, BadAuthRequest("Bridge 名称不能超过 80 个字符") + } + capabilitiesJSON := "" + if req.Capabilities != nil { + encoded, err := json.Marshal(req.Capabilities) + if err != nil { + return nil, BadAuthRequest("Bridge 能力信息格式无效") + } + if len(encoded) > comfyBridgeCapabilitiesLimit { + return nil, BadAuthRequest("Bridge 能力信息不能超过 4MB") + } + capabilitiesJSON = string(encoded) + } + token := randomToken() + now := time.Now() + bridge := &model.ComfyBridge{ + ID: newID(), + UserID: userID, + Name: name, + TokenHash: hashToken(token), + Enabled: true, + CapabilitiesJSON: capabilitiesJSON, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.Create(bridge); err != nil { + return nil, err + } + return &ComfyBridgeRegistration{Bridge: comfyBridgeSummary(*bridge), Token: token}, nil +} + +func (s *Service) ComfyBridges(userID string) ([]ComfyBridgeSummary, error) { + bridges, err := s.repo.ComfyBridgesForUser(userID) + if err != nil { + return nil, err + } + items := make([]ComfyBridgeSummary, 0, len(bridges)) + for _, bridge := range bridges { + items = append(items, comfyBridgeSummary(bridge)) + } + return items, nil +} + +func (s *Service) RevokeComfyBridge(userID string, bridgeID string) error { + bridgeID = strings.TrimSpace(bridgeID) + if bridgeID == "" { + return BadAuthRequest("Bridge ID 不能为空") + } + if err := s.repo.DisableComfyBridge(userID, bridgeID, time.Now()); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("Bridge 不存在或已撤销") + } + return err + } + _ = s.repo.FailComfyBridgeRequests(bridgeID, "Bridge 已撤销", time.Now()) + s.comfyBridgeRuntime().failBridge(bridgeID, errors.New("Bridge 已撤销")) + return nil +} + +// AuthenticateComfyBridge 校验 Bridge 专用 Token,并刷新心跳。 +func (s *Service) AuthenticateComfyBridge(token string) (*model.ComfyBridge, error) { + token = strings.TrimSpace(token) + if len(token) < 32 || len(token) > 256 { + return nil, Unauthorized("Bridge 令牌无效或已撤销") + } + bridge, err := s.repo.ComfyBridgeByTokenHash(hashToken(token)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, Unauthorized("Bridge 令牌无效或已撤销") + } + if err != nil { + return nil, err + } + if err := s.repo.TouchComfyBridge(bridge.ID, time.Now()); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, Unauthorized("Bridge 令牌无效或已撤销") + } + return nil, err + } + return bridge, nil +} + +func (s *Service) TouchComfyBridgeHeartbeat(bridgeID string, capabilities map[string]any) error { + capabilitiesJSON := "" + if capabilities != nil { + encoded, err := json.Marshal(capabilities) + if err != nil || len(encoded) > comfyBridgeCapabilitiesLimit { + return BadAuthRequest("Bridge 能力信息格式无效或超过 4MB") + } + capabilitiesJSON = string(encoded) + } + if err := s.repo.UpdateComfyBridgeHeartbeat(strings.TrimSpace(bridgeID), capabilitiesJSON, time.Now()); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return Unauthorized("Bridge 令牌无效或已撤销") + } + return err + } + return nil +} + +// EnqueueComfyBridgeRequest 将工作流请求持久化,并返回请求 ID。 +func (s *Service) EnqueueComfyBridgeRequest(ctx context.Context, userID string, bridgeID string, taskID string, payload map[string]any) (*ComfyBridgeRequest, error) { + return s.enqueueComfyBridgeRequest(ctx, userID, bridgeID, taskID, "", payload) +} + +func (s *Service) enqueueComfyBridgeRequest(ctx context.Context, userID string, bridgeID string, taskID string, requestID string, payload map[string]any) (*ComfyBridgeRequest, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + bridgeID = strings.TrimSpace(bridgeID) + taskID = strings.TrimSpace(taskID) + if bridgeID == "" || taskID == "" { + return nil, BadAuthRequest("Bridge ID 和任务 ID 不能为空") + } + if payload == nil { + return nil, BadAuthRequest("Bridge 请求内容不能为空") + } + bridge, err := s.repo.ComfyBridgeForUser(userID, bridgeID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, BadAuthRequest("Bridge 不存在或已撤销") + } + return nil, err + } + if !bridge.Enabled { + return nil, BadAuthRequest("Bridge 不存在或已撤销") + } + encoded, err := json.Marshal(payload) + if err != nil { + return nil, BadAuthRequest("Bridge 请求内容格式无效") + } + if len(encoded) > comfyBridgeRequestPayloadLimit { + return nil, BadAuthRequest("Bridge 请求内容超过 16MB") + } + // Marshal/Unmarshal 生成独立副本,避免调用方在入队后继续修改 map 导致任务内容漂移。 + var copied map[string]any + if err := json.Unmarshal(encoded, &copied); err != nil { + return nil, BadAuthRequest("Bridge 请求内容必须是 JSON 对象") + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + if count, err := s.repo.PendingComfyBridgeRequestCount(bridgeID, time.Now()); err != nil { + return nil, err + } else if count >= comfyBridgeMaxPending { + return nil, BadAuthRequest("该 Bridge 待处理请求过多,请稍后重试") + } + requestID = newID() + } + now := time.Now() + record, created, err := s.repo.CreateOrGetComfyBridgeRequest(&model.ComfyBridgeRequest{ + ID: requestID, TaskID: taskID, UserID: userID, BridgeID: bridgeID, Kind: ComfyBridgeRequestKindGenerate, + Status: "queued", PayloadJSON: string(encoded), ExpiresAt: now.Add(comfyBridgeRequestTTL), CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + return nil, err + } + if record.UserID != userID || record.BridgeID != bridgeID || record.TaskID != taskID { + return nil, Forbidden("Bridge 请求 ID 已被其他任务使用") + } + request, err := comfyBridgeRequestFromModel(*record) + if err != nil { + return nil, err + } + runtime := s.comfyBridgeRuntime() + runtime.ensure(request) + if created { + runtime.signal(bridgeID) + } + if err := s.repo.MarkComfyBridgeTask(bridgeID, now); err != nil { + if created { + _ = s.repo.CancelComfyBridgeRequest(request.ID, time.Now()) + runtime.remove(request.ID) + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, BadAuthRequest("Bridge 不存在或已撤销") + } + return nil, err + } + return request, nil +} + +// PollComfyBridgeRequest 由本地 Bridge 调用。wait 为 0 时只检查当前队列。 +func (s *Service) PollComfyBridgeRequest(ctx context.Context, bridge *model.ComfyBridge, wait time.Duration) (*ComfyBridgeRequest, error) { + if ctx == nil { + ctx = context.Background() + } + if bridge == nil || strings.TrimSpace(bridge.ID) == "" || !bridge.Enabled { + return nil, Unauthorized("Bridge 令牌无效或已撤销") + } + if wait < 0 { + wait = 0 + } + if wait > comfyBridgePollMaxWait { + wait = comfyBridgePollMaxWait + } + runtime := s.comfyBridgeRuntime() + deadline := time.Now().Add(wait) + for { + record, err := s.repo.ClaimNextComfyBridgeRequest(bridge.ID, time.Now()) + if err != nil { + return nil, err + } + if record != nil { + request, err := comfyBridgeRequestFromModel(*record) + if err != nil { + _ = s.repo.CancelComfyBridgeRequest(record.ID, time.Now()) + return nil, err + } + runtime.ensure(request) + if err := s.repo.MarkComfyBridgeTask(bridge.ID, time.Now()); err != nil { + _ = s.repo.CancelComfyBridgeRequest(request.ID, time.Now()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, Unauthorized("Bridge 令牌无效或已撤销") + } + return nil, err + } + return request, nil + } + if wait == 0 { + return nil, nil + } + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, nil + } + channel := runtime.channel(bridge.ID) + timer := time.NewTimer(remaining) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return nil, ctx.Err() + case <-timer.C: + return nil, nil + case <-channel: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + } + } +} + +// CompleteComfyBridgeRequest 将 Bridge 结果投递给等待该请求的后端 worker。 +func (s *Service) CompleteComfyBridgeRequest(bridgeID string, completion ComfyBridgeCompletion) error { + bridgeID = strings.TrimSpace(bridgeID) + completion.RequestID = strings.TrimSpace(completion.RequestID) + if bridgeID == "" || completion.RequestID == "" { + return BadAuthRequest("Bridge ID 和请求 ID 不能为空") + } + completion.Status = strings.ToLower(strings.TrimSpace(completion.Status)) + if completion.Status == "" { + completion.Status = "succeeded" + } + switch completion.Status { + case "succeeded", "failed", "cancelled": + default: + return BadAuthRequest("Bridge 结果状态无效") + } + if len([]rune(completion.Error)) > 4000 { + completion.Error = truncateRunes(completion.Error, 4000) + } + if completion.Result != nil { + encoded, err := json.Marshal(completion.Result) + if err != nil || len(encoded) > comfyBridgeResultPayloadLimit { + return BadAuthRequest("Bridge 结果内容无效或超过 64MB") + } + } + resultJSON := "" + if completion.Result != nil { + encoded, _ := json.Marshal(completion.Result) + resultJSON = string(encoded) + } + if _, err := s.repo.CompleteComfyBridgeRequest(bridgeID, completion.RequestID, completion.Status, resultJSON, completion.Error, time.Now()); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("Bridge 请求不存在或已过期") + } + if errors.Is(err, repository.ErrComfyBridgeRequestOwnership) { + return Forbidden("Bridge 不能提交其他设备的请求") + } + if errors.Is(err, repository.ErrComfyBridgeRequestState) { + return BadAuthRequest("Bridge 请求尚未领取") + } + return err + } + runtime := s.comfyBridgeRuntime() + runtime.mu.Lock() + envelope := runtime.pending[completion.RequestID] + if envelope != nil { + select { + case envelope.done <- completion: + default: + } + } + runtime.mu.Unlock() + return nil +} + +// WaitComfyBridgeRequest 由生成 worker 调用,直到 Bridge 回传结果或任务被取消。 +func (s *Service) WaitComfyBridgeRequest(ctx context.Context, requestID string) (ComfyBridgeCompletion, error) { + if ctx == nil { + ctx = context.Background() + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return ComfyBridgeCompletion{}, BadAuthRequest("Bridge 请求 ID 不能为空") + } + runtime := s.comfyBridgeRuntime() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + record, err := s.repo.ComfyBridgeRequest(requestID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return ComfyBridgeCompletion{}, BadAuthRequest("Bridge 请求不存在或已过期") + } + if err != nil { + return ComfyBridgeCompletion{}, err + } + completion, terminal, err := comfyBridgeCompletionFromModel(*record) + if err != nil { + return ComfyBridgeCompletion{}, err + } + if terminal { + runtime.remove(requestID) + return completion, nil + } + request, err := comfyBridgeRequestFromModel(*record) + if err != nil { + return ComfyBridgeCompletion{}, err + } + envelope := runtime.ensure(request) + select { + case <-envelope.done: + // 数据库已经在结果接口事务中落盘;下一轮统一从持久化记录读取。 + case <-ticker.C: + case <-ctx.Done(): + _ = s.repo.CancelComfyBridgeRequest(requestID, time.Now()) + runtime.remove(requestID) + return ComfyBridgeCompletion{}, ctx.Err() + } + } +} + +// CancelComfyBridgeRequest 供任务取消路径清理尚未完成的 Bridge 请求。 +func (s *Service) CancelComfyBridgeRequest(requestID string) { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return + } + _ = s.repo.CancelComfyBridgeRequest(requestID, time.Now()) + runtime := s.comfyBridgeRuntime() + runtime.remove(requestID) +} + +func comfyBridgeRequestFromModel(record model.ComfyBridgeRequest) (*ComfyBridgeRequest, error) { + payload := map[string]any{} + if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { + return nil, errors.New("Bridge 持久化请求内容无效") + } + return &ComfyBridgeRequest{ID: record.ID, Kind: record.Kind, TaskID: record.TaskID, BridgeID: record.BridgeID, Payload: payload, CreatedAt: record.CreatedAt}, nil +} + +func comfyBridgeCompletionFromModel(record model.ComfyBridgeRequest) (ComfyBridgeCompletion, bool, error) { + if record.Status != "succeeded" && record.Status != "failed" && record.Status != "cancelled" { + return ComfyBridgeCompletion{}, false, nil + } + completion := ComfyBridgeCompletion{RequestID: record.ID, Status: record.Status, Error: record.Error} + if strings.TrimSpace(record.ResultJSON) != "" { + if err := json.Unmarshal([]byte(record.ResultJSON), &completion.Result); err != nil { + return ComfyBridgeCompletion{}, false, errors.New("Bridge 持久化结果内容无效") + } + } + return completion, true, nil +} + +func (q *comfyBridgeQueue) ensure(request *ComfyBridgeRequest) *comfyBridgeEnvelope { + q.mu.Lock() + defer q.mu.Unlock() + if envelope := q.pending[request.ID]; envelope != nil { + return envelope + } + envelope := &comfyBridgeEnvelope{request: *request, done: make(chan ComfyBridgeCompletion, 1)} + q.pending[request.ID] = envelope + return envelope +} + +func (q *comfyBridgeQueue) channel(bridgeID string) <-chan struct{} { + q.mu.Lock() + defer q.mu.Unlock() + channel := q.notify[bridgeID] + if channel == nil { + channel = make(chan struct{}, 1) + q.notify[bridgeID] = channel + } + return channel +} + +func (q *comfyBridgeQueue) signal(bridgeID string) { + q.mu.Lock() + q.signalLocked(bridgeID) + q.mu.Unlock() +} + +func (q *comfyBridgeQueue) remove(requestID string) { + q.mu.Lock() + defer q.mu.Unlock() + delete(q.pending, requestID) +} + +func (q *comfyBridgeQueue) failBridge(bridgeID string, err error) { + q.mu.Lock() + defer q.mu.Unlock() + message := "Bridge 请求失败" + if err != nil { + message = err.Error() + } + for id, envelope := range q.pending { + if envelope == nil { + delete(q.pending, id) + continue + } + if envelope.request.BridgeID != bridgeID { + continue + } + select { + case envelope.done <- ComfyBridgeCompletion{RequestID: id, Status: "failed", Error: message}: + default: + } + delete(q.pending, id) + } + q.signalLocked(bridgeID) +} + +func (q *comfyBridgeQueue) signalLocked(bridgeID string) { + channel := q.notify[bridgeID] + if channel == nil { + channel = make(chan struct{}, 1) + q.notify[bridgeID] = channel + } + select { + case channel <- struct{}{}: + default: + } +} + +func comfyBridgeSummary(bridge model.ComfyBridge) ComfyBridgeSummary { + result := ComfyBridgeSummary{ID: bridge.ID, Name: bridge.Name, Enabled: bridge.Enabled, LastSeenAt: bridge.LastSeenAt, LastTaskAt: bridge.LastTaskAt, CreatedAt: bridge.CreatedAt, UpdatedAt: bridge.UpdatedAt} + result.Online = bridge.Enabled && bridge.LastSeenAt != nil && time.Since(*bridge.LastSeenAt) <= 90*time.Second + if strings.TrimSpace(bridge.CapabilitiesJSON) != "" { + var capabilities map[string]any + if json.Unmarshal([]byte(bridge.CapabilitiesJSON), &capabilities) == nil && capabilities != nil { + result.Capabilities = capabilities + } + } + return result +} + +func contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} diff --git a/backend/internal/service/comfy_bridge_persistence_test.go b/backend/internal/service/comfy_bridge_persistence_test.go new file mode 100644 index 000000000..7460fb553 --- /dev/null +++ b/backend/internal/service/comfy_bridge_persistence_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestComfyBridgeRequestSurvivesServiceRestart(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ComfyBridge{}, &model.ComfyBridgeRequest{}); err != nil { + t.Fatal(err) + } + repo := repository.New(db) + bridge := model.ComfyBridge{ID: "bridge-1", UserID: "user-1", Name: "Local", TokenHash: "hash", Enabled: true, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := repo.Create(&bridge); err != nil { + t.Fatal(err) + } + + first := New(repo, t.TempDir()) + request, err := first.EnqueueComfyBridgeRequest(context.Background(), bridge.UserID, bridge.ID, "task-1", map[string]any{"prompt": "hello"}) + if err != nil { + t.Fatal(err) + } + + // 新 Service 没有旧进程的内存队列,仍应从数据库领取同一请求。 + second := New(repo, t.TempDir()) + claimed, err := second.PollComfyBridgeRequest(context.Background(), &bridge, 0) + if err != nil { + t.Fatal(err) + } + if claimed == nil || claimed.ID != request.ID { + t.Fatalf("claimed request = %#v, want %q", claimed, request.ID) + } + if err := second.CompleteComfyBridgeRequest(bridge.ID, ComfyBridgeCompletion{RequestID: request.ID, Status: "succeeded", Result: map[string]any{"url": "https://example.test/result.png"}}); err != nil { + t.Fatal(err) + } + + // 完成结果先于任务 worker 恢复也不会丢失,恢复时复用原请求 ID 且不重复入队。 + third := New(repo, t.TempDir()) + resumed, err := third.enqueueComfyBridgeRequest(context.Background(), bridge.UserID, bridge.ID, "task-1", request.ID, map[string]any{"prompt": "hello"}) + if err != nil { + t.Fatal(err) + } + if resumed.ID != request.ID { + t.Fatalf("resumed request ID = %q, want %q", resumed.ID, request.ID) + } + completion, err := third.WaitComfyBridgeRequest(context.Background(), request.ID) + if err != nil { + t.Fatal(err) + } + if completion.Status != "succeeded" || completion.Result["url"] != "https://example.test/result.png" { + t.Fatalf("completion = %#v", completion) + } + if duplicate, err := third.PollComfyBridgeRequest(context.Background(), &bridge, 0); err != nil || duplicate != nil { + t.Fatalf("completed request was queued again: request=%#v err=%v", duplicate, err) + } +} + +func TestFetchRunningHubAppInfoKeepsAPIKeyOutOfURL(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + t.Errorf("method = %s, want POST", request.Method) + } + if request.URL.RawQuery != "" { + t.Errorf("API Key leaked into URL query: %s", request.URL.RawQuery) + } + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + if body["apiKey"] != "secret-key" || body["webappId"] != "app-1" { + t.Errorf("body = %#v", body) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"code":0,"data":{"nodeInfoList":[]}}`)) + })) + defer server.Close() + + result, err := (&Service{}).FetchRunningHubAppInfo(context.Background(), RunningHubWorkflowFetchRequest{BaseURL: server.URL, APIKey: "secret-key", WebappID: "app-1"}) + if err != nil { + t.Fatal(err) + } + if result["webappId"] != "app-1" { + t.Fatalf("result = %#v", result) + } +} diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 3163dfc06..15c41096b 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -535,7 +535,14 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { return BadAuthRequest("任务输入格式无效") } var taskInput canvasGenerationInput - if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video") { + if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video" && taskInput.Mode != "audio") { + return nil + } + if isWorkflowProviderInterface(taskInput.Config.InterfaceType) { + return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config) + } + // 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。 + if taskInput.Mode == "audio" { return nil } channelID := strings.TrimSpace(taskInput.Config.ChannelID) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 959cddd38..3b57d59d9 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -81,6 +81,14 @@ type providerConfig struct { AudioInstructions string `json:"audioInstructions"` SystemPrompt string `json:"systemPrompt"` CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"` + WorkflowID string `json:"workflowId"` + WebappID string `json:"webappId"` + WorkflowJSON map[string]interface{} `json:"workflowJson"` + WorkflowFields []WorkflowField `json:"workflowFields"` + BridgeID string `json:"bridgeId"` + RunningHubUseWallet bool `json:"runningHubUseWallet"` + RunningHubWalletKey string `json:"runningHubWalletApiKey"` + RunningHubUploadKey string `json:"runningHubUploadApiKey"` } const providerHTTPTimeout = 5 * time.Minute @@ -286,6 +294,18 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } input.Config = config ctx = withProviderOutboundPolicy(ctx, input.Config) + if isWorkflowProviderInterface(input.Config.InterfaceType) { + if err := validateWorkflowProviderConfig(input.Mode, input.Config); err != nil { + return nil, err + } + // 工作流参数由工作流字段定义校验,普通模型能力配置不能覆盖它们。 + if resumedProviderRequestID(ctx) == "" { + if err := s.hydrateGenerationMedia(userID, &input, false); err != nil { + return nil, err + } + } + return s.runWorkflowProviderTask(ctx, input) + } if input.Mode == "image" && input.Metadata != nil { if err := s.applyGenerationStyleProfile(userID, taskProjectID, &input); err != nil { return nil, err @@ -995,6 +1015,14 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, return providerConfig{}, err } config.Headers = headers + if isComfyBridgeInterface(config.InterfaceType) { + config.BaseURL = "bridge://local" + config.APIKey = "" + return config, nil + } + if isRunningHubInterface(config.InterfaceType) && strings.TrimSpace(config.BaseURL) == "" { + config.BaseURL = "https://www.runninghub.cn" + } channelID := strings.TrimSpace(config.ChannelID) if channelID == "" { channelID = systemChannelIDFromBaseURL(config.BaseURL) diff --git a/backend/internal/service/provider_task_cancellation.go b/backend/internal/service/provider_task_cancellation.go index 9c6ed624a..0dff29d5e 100644 --- a/backend/internal/service/provider_task_cancellation.go +++ b/backend/internal/service/provider_task_cancellation.go @@ -73,6 +73,22 @@ func (s *Service) requestProviderCancellation(ctx context.Context, task *model.T if err != nil { return s.markProviderCancellationUncertain(task, "读取上游取消配置失败,费用待核对:"+err.Error()) } + if isComfyBridgeInterface(input.Config.InterfaceType) { + // 尚未领取的请求可以确定取消并退款;已经在本机执行的工作流只能丢弃迟到结果并转人工核对。 + request, requestErr := s.repo.ComfyBridgeRequest(task.ProviderRequestID) + s.CancelComfyBridgeRequest(task.ProviderRequestID) + if requestErr == nil && request.Status == "queued" { + if err := s.taskBilling().RefundBilling(task.BillingOrderID, "本地 ComfyUI Bridge 请求在领取前取消"); err != nil { + return s.markProviderCancellationUncertain(task, "Bridge 请求已取消,但积分退回失败:"+err.Error()) + } + now := time.Now() + if err := s.repo.UpdateTaskProviderCancellation(task.ID, model.ProviderCancelStatusRequested, model.ProviderCancelStatusConfirmed, "", nil, &now); err != nil { + return err + } + return nil + } + return s.markProviderCancellationUncertain(task, "本地 ComfyUI Bridge 不支持取消已领取工作流,执行状态待核对") + } if !supportsProviderCancellation(input.Config.InterfaceType) { return s.markProviderCancellationUncertain(task, "当前上游协议不支持取消,费用待核对") } diff --git a/backend/internal/service/runninghub_management.go b/backend/internal/service/runninghub_management.go new file mode 100644 index 000000000..d90c78acb --- /dev/null +++ b/backend/internal/service/runninghub_management.go @@ -0,0 +1,561 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +// RunningHubWorkflowFetchRequest 只用于独立工作流设置页,不进入 ModelChannel。 +type RunningHubWorkflowFetchRequest struct { + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + WalletAPIKey string `json:"walletApiKey"` + UseWallet bool `json:"useWallet"` + WorkflowID string `json:"workflowId"` + WebappID string `json:"webappId,omitempty"` + Title string `json:"title,omitempty"` + Capability string `json:"capability,omitempty"` +} + +func (s *Service) FetchRunningHubWorkflowInfo(ctx context.Context, req RunningHubWorkflowFetchRequest) (map[string]any, error) { + workflowID := strings.TrimSpace(req.WorkflowID) + if workflowID == "" { + return nil, BadAuthRequest("workflowId 不能为空") + } + config, err := runningHubManagementConfig(req) + if err != nil { + return nil, err + } + root := runningHubRootURL(config.BaseURL) + var response map[string]any + if err := s.runningHubJSON(ctx, config, root+"/api/openapi/getJsonApiFormat", map[string]any{"apiKey": runningHubAPIKey(config), "workflowId": workflowID}, &response); err != nil { + return nil, fmt.Errorf("拉取 RunningHub 工作流参数失败:%w", err) + } + data, _ := response["data"].(map[string]any) + if data == nil { + return nil, errors.New("RunningHub 工作流参数响应缺少 data") + } + workflow := map[string]any{} + if prompt, ok := data["prompt"].(string); ok && strings.TrimSpace(prompt) != "" { + if err := json.Unmarshal([]byte(prompt), &workflow); err != nil { + return nil, fmt.Errorf("RunningHub 工作流 JSON 解析失败:%w", err) + } + } else if prompt, ok := data["prompt"].(map[string]any); ok { + workflow = prompt + } + return map[string]any{"workflowId": workflowID, "kind": "workflow", "title": firstNonEmptyString(req.Title, workflowID), "fields": collectManagementWorkflowFields(workflow, req.Capability), "workflowJson": workflow, "raw": response}, nil +} + +func (s *Service) FetchRunningHubAppInfo(ctx context.Context, req RunningHubWorkflowFetchRequest) (map[string]any, error) { + webappID := strings.TrimSpace(req.WebappID) + if webappID == "" { + return nil, BadAuthRequest("webappId 不能为空") + } + config, err := runningHubManagementConfig(req) + if err != nil { + return nil, err + } + root := runningHubRootURL(config.BaseURL) + var response map[string]any + if err := s.runningHubJSON(ctx, config, root+"/api/webapp/apiCallDemo", map[string]any{ + "apiKey": runningHubAPIKey(config), + "webappId": webappID, + }, &response); err != nil { + return nil, fmt.Errorf("拉取 RunningHub App 参数失败:%w", err) + } + if code, valid := runningHubPayloadCode(response); valid && code != 0 { + return nil, fmt.Errorf("拉取 RunningHub App 参数失败:%s", runningHubFailureMessage(response)) + } + rawData, _ := response["data"].(map[string]any) + result := map[string]any{"kind": "app", "webappId": webappID, "workflowId": webappID, "title": firstNonEmptyString(req.Title, webappID), "fields": []map[string]any{}, "raw": response} + if rawData != nil { + result["fields"] = normalizeManagementAppFields(rawData["nodeInfoList"], req.Capability) + } + return result, nil +} + +func normalizeManagementAppFields(raw any, capability string) []map[string]any { + items, _ := raw.([]any) + fields := make([]map[string]any, 0, len(items)) + for _, item := range items { + original, ok := item.(map[string]any) + if !ok { + continue + } + field := make(map[string]any, len(original)+3) + for key, value := range original { + field[key] = value + } + nodeID := strings.TrimSpace(stringValue(field["nodeId"])) + fieldName := strings.TrimSpace(stringValue(field["fieldName"])) + if nodeID == "" || fieldName == "" { + continue + } + if stringValue(field["id"]) == "" { + field["id"] = nodeID + "::" + fieldName + } + if _, exists := field["enabled"]; !exists { + field["enabled"] = true + } + fieldType := strings.ToUpper(strings.TrimSpace(stringValue(field["fieldType"]))) + if fieldType == "" { + fieldType = managementWorkflowFieldType(fieldName, field["fieldValue"], "") + field["fieldType"] = fieldType + } + if strings.TrimSpace(stringValue(field["source"])) == "" { + if isWorkflowPromptFieldName(fieldName) { + field["source"] = "prompt" + field["sourceAutomatic"] = true + } else if source := managementAutomaticInputSource(fieldName, fieldType); source != "" { + field["source"] = source + field["sourceAutomatic"] = true + } else { + field["source"] = "" + field["sourceAutomatic"] = false + } + } + field["role"] = managementWorkflowFieldRole("", fieldName, fieldType, stringValue(field["source"])) + field["safeToOverride"] = true + fields = append(fields, field) + } + return applyManagementFieldDefaults(fields, capability) +} + +func runningHubManagementConfig(req RunningHubWorkflowFetchRequest) (providerConfig, error) { + config := providerConfig{BaseURL: strings.TrimSpace(req.BaseURL), APIKey: strings.TrimSpace(req.APIKey), RunningHubWalletKey: strings.TrimSpace(req.WalletAPIKey), RunningHubUseWallet: req.UseWallet} + if config.BaseURL == "" { + config.BaseURL = "https://www.runninghub.cn" + } + if _, err := ValidateOutboundURL(runningHubRootURL(config.BaseURL)); err != nil { + return config, err + } + if runningHubAPIKey(config) == "" { + return config, BadAuthRequest("请先填写 RunningHub 积分 API Key") + } + return config, nil +} + +func collectManagementWorkflowFields(workflow map[string]any, capability string) []map[string]any { + fields := make([]map[string]any, 0) + promptNodeID := "" + promptFieldName := "" + if promptFields := runningHubPromptFallback(workflow, "prompt"); len(promptFields) > 0 { + promptNodeID = strings.TrimSpace(stringValue(promptFields[0]["nodeId"])) + promptFieldName = strings.TrimSpace(stringValue(promptFields[0]["fieldName"])) + } + nodeIDs := make([]string, 0, len(workflow)) + for nodeID := range workflow { + nodeIDs = append(nodeIDs, nodeID) + } + sort.Slice(nodeIDs, func(i, j int) bool { return managementNodeIDLess(nodeIDs[i], nodeIDs[j]) }) + for _, nodeID := range nodeIDs { + rawNode := workflow[nodeID] + node, ok := rawNode.(map[string]any) + if !ok { + continue + } + inputs, ok := node["inputs"].(map[string]any) + if !ok { + continue + } + classType := strings.TrimSpace(stringValue(node["class_type"])) + fieldNames := make([]string, 0, len(inputs)) + for fieldName := range inputs { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + for _, fieldName := range fieldNames { + value := inputs[fieldName] + if isWorkflowLinkValue(value) { + continue + } + // 所有静态输入都保留在设置页,但内部参数默认关闭;危险数值字段 + // 额外标记为不可覆盖,并由提交端再次强制拦截。 + fieldType := managementWorkflowFieldType(fieldName, value, classType) + label := fieldName + if title := strings.TrimSpace(workflowNodeMetaTitle(node)); title != "" { + label = title + " · " + fieldName + } + safeToOverride := managementWorkflowFieldSafeToOverride(classType, fieldName) + role := managementWorkflowFieldRole(classType, fieldName, fieldType, "") + field := map[string]any{"id": nodeID + "::" + fieldName, "nodeId": nodeID, "classType": classType, "fieldName": fieldName, "fieldValue": value, "fieldType": fieldType, "label": label, "role": role, "safeToOverride": safeToOverride, "enabled": safeToOverride && role != "internal", "source": "", "sourceAutomatic": false} + if nodeID == promptNodeID && fieldName == promptFieldName { + field["source"] = "prompt" + field["sourceAutomatic"] = true + field["required"] = true + field["role"] = "prompt" + field["enabled"] = true + } else if source := managementAutomaticInputSource(fieldName, fieldType); source != "" { + field["source"] = source + field["sourceAutomatic"] = true + field["role"] = "media" + field["enabled"] = true + } + if isManagementSeedField(fieldName) { + field["randomEnabled"] = true + } + fields = append(fields, field) + } + } + return applyManagementFieldDefaults(fields, capability) +} + +func managementWorkflowFieldSafeToOverride(classType string, fieldName string) bool { + classKey := normalizeManagementFieldName(classType) + fieldKey := normalizeManagementFieldName(fieldName) + if classKey == "int" && fieldKey == "value" { + return false + } + return classKey != "imageresize+" || (fieldKey != "width" && fieldKey != "height" && fieldKey != "multipleof") +} + +func managementWorkflowFieldRole(classType string, fieldName string, fieldType string, source string) string { + normalizedSource := normalizeManagementFieldName(source) + if normalizedSource == "prompt" || normalizedSource == "text" || normalizedSource == "positiveprompt" || normalizedSource == "positive" { + return "prompt" + } + if normalizedSource == "referenceimage" || normalizedSource == "image" || normalizedSource == "referencevideo" || normalizedSource == "video" || normalizedSource == "referenceaudio" || normalizedSource == "audio" || normalizedSource == "mask" { + return "media" + } + if fieldType == "IMAGE" || fieldType == "VIDEO" || fieldType == "AUDIO" { + return "media" + } + if !managementWorkflowFieldSafeToOverride(classType, fieldName) { + return "internal" + } + key := normalizeManagementFieldName(fieldName) + for _, candidate := range []string{"aspectratio", "ratio", "duration", "durationseconds", "seconds", "videoseconds", "quality", "resolution", "seed", "noiseseed", "steps", "step", "sigmapoints", "cfg", "cfgscale", "guidance", "guidancescale", "sampler", "samplername", "scheduler", "fps", "count", "batch", "batchsize", "generateaudio", "watermark", "negativeprompt", "systemprompt"} { + if key == candidate { + return "business" + } + } + if strings.TrimSpace(classType) == "" { + // AI App 返回的是公开参数列表,没有内部节点类型。 + return "business" + } + return "internal" +} + +func applyManagementFieldDefaults(fields []map[string]any, capability string) []map[string]any { + sort.SliceStable(fields, func(i, j int) bool { + leftNode := strings.TrimSpace(stringValue(fields[i]["nodeId"])) + rightNode := strings.TrimSpace(stringValue(fields[j]["nodeId"])) + if leftNode != rightNode { + return managementNodeIDLess(leftNode, rightNode) + } + return strings.TrimSpace(stringValue(fields[i]["fieldName"])) < strings.TrimSpace(stringValue(fields[j]["fieldName"])) + }) + imageOrder := 0 + videoOrder := 0 + audioOrder := 0 + for _, field := range fields { + fieldName := strings.TrimSpace(stringValue(field["fieldName"])) + fieldType := strings.ToUpper(strings.TrimSpace(stringValue(field["fieldType"]))) + source := strings.TrimSpace(stringValue(field["source"])) + sourceAutomatic, sourceAutomaticConfigured := field["sourceAutomatic"].(bool) + if source == "" && (!sourceAutomaticConfigured || sourceAutomatic) { + source = workflowDynamicSourceForMode(fieldName, fieldType, capability) + if source != "" { + field["source"] = source + field["sourceAutomatic"] = true + } + } + switch normalizeManagementFieldName(source) { + case "prompt", "text", "positiveprompt", "positive": + if _, exists := field["required"]; !exists { + field["required"] = true + } + case "referenceimage", "image": + configuredOrder := managementIntValue(field["imageOrder"], 0) + if configuredOrder <= 0 { + imageOrder++ + configuredOrder = imageOrder + field["imageOrder"] = configuredOrder + } else if configuredOrder > imageOrder { + imageOrder = configuredOrder + } + if _, exists := field["sourceIndex"]; !exists { + field["sourceIndex"] = configuredOrder - 1 + } + if _, exists := field["required"]; !exists { + field["required"] = configuredOrder == 1 + } + case "referencevideo", "video": + configuredIndex, exists := field["sourceIndex"] + index := managementIntValue(configuredIndex, videoOrder) + if !exists { + field["sourceIndex"] = index + } + if index >= videoOrder { + videoOrder = index + 1 + } + if _, exists := field["required"]; !exists { + field["required"] = index == 0 + } + case "referenceaudio", "audio": + configuredIndex, exists := field["sourceIndex"] + index := managementIntValue(configuredIndex, audioOrder) + if !exists { + field["sourceIndex"] = index + } + if index >= audioOrder { + audioOrder = index + 1 + } + if _, exists := field["required"]; !exists { + field["required"] = index == 0 + } + case "mask": + if _, exists := field["required"]; !exists { + field["required"] = false + } + } + if isManagementSeedField(fieldName) { + field["randomEnabled"] = true + } + } + return fields +} + +func managementAutomaticInputSource(fieldName string, fieldType string) string { + if strings.Contains(normalizeManagementFieldName(fieldName), "mask") { + return "mask" + } + switch strings.ToUpper(strings.TrimSpace(fieldType)) { + case "IMAGE": + return "referenceImage" + case "VIDEO": + return "referenceVideo" + case "AUDIO": + return "referenceAudio" + default: + return "" + } +} + +func managementNodeIDLess(left string, right string) bool { + leftNumber, leftErr := strconv.ParseInt(strings.TrimSpace(left), 10, 64) + rightNumber, rightErr := strconv.ParseInt(strings.TrimSpace(right), 10, 64) + if leftErr == nil && rightErr == nil { + return leftNumber < rightNumber + } + return left < right +} + +func managementIntValue(value any, fallback int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(value))) + if err != nil { + return fallback + } + return parsed +} + +func workflowDynamicSource(name string, fieldType string) string { + return workflowDynamicSourceForMode(name, fieldType, "") +} + +func workflowDynamicSourceForMode(name string, fieldType string, mode string) string { + if source := workflowNamedDynamicSource(name, mode); source != "" { + return source + } + normalizedFieldType := strings.ToUpper(strings.TrimSpace(fieldType)) + switch normalizedFieldType { + case "IMAGE": + return "referenceImage" + case "VIDEO": + return "referenceVideo" + case "AUDIO": + return "referenceAudio" + default: + return "" + } +} + +func workflowNamedDynamicSource(name string, mode string) string { + key := normalizeManagementFieldName(name) + switch { + case strings.Contains(key, "mask"): + return "mask" + case workflowDimensionNameSource(key) != "": + return workflowDimensionNameSource(key) + case key == "ratio" || key == "aspectratio" || key == "imageaspectratio" || key == "imageratio" || key == "videoaspectratio" || key == "videoratio": + return "aspectRatio" + case key == "videoresolution" || key == "videoquality" || key == "vquality": + return "vquality" + case key == "size" || key == "imagesize" || key == "imageresolution": + return "size" + case key == "resolution" && strings.EqualFold(strings.TrimSpace(mode), "video"): + return "vquality" + case key == "resolution" && strings.EqualFold(strings.TrimSpace(mode), "image"): + return "size" + case key == "batch" || key == "batchsize" || key == "count" || key == "numimages" || key == "numberofimages" || key == "imagecount" || key == "imagescount": + return "count" + case key == "quality": + return "quality" + case key == "duration" || key == "seconds" || key == "durationseconds" || key == "videoseconds" || key == "videoduration" || key == "videodurationseconds" || key == "videolength" || key == "clipduration": + return "videoSeconds" + case key == "generateaudio" || key == "videogenerateaudio": + return "videoGenerateAudio" + case key == "watermark" || key == "videowatermark": + return "videoWatermark" + case key == "audioformat": + return "audioFormat" + case key == "voice" || key == "audiovoice": + return "audioVoice" + case key == "audiospeed": + return "audioSpeed" + case key == "audioinstructions": + return "audioInstructions" + case key == "transparentbackground" || key == "transparent": + return "transparentBackground" + default: + return "" + } +} + +func workflowDimensionNameSource(key string) string { + prefixes := []string{"", "image", "video", "size", "output", "target", "latent", "frame", "canvas", "source", "resolution", "final"} + for _, prefix := range prefixes { + if key == prefix+"width" { + return "width" + } + if key == prefix+"height" { + return "height" + } + } + if key == "pixelwidth" || key == "widthpixels" { + return "width" + } + if key == "pixelheight" || key == "heightpixels" { + return "height" + } + return "" +} + +func normalizeManagementFieldName(value string) string { + return strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) +} + +func isManagementSeedField(value string) bool { + key := normalizeManagementFieldName(value) + return key == "seed" || strings.HasSuffix(key, "seed") || strings.Contains(key, "noiseseed") +} + +func managementFieldType(name string, value any) string { + return managementWorkflowFieldType(name, value, "") +} + +func managementWorkflowFieldType(name string, value any, classType string) string { + key := normalizeManagementFieldName(name) + classKey := normalizeManagementFieldName(classType) + if strings.Contains(key, "mask") { + return "IMAGE" + } + if strings.Contains(classKey, "loadimage") && (key == "file" || key == "path") { + return "IMAGE" + } + if strings.Contains(classKey, "loadvideo") && (key == "file" || key == "path") { + return "VIDEO" + } + if strings.Contains(classKey, "loadaudio") && (key == "file" || key == "path") { + return "AUDIO" + } + if source := workflowNamedDynamicSource(name, ""); source != "" && source != "mask" { + return managementScalarFieldType(value) + } + switch value.(type) { + case bool: + return "BOOLEAN" + case float64, int, int64: + return "NUMBER" + } + if managementMediaFieldName(key, "image") { + return "IMAGE" + } + if managementMediaFieldName(key, "video") { + return "VIDEO" + } + if managementMediaFieldName(key, "audio") { + return "AUDIO" + } + // RunningHub 的部分自定义节点不会把素材字段命名为 image/video/audio, + // 但默认值仍是带扩展名的输入文件;与参考项目一致,使用文件类型补充识别。 + if mediaType := managementMediaValueType(value); mediaType != "" { + return mediaType + } + return "TEXT" +} + +func managementMediaValueType(value any) string { + switch items := value.(type) { + case []any: + if len(items) == 0 { + return "" + } + value = items[0] + case []string: + if len(items) == 0 { + return "" + } + value = items[0] + } + text := strings.ToLower(strings.TrimSpace(stringValue(value))) + if text == "" { + return "" + } + if index := strings.IndexAny(text, "?#"); index >= 0 { + text = text[:index] + } + for _, suffix := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".avif"} { + if strings.HasSuffix(text, suffix) { + return "IMAGE" + } + } + for _, suffix := range []string{".mp4", ".webm", ".mov", ".m4v", ".mkv"} { + if strings.HasSuffix(text, suffix) { + return "VIDEO" + } + } + for _, suffix := range []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"} { + if strings.HasSuffix(text, suffix) { + return "AUDIO" + } + } + return "" +} + +func managementScalarFieldType(value any) string { + switch value.(type) { + case bool: + return "BOOLEAN" + case float32, float64, int, int32, int64: + return "NUMBER" + default: + return "TEXT" + } +} + +func managementMediaFieldName(key string, mediaType string) bool { + if key == mediaType { + return true + } + prefixes := []string{"input", "reference", "ref", "source", "init", "start", "end", "first", "last", "control", "controlnet", "style", "subject"} + for _, prefix := range prefixes { + if key == prefix+mediaType { + return true + } + } + for _, suffix := range []string{"file", "path", "filename", "upload"} { + if key == mediaType+suffix { + return true + } + } + if mediaType == "image" { + return key == "firstframe" || key == "lastframe" || key == "startframe" || key == "endframe" + } + return false +} diff --git a/backend/internal/service/settings.go b/backend/internal/service/settings.go index ef5934c1c..d8b5175e4 100644 --- a/backend/internal/service/settings.go +++ b/backend/internal/service/settings.go @@ -376,7 +376,7 @@ func (s *Service) protectTaskSecrets(value interface{}) error { switch item := value.(type) { case map[string]interface{}: for key, child := range item { - if key == "apiKey" { + if isTaskSecretField(key) { secret, _ := child.(string) if secret != "" && secret != "system" && !strings.HasPrefix(secret, encryptedSettingPrefix) { encrypted, err := s.encryptSettingSecret(secret) @@ -420,7 +420,7 @@ func (s *Service) decryptTaskSecrets(value interface{}) error { switch item := value.(type) { case map[string]interface{}: for key, child := range item { - if key == "apiKey" { + if isTaskSecretField(key) { secret, _ := child.(string) if strings.HasPrefix(secret, encryptedSettingPrefix) { plain, err := s.decryptSettingSecret(secret) @@ -445,6 +445,15 @@ func (s *Service) decryptTaskSecrets(value interface{}) error { return nil } +func isTaskSecretField(key string) bool { + switch key { + case "apiKey", "secretKey", "runningHubWalletApiKey", "runningHubUploadApiKey": + return true + default: + return false + } +} + func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossSettingValue, error) { next := normalizeOSSSetting(ossSettingValue{ Enabled: req.Enabled, diff --git a/backend/internal/service/task_cancellation_test.go b/backend/internal/service/task_cancellation_test.go index b597b3bb7..79c84ebd7 100644 --- a/backend/internal/service/task_cancellation_test.go +++ b/backend/internal/service/task_cancellation_test.go @@ -12,7 +12,7 @@ import ( "gorm.io/gorm" ) -func TestCancelTaskRejectsRunningTask(t *testing.T) { +func TestCancelTaskCancelsRunningTask(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -33,20 +33,24 @@ func TestCancelTaskRejectsRunningTask(t *testing.T) { } svc := &Service{repo: repository.New(db)} - if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" { + cancelled, err := svc.CancelTask(context.Background(), task.UserID, task.ID) + if err != nil { t.Fatalf("CancelTask() error = %v", err) } + if cancelled.Status != model.TaskStatusCancelled { + t.Fatalf("CancelTask() status = %s, want cancelled", cancelled.Status) + } stored, err := svc.repo.TaskForUser(task.UserID, task.ID) if err != nil { t.Fatal(err) } - if stored.Status != model.TaskStatusRunning || stored.CompletedAt != nil { - t.Fatalf("running task changed after cancellation attempt: status=%s completedAt=%v", stored.Status, stored.CompletedAt) + if stored.Status != model.TaskStatusCancelled || stored.CompletedAt == nil { + t.Fatalf("running task was not cancelled: status=%s completedAt=%v", stored.Status, stored.CompletedAt) } } -func TestCancelTaskRejectsQueuedTaskWithoutChangingBillingState(t *testing.T) { +func TestCancelTaskCancelsQueuedTask(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -60,16 +64,20 @@ func TestCancelTaskRejectsQueuedTaskWithoutChangingBillingState(t *testing.T) { } svc := &Service{repo: repository.New(db)} - if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" { + cancelled, err := svc.CancelTask(context.Background(), task.UserID, task.ID) + if err != nil { t.Fatalf("CancelTask() error = %v", err) } + if cancelled.Status != model.TaskStatusCancelled { + t.Fatalf("CancelTask() status = %s, want cancelled", cancelled.Status) + } storedTask, err := svc.repo.TaskForUser(task.UserID, task.ID) if err != nil { t.Fatal(err) } - if storedTask.Status != model.TaskStatusQueued || storedTask.CompletedAt != nil { - t.Fatalf("queued task changed after cancellation attempt: status=%s completedAt=%v", storedTask.Status, storedTask.CompletedAt) + if storedTask.Status != model.TaskStatusCancelled || storedTask.CompletedAt == nil { + t.Fatalf("queued task was not cancelled: status=%s completedAt=%v", storedTask.Status, storedTask.CompletedAt) } } @@ -92,7 +100,7 @@ func TestCancelTaskRejectsTaskWithProviderRequestID(t *testing.T) { } svc := &Service{repo: repository.New(db)} - if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务已发起,无法取消,请等待任务完成" { + if _, err := svc.CancelTask(context.Background(), task.UserID, task.ID); err == nil || err.Error() != "任务当前状态为 failed,无法取消" { t.Fatalf("CancelTask() error = %v", err) } diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index 425b3d09b..138229256 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -26,38 +26,42 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, return nil, err } - // 根据 frontendModelsEnabled 开关强制分流模型选择 - frontendEnabled, err := s.FeatureEnabled(FeatureFrontendModels) - if err != nil { - return nil, err - } - var routed *RoutedModel logicalModelID := strings.TrimSpace(req.LogicalModelID) - - if frontendEnabled { - // 前台模型模式:必须有 logicalModelId - if logicalModelID == "" { - return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId") - } - intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation) - routed, err = s.ResolveLogicalModel(logicalModelID, intent) + workflowProviderTask := taskInputUsesWorkflowProvider(normalizedInput) + frontendEnabled := false + if !workflowProviderTask { + // 工作流是独立执行器;普通模型仍严格使用主线的目录和路由校验。 + frontendEnabled, err = s.FeatureEnabled(FeatureFrontendModels) if err != nil { return nil, err } - normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) - } else { - // 系统渠道模型模式:禁止 logicalModelId - if logicalModelID != "" { - return nil, ModelCatalogMismatch("模型目录已更新,请重新选择") - } - // 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、 - // 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。 - if !taskInputUsesCustomChannel(normalizedInput) { - // 非自定义任务必须校验 channelId + model 属于启用的系统渠道模型。 - if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil { + } + + if !workflowProviderTask { + if frontendEnabled { + // 前台模型模式:必须有 logicalModelId + if logicalModelID == "" { + return nil, InvalidModelSelection("前台模型模式下必须指定 logicalModelId") + } + intent := ModelRequestIntentFromTaskInput(normalizedInput, taskType, req.Operation) + routed, err = s.ResolveLogicalModel(logicalModelID, intent) + if err != nil { return nil, err } + normalizedInput = applyRoutedProviderSelection(normalizedInput, routed) + } else { + // 系统渠道模型模式:禁止 logicalModelId + if logicalModelID != "" { + return nil, ModelCatalogMismatch("模型目录已更新,请重新选择") + } + // 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、 + // 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。 + if !taskInputUsesCustomChannel(normalizedInput) { + if err := s.validateSystemChannelModelSelection(normalizedInput); err != nil { + return nil, err + } + } } } @@ -306,6 +310,9 @@ func (s *Service) validateSystemChannelModelSelection(input map[string]any) erro } func taskInputUsesCustomChannel(input map[string]any) bool { + if taskInputUsesWorkflowProvider(input) { + return false + } config, ok := input["config"].(map[string]any) if !ok { return false @@ -319,6 +326,14 @@ func taskInputUsesCustomChannel(input map[string]any) bool { return strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != "" } +func taskInputUsesWorkflowProvider(input map[string]any) bool { + config, ok := input["config"].(map[string]any) + if !ok { + return false + } + return isWorkflowProviderInterface(strings.TrimSpace(fmt.Sprint(config["interfaceType"]))) +} + func compactPersistedValue(value interface{}) interface{} { switch item := value.(type) { case map[string]interface{}: diff --git a/backend/internal/service/task_creation_test.go b/backend/internal/service/task_creation_test.go new file mode 100644 index 000000000..acd426c7d --- /dev/null +++ b/backend/internal/service/task_creation_test.go @@ -0,0 +1,28 @@ +package service + +import "testing" + +func TestTaskInputUsesWorkflowProvider(t *testing.T) { + tests := []struct { + name string + input map[string]any + want bool + }{ + {name: "runninghub workflow", input: map[string]any{"config": map[string]any{"interfaceType": "runninghub-workflow-video"}}, want: true}, + {name: "comfy bridge", input: map[string]any{"config": map[string]any{"interfaceType": "comfyui-bridge-image"}}, want: true}, + {name: "case insensitive", input: map[string]any{"config": map[string]any{"interfaceType": "RunningHub-Workflow-Audio"}}, want: true}, + {name: "ordinary model", input: map[string]any{"config": map[string]any{"interfaceType": "openai-image", "channelId": "system-1", "model": "image-model"}}, want: false}, + {name: "missing config", input: map[string]any{}, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := taskInputUsesWorkflowProvider(test.input); got != test.want { + t.Fatalf("taskInputUsesWorkflowProvider() = %v, want %v", got, test.want) + } + if test.want && taskInputUsesCustomChannel(test.input) { + t.Fatal("workflow input must not be classified as a custom channel") + } + }) + } +} diff --git a/backend/internal/service/task_execution.go b/backend/internal/service/task_execution.go index 3be0bb2d5..9a81dc7ab 100644 --- a/backend/internal/service/task_execution.go +++ b/backend/internal/service/task_execution.go @@ -57,7 +57,30 @@ func canRunProviderTask(task model.Task) bool { func hasExecutableProviderVideoConfig(input map[string]any) bool { mode, _ := input["mode"].(string) config, ok := input["config"].(map[string]any) - if mode != "video" || !ok || stringValue(config["model"]) == "" { + if mode != "video" || !ok { + return false + } + interfaceType := stringValue(config["interfaceType"]) + if isComfyBridgeInterface(interfaceType) { + workflowJSON, hasWorkflowJSON := config["workflowJson"] + workflowReady := false + if hasWorkflowJSON { + switch value := workflowJSON.(type) { + case map[string]interface{}: + workflowReady = len(value) > 0 + case string: + workflowReady = strings.TrimSpace(value) != "" + } + } + return stringValue(config["bridgeId"]) != "" && (stringValue(config["workflowId"]) != "" || workflowReady) + } + if isRunningHubInterface(interfaceType) { + if stringValue(config["workflowId"]) == "" && stringValue(config["webappId"]) == "" && stringValue(config["model"]) == "" { + return false + } + return stringValue(config["baseUrl"]) != "" && stringValue(config["apiKey"]) != "" + } + if stringValue(config["model"]) == "" { return false } return stringValue(config["channelId"]) != "" || (stringValue(config["baseUrl"]) != "" && stringValue(config["apiKey"]) != "") diff --git a/backend/internal/service/task_lifecycle.go b/backend/internal/service/task_lifecycle.go index 5716cd65c..4eec1e20c 100644 --- a/backend/internal/service/task_lifecycle.go +++ b/backend/internal/service/task_lifecycle.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" @@ -108,10 +109,73 @@ func (w *taskLifecycleCoordinator) cancelTask(_ context.Context, userID string, if err != nil { return nil, err } - // 任务创建后可能已经产生上游费用。取消接口保留用于兼容旧客户端, - // 但不能再改变任务状态、退款或结束关联会话;页面离开只应停止监听。 - if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning || task.ProviderRequestID != "" { - return nil, errors.New("任务已发起,无法取消,请等待任务完成") + if task.Status != model.TaskStatusQueued && task.Status != model.TaskStatusRunning { + if task.Status == model.TaskStatusCancelled { + return taskForOutput(*task), nil + } + return nil, fmt.Errorf("任务当前状态为 %s,无法取消", task.Status) } - return nil, fmt.Errorf("任务当前状态为 %s,无法取消", task.Status) + + // 先从账单和请求日志补齐上游 ID,再做条件更新。取消与 worker 完成之间 + // 以数据库终态为准,避免“用户已取消但迟到结果又把任务写成成功”。 + s.hydrateTaskProviderRequestID(task) + originalStatus := task.Status + now := time.Now() + cancelled, err := s.repo.CancelTaskIfStatus(userID, id, task.Status, now) + if err != nil { + return nil, err + } + if !cancelled { + latest, latestErr := s.repo.TaskForUser(userID, id) + if latestErr != nil { + return nil, latestErr + } + if latest.Status == model.TaskStatusCancelled { + return taskForOutput(*latest), nil + } + return nil, errors.New("任务状态已变化,请刷新后重试") + } + + task.Status = model.TaskStatusCancelled + task.Stage = "任务已取消" + task.Error = "任务已取消" + task.CompletedAt = &now + s.cancelActiveTask(task.ID) + + // 这些收尾操作必须幂等;任何单项失败都记录日志,但不能让已经落库的 + // cancelled 状态重新对用户表现为“取消失败”。 + if err := s.markSessionFailed(*task, "会话任务已取消。"); err != nil { + _ = s.log(task.UserID, task.ID, "error", "取消任务后更新会话状态失败", err.Error()) + } + if err := s.finalizeTaskTextReplay(task.ID, model.TaskStatusCancelled); err != nil { + _ = s.log(task.UserID, task.ID, "error", "取消任务后归并文本回放失败", err.Error()) + } + _ = s.log(task.UserID, task.ID, "warn", "用户主动取消任务", "") + + if task.ProviderRequestID != "" { + // 上游取消可能需要轮询确认,不能阻塞取消接口;请求上下文也不能因 + // 浏览器关闭而中断。后台对账会继续负责退款或费用核对。 + cancelTask := *task + go func() { + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := s.requestProviderCancellation(requestCtx, &cancelTask); err != nil { + _ = s.log(cancelTask.UserID, cancelTask.ID, "error", "发送上游取消请求失败", err.Error()) + } + }() + } else { + var billingErr error + if originalStatus == model.TaskStatusQueued { + billingErr = s.taskBilling().RefundBilling(task.BillingOrderID, "用户主动取消,且任务尚未开始执行") + } else { + // running 任务可能已经发起上游调用但尚未把 request ID 写回,不能 + // 直接退款后放任上游继续生成,先冻结为待核对更安全。 + billingErr = s.taskBilling().MarkBillingUncertain(task.BillingOrderID, "用户取消时上游请求 ID 尚未确认,费用待核对") + } + if billingErr != nil { + _ = s.log(task.UserID, task.ID, "error", "取消任务后处理积分失败,已保留人工核对线索", billingErr.Error()) + } + } + + return taskForOutput(*task), nil } diff --git a/backend/internal/service/task_terminal.go b/backend/internal/service/task_terminal.go index a9ba83a3d..b2a000512 100644 --- a/backend/internal/service/task_terminal.go +++ b/backend/internal/service/task_terminal.go @@ -95,6 +95,11 @@ func (c *taskTerminalCoordinator) markPreparationFailure(task *model.Task, stage // 让 worker 保留重试/监控所需的失败语义。 func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err error, providerSucceeded bool, channelSlotFailedBeforeRequest bool) error { if errors.Is(err, context.Canceled) { + // 用户取消会先把数据库任务置为 cancelled,再停止 worker context。 + // 此时不再重复退款/核对,只补齐 worker 侧的会话、回放和日志收尾。 + if latest, latestErr := c.repo.Task(task.ID); latestErr == nil && latest.Status == model.TaskStatusCancelled { + return c.handleAlreadyCancelled(*latest) + } task.Status = model.TaskStatusCancelled task.Stage = "任务已取消" task.Error = "任务已取消" @@ -145,6 +150,16 @@ func (c *taskTerminalCoordinator) handleExecutionFailure(task *model.Task, err e return resultErr } +func (c *taskTerminalCoordinator) handleAlreadyCancelled(task model.Task) error { + c.finalizeReplay(&task, model.TaskStatusCancelled, "文本回放草稿归并失败") + sessionErr := c.sessions.markSessionFailed(task, "会话任务已取消。") + _ = c.logger.log(task.UserID, task.ID, "warn", "任务已取消,worker 已停止执行", "") + if sessionErr != nil { + return fmt.Errorf("任务取消后的会话状态更新失败:%w", sessionErr) + } + return nil +} + func (c *taskTerminalCoordinator) handleCancelledResult(task model.Task) error { c.finalizeReplay(&task, model.TaskStatusCancelled, "文本回放草稿归并失败") billingErr := c.billing.MarkBillingUncertain(task.BillingOrderID, "上游已返回结果,但任务被取消") diff --git a/backend/internal/service/task_worker.go b/backend/internal/service/task_worker.go index 468b01352..57c1e960b 100644 --- a/backend/internal/service/task_worker.go +++ b/backend/internal/service/task_worker.go @@ -110,6 +110,11 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task) error { defer close(leaseDone) s.registerActiveTask(task.ID, cancel) defer s.unregisterActiveTask(task.ID) + // 取消请求可能在任务领取和注册 worker context 之间到达;再次读取终态 + // 可以避免这种极窄窗口仍然向上游发起调用。 + if latest, latestErr := s.repo.Task(task.ID); latestErr == nil && latest.Status == model.TaskStatusCancelled { + return terminal.handleAlreadyCancelled(*latest) + } task.Stage = "调用生成模型" task.Progress = 35 diff --git a/backend/internal/service/workflow_provider.go b/backend/internal/service/workflow_provider.go new file mode 100644 index 000000000..027af3615 --- /dev/null +++ b/backend/internal/service/workflow_provider.go @@ -0,0 +1,2172 @@ +package service + +// RunningHub 与 ComfyUI Bridge 的协议适配集中在这里。两者都接受“工作流 + 字段覆盖”, +// 但前者由云端 HTTP API 执行,后者由已注册的本地进程领取,因此不能复用普通模型的 URL 拼接逻辑。 + +import ( + "bytes" + "context" + cryptorand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +// WorkflowField 是 RunningHub/ComfyUI 共用的字段描述。Value 与 FieldValue 兼容来源项目 +// 的两种命名;Source 可取 referenceImage/referenceVideo/referenceAudio/mask。 +type WorkflowField struct { + ID string `json:"id"` + NodeID string `json:"nodeId"` + ClassType string `json:"classType,omitempty"` + FieldName string `json:"fieldName"` + Value interface{} `json:"value,omitempty"` + FieldValue interface{} `json:"fieldValue,omitempty"` + FieldType string `json:"fieldType,omitempty"` + Label string `json:"label,omitempty"` + Role string `json:"role,omitempty"` + SafeToOverride *bool `json:"safeToOverride,omitempty"` + OptionsSource string `json:"optionsSource,omitempty"` + Options []interface{} `json:"options,omitempty"` + Min interface{} `json:"min,omitempty"` + Max interface{} `json:"max,omitempty"` + Step interface{} `json:"step,omitempty"` + RandomEnabled bool `json:"randomEnabled,omitempty"` + BindPrompt bool `json:"bindPrompt,omitempty"` + // 指针用于区分“未配置(默认启用)”和明确传入 false。 + Enabled *bool `json:"enabled,omitempty"` + Source string `json:"source,omitempty"` + SourceIndex int `json:"sourceIndex,omitempty"` + ImageOrder int `json:"imageOrder,omitempty"` + SourceFromUpstream bool `json:"sourceFromUpstream,omitempty"` + Required bool `json:"required,omitempty"` + // nil 兼容旧配置;true 表示来源由字段名推断,false 表示用户在映射面板明确选择。 + SourceAutomatic *bool `json:"sourceAutomatic,omitempty"` + // 仅在本次反序列化期间保留,用于区分旧数据缺字段与用户明确选择“保留默认值”。 + sourceConfigured bool +} + +// UnmarshalJSON 兼容来源项目的字段配置格式。来源项目使用 node/input/default/ +// bind_prompt,而画布内部使用 nodeId/fieldName/fieldValue/source;在入口统一归一化, +// 后续 RunningHub 和 Bridge 不需要各自维护一套别名解析。 +func (f *WorkflowField) UnmarshalJSON(data []byte) error { + type plainWorkflowField WorkflowField + var parsed plainWorkflowField + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *f = WorkflowField(parsed) + if f.NodeID == "" { + f.NodeID = workflowFieldRawString(raw, "node", "node_id", "nodeID") + } + if f.ClassType == "" { + f.ClassType = workflowFieldRawString(raw, "class_type", "typeName", "nodeType") + } + if f.FieldName == "" { + f.FieldName = workflowFieldRawString(raw, "input", "inputName", "input_name", "name") + } + if f.ID == "" { + f.ID = workflowFieldRawString(raw, "fieldId", "field_id", "key") + } + if f.FieldType == "" { + f.FieldType = workflowFieldRawString(raw, "type") + } + if len(f.Options) == 0 { + if options, ok := workflowFieldRawSlice(raw, "options", "values", "choices", "enum", "fieldOptions", "field_options"); ok { + f.Options = options + } + } + if f.Min == nil { + f.Min, _ = workflowFieldRawAny(raw, "min", "minValue", "min_value") + if f.Min == nil { + f.Min, _ = workflowFieldRawNestedAny(raw, "min", "minValue", "min_value") + } + } + if f.Max == nil { + f.Max, _ = workflowFieldRawAny(raw, "max", "maxValue", "max_value") + if f.Max == nil { + f.Max, _ = workflowFieldRawNestedAny(raw, "max", "maxValue", "max_value") + } + } + if f.Step == nil { + f.Step, _ = workflowFieldRawAny(raw, "step", "stepValue", "step_value") + if f.Step == nil { + f.Step, _ = workflowFieldRawNestedAny(raw, "step", "stepValue", "step_value") + } + } + if f.Label == "" { + f.Label = workflowFieldRawString(raw, "title") + } + sourceConfigured := workflowFieldRawHas(raw, "source") || workflowFieldRawHas(raw, "bind") || workflowFieldRawHas(raw, "from") + if f.Source == "" { + f.Source = workflowFieldRawString(raw, "bind", "from") + } + if !workflowFieldRawHas(raw, "fieldValue") { + if value, ok := workflowFieldRawAny(raw, "defaultValue", "default_value", "default"); ok { + f.FieldValue = value + } + } + if !workflowFieldRawHas(raw, "sourceIndex") { + f.SourceIndex = workflowFieldRawInt(raw, f.SourceIndex, "source_index", "index") + } + if !workflowFieldRawHas(raw, "imageOrder") { + f.ImageOrder = workflowFieldRawInt(raw, f.ImageOrder, "image_order") + } + if !workflowFieldRawHas(raw, "required") { + f.Required = workflowFieldRawBool(raw, f.Required, "isRequired", "is_required") + } + if !workflowFieldRawHas(raw, "randomEnabled") { + f.RandomEnabled = workflowFieldRawBool(raw, f.RandomEnabled, "random_enabled") + } + if !workflowFieldRawHas(raw, "bindPrompt") { + f.BindPrompt = workflowFieldRawBool(raw, f.BindPrompt, "bind_prompt") + } + if f.BindPrompt && strings.TrimSpace(f.Source) == "" && !sourceConfigured { + f.Source = "prompt" + } + sourceFromUpstreamConfigured := workflowFieldRawHas(raw, "sourceFromUpstream") || workflowFieldRawHas(raw, "source_from_upstream") + f.sourceConfigured = sourceConfigured || sourceFromUpstreamConfigured + if !workflowFieldRawHas(raw, "sourceFromUpstream") && workflowFieldRawHas(raw, "source_from_upstream") { + f.SourceFromUpstream = workflowFieldRawBool(raw, f.SourceFromUpstream, "source_from_upstream") + } + if !sourceConfigured && !sourceFromUpstreamConfigured && strings.TrimSpace(f.Source) == "" { + // 旧工作流没有保存动态来源时,按字段语义恢复宽高、数量、媒体等画布输入绑定。 + f.Source = workflowDynamicSource(f.FieldName, f.FieldType) + } + if !sourceConfigured && !sourceFromUpstreamConfigured && !f.SourceFromUpstream { + switch strings.ToLower(strings.TrimSpace(f.FieldType)) { + case "image", "video", "audio": + // 来源配置默认把媒体字段绑定到上游参考素材。 + f.SourceFromUpstream = true + } + } + if f.ID == "" && f.NodeID != "" && f.FieldName != "" { + f.ID = f.NodeID + "::" + f.FieldName + } + return nil +} + +func workflowFieldRawHas(raw map[string]json.RawMessage, key string) bool { + _, ok := raw[key] + return ok +} + +func workflowFieldRawString(raw map[string]json.RawMessage, keys ...string) string { + for _, key := range keys { + value, ok := raw[key] + if !ok { + continue + } + var text string + if json.Unmarshal(value, &text) == nil && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + var generic interface{} + if json.Unmarshal(value, &generic) == nil && generic != nil { + text = strings.TrimSpace(fmt.Sprint(generic)) + if text != "" && text != "" { + return text + } + } + } + return "" +} + +func workflowFieldRawAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) { + for _, key := range keys { + value, ok := raw[key] + if !ok || string(value) == "null" { + continue + } + var decoded interface{} + if json.Unmarshal(value, &decoded) == nil { + return decoded, true + } + } + return nil, false +} + +func workflowFieldRawSlice(raw map[string]json.RawMessage, keys ...string) ([]interface{}, bool) { + value, ok := workflowFieldRawAny(raw, keys...) + if !ok { + return nil, false + } + if items, ok := value.([]interface{}); ok { + return items, true + } + if object, ok := value.(map[string]interface{}); ok { + for _, key := range []string{"choices", "options", "values", "enum"} { + if items, ok := object[key].([]interface{}); ok { + return items, true + } + } + } + return nil, false +} + +func workflowFieldRawNestedAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) { + for _, containerKey := range []string{"options", "values", "choices", "range", "fieldOptions", "field_options"} { + value, ok := workflowFieldRawAny(raw, containerKey) + if !ok { + continue + } + if found, ok := workflowNestedFieldValue(value, keys...); ok { + return found, true + } + } + return nil, false +} + +func workflowNestedFieldValue(value interface{}, keys ...string) (interface{}, bool) { + if items, ok := value.([]interface{}); ok { + for _, item := range items { + if found, ok := workflowNestedFieldValue(item, keys...); ok { + return found, true + } + } + return nil, false + } + object, ok := value.(map[string]interface{}) + if !ok { + return nil, false + } + for _, key := range keys { + if found, exists := object[key]; exists && found != nil { + return found, true + } + } + if nested, exists := object["range"]; exists { + return workflowNestedFieldValue(nested, keys...) + } + return nil, false +} + +func workflowFieldRawInt(raw map[string]json.RawMessage, fallback int, keys ...string) int { + value, ok := workflowFieldRawAny(raw, keys...) + if !ok { + return fallback + } + switch item := value.(type) { + case float64: + return int(item) + case json.Number: + parsed, err := strconv.Atoi(string(item)) + if err == nil { + return parsed + } + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(item)) + if err == nil { + return parsed + } + } + return fallback +} + +func workflowFieldRawBool(raw map[string]json.RawMessage, fallback bool, keys ...string) bool { + value, ok := workflowFieldRawAny(raw, keys...) + if !ok { + return fallback + } + switch item := value.(type) { + case bool: + return item + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(item)) + if err == nil { + return parsed + } + } + return fallback +} + +func isRunningHubInterface(value string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "runninghub-workflow-") +} + +func isComfyBridgeInterface(value string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "comfyui-bridge-") +} + +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) + } + interfaceType := strings.ToLower(strings.TrimSpace(config.InterfaceType)) + if !workflowInterfaceSupportsMode(interfaceType, mode) { + return fmt.Errorf("接口类型 %s 不支持%s生成", config.InterfaceType, mode) + } + if isRunningHubInterface(config.InterfaceType) { + if runningHubAPIKey(config) == "" { + return errors.New("RunningHub 工作流缺少积分 API Key") + } + if _, err := ValidateOutboundURL(runningHubRootURL(config.BaseURL)); err != nil { + return err + } + if strings.TrimSpace(config.WorkflowID) == "" && strings.TrimSpace(config.WebappID) == "" && strings.TrimSpace(config.Model) == "" { + return errors.New("RunningHub 缺少 workflowId 或 webappId") + } + return nil + } + if isComfyBridgeInterface(config.InterfaceType) { + if strings.TrimSpace(config.BridgeID) == "" { + return errors.New("本地 ComfyUI 缺少 Bridge ID,请先注册并连接 Bridge") + } + if len(config.WorkflowJSON) == 0 && strings.TrimSpace(config.WorkflowID) == "" { + return errors.New("本地 ComfyUI 缺少 API 格式工作流 JSON 或 workflowId") + } + return nil + } + return errors.New("未知工作流协议") +} + +func workflowInterfaceSupportsMode(interfaceType string, mode string) bool { + switch mode { + case "image": + return interfaceType == string(model.ChannelInterfaceRunningHubImage) || interfaceType == string(model.ChannelInterfaceComfyBridgeImage) + case "video": + return interfaceType == string(model.ChannelInterfaceRunningHubVideo) || interfaceType == string(model.ChannelInterfaceComfyBridgeVideo) + case "audio": + return interfaceType == string(model.ChannelInterfaceRunningHubAudio) || interfaceType == string(model.ChannelInterfaceComfyBridgeAudio) + default: + return false + } +} + +func (s *Service) runWorkflowProviderTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + if isRunningHubInterface(input.Config.InterfaceType) { + return s.runRunningHubWorkflow(ctx, input) + } + return s.runComfyBridgeWorkflow(ctx, input) +} + +func (s *Service) runComfyBridgeWorkflow(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if metadata.Service == nil { + metadata.Service = s + } + workflowID := strings.TrimSpace(input.Config.WorkflowID) + if workflowID == "" { + // Bridge-only 渠道可以把模型名直接当作本地 workflows 文件名。 + workflowID = strings.TrimSpace(input.Config.Model) + } + workflowFields := workflowFieldsForMode(input.Config.WorkflowFields, input.Mode) + payload := map[string]any{ + "mode": input.Mode, + "prompt": input.Prompt, + "model": input.Config.Model, + "workflowId": workflowID, + "workflowJson": input.Config.WorkflowJSON, + "workflowFields": workflowFields, + "referenceImages": input.ReferenceImages, + "referenceVideos": input.ReferenceVideos, + "referenceAudios": input.ReferenceAudios, + "mask": input.Mask, + "params": map[string]any{ + "size": input.Config.Size, "quality": input.Config.Quality, "count": input.Config.Count, + "transparentBackground": input.Config.TransparentBackground, + "videoSeconds": input.Config.VideoSeconds, "vquality": input.Config.VQuality, + "videoGenerateAudio": input.Config.VideoGenerateAudio, + "videoWatermark": input.Config.VideoWatermark, + "audioVoice": input.Config.AudioVoice, "audioFormat": input.Config.AudioFormat, + "audioSpeed": input.Config.AudioSpeed, "audioInstructions": input.Config.AudioInstructions, + "systemPrompt": input.Config.SystemPrompt, + }, + "metadata": input.Metadata, + } + request, err := s.enqueueComfyBridgeRequest(ctx, metadata.UserID, input.Config.BridgeID, metadata.TaskID, resumedProviderRequestID(ctx), payload) + if err != nil { + return nil, err + } + if err := s.recordWorkflowProviderRequest(ctx, request.ID, "queued", nil); err != nil { + // 入队后 Bridge 可能已经领取,不能把状态写入失败误判成“上游未执行”并退款。 + _ = s.log(metadata.UserID, metadata.TaskID, "error", "本地 ComfyUI Bridge 请求状态保存失败", err.Error()) + } + completion, err := s.WaitComfyBridgeRequest(ctx, request.ID) + if err != nil { + // Wait 在取消/超时分支会清理队列;这里再次幂等清理,覆盖请求尚未进入等待的竞态。 + s.CancelComfyBridgeRequest(request.ID) + return nil, err + } + _ = s.updateWorkflowProviderState(ctx, request.ID, strings.ToLower(completion.Status), nil) + if completion.Status != "succeeded" { + if strings.TrimSpace(completion.Error) == "" { + completion.Error = "本地 ComfyUI Bridge 执行失败" + } + return nil, errors.New(completion.Error) + } + if completion.Result == nil { + return nil, errors.New("本地 ComfyUI Bridge 未返回结果") + } + return completion.Result, nil +} + +func (s *Service) updateWorkflowProviderState(ctx context.Context, requestID string, stage string, nextPollAt *time.Time) error { + metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if !ok || metadata.TaskID == "" { + return nil + } + return s.repo.UpdateTaskProviderState(metadata.TaskID, requestID, stage, nextPollAt) +} + +// recordWorkflowProviderRequest 只在供应商生成请求首次建立时调用,同时保存任务和账单关联。 +// 轮询状态仍走 updateWorkflowProviderState,避免每次轮询都刷新账单 updated_at,掩盖长期未结算订单。 +func (s *Service) recordWorkflowProviderRequest(ctx context.Context, requestID string, stage string, nextPollAt *time.Time) error { + metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if !ok || metadata.TaskID == "" { + return nil + } + if err := s.repo.UpdateTaskProviderState(metadata.TaskID, requestID, stage, nextPollAt); err != nil { + return err + } + if metadata.BillingOrderID == "" || strings.TrimSpace(requestID) == "" { + return nil + } + return s.repo.UpdateBillingProviderRequestID(metadata.BillingOrderID, strings.TrimSpace(requestID)) +} + +func (s *Service) runRunningHubWorkflow(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + root := runningHubRootURL(input.Config.BaseURL) + apiKey := runningHubAPIKey(input.Config) + if resumed := resumedProviderRequestID(ctx); resumed != "" { + return s.pollRunningHubWorkflow(ctx, input.Config, root, resumed) + } + workflowID := strings.TrimSpace(input.Config.WorkflowID) + webappID := strings.TrimSpace(input.Config.WebappID) + if workflowID == "" { + workflowID = strings.TrimSpace(input.Config.Model) + } + mappingWorkflow := input.Config.WorkflowJSON + if webappID == "" && len(mappingWorkflow) == 0 && workflowID != "" { + // 获取当前 API 工作流既用于旧配置的字段推断,也用于删除缺失的可选媒体默认值。 + // 某些兼容网关没有该接口,失败时仍允许仅依赖用户已经保存的字段映射提交。 + if fetched, fetchErr := s.fetchRunningHubWorkflowJSON(ctx, root, input.Config, workflowID); fetchErr == nil { + mappingWorkflow = fetched + } + } + workflowFields := input.Config.WorkflowFields + if len(workflowFields) == 0 && len(mappingWorkflow) > 0 { + var inferErr error + workflowFields, inferErr = workflowFieldsFromManagement(mappingWorkflow, input.Mode) + if inferErr != nil { + return nil, inferErr + } + } + workflowFields = workflowFieldsForMode(workflowFields, input.Mode) + if err := validateWorkflowMediaInputs(workflowFields, input); err != nil { + return nil, err + } + files := map[string]string{} + for _, media := range append(append(append([]providerMedia{}, input.ReferenceImages...), input.ReferenceVideos...), input.ReferenceAudios...) { + name, err := s.uploadRunningHubMedia(ctx, root, input.Config, media) + if err != nil { + return nil, err + } + files[media.ID] = name + } + if input.Mask != nil { + name, err := s.uploadRunningHubMedia(ctx, root, input.Config, *input.Mask) + if err != nil { + return nil, err + } + files[input.Mask.ID] = name + } + nodeInfo, err := runningHubNodeInfoWithWorkflow(workflowFields, files, input, mappingWorkflow) + if err != nil { + return nil, err + } + // 旧条目可能保存了全部默认字段,却没有把文本节点绑定到任务 Prompt。只在没有任何显式 + // Prompt 映射时回退,并替换同节点同字段的默认值,避免 nodeInfoList 出现互相冲突的重复项。 + if !workflowFieldsBindPrompt(workflowFields) { + nodeInfo = upsertRunningHubNodeInfo(nodeInfo, runningHubPromptFallback(mappingWorkflow, input.Prompt)) + } + body := map[string]any{"apiKey": apiKey} + endpoint := root + "/task/openapi/create" + if webappID != "" { + body["webappId"] = webappID + endpoint = root + "/task/openapi/ai-app/run" + } else { + body["workflowId"] = workflowID + } + if len(nodeInfo) > 0 { + body["nodeInfoList"] = nodeInfo + } + var submitted map[string]any + if err := s.runningHubJSON(ctx, input.Config, endpoint, body, &submitted); err != nil { + return nil, fmt.Errorf("RunningHub 工作流提交失败:%w", err) + } + code, validCode := runningHubPayloadCode(submitted) + if !validCode { + // 部分 RunningHub 兼容网关省略 code,但已经返回 taskId,按成功提交处理。 + validCode = runningHubTaskID(submitted) != "" + code = 0 + } + if !validCode || code != 0 { + return nil, fmt.Errorf("RunningHub 工作流提交失败:%s", runningHubWorkflowFailureMessage(submitted)) + } + taskID := runningHubTaskID(submitted) + if taskID == "" { + return nil, errors.New("RunningHub 未返回 taskId") + } + if err := s.recordWorkflowProviderRequest(ctx, taskID, "submitted", nil); err != nil { + // 上游已经接受请求;继续轮询可在后续状态写入恢复后保住结果和 taskId。 + metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + _ = s.log(metadata.UserID, metadata.TaskID, "error", "RunningHub 请求状态保存失败", taskID+":"+err.Error()) + } + return s.pollRunningHubWorkflow(ctx, input.Config, root, taskID) +} + +func runningHubWorkflowFailureMessage(response map[string]any) string { + message := runningHubFailureMessage(response) + normalized := strings.ToLower(message) + if strings.Contains(message, "企业版余额不足") || (strings.Contains(normalized, "enterprise") && strings.Contains(normalized, "balance")) { + return message + ";工作流提交固定使用积分 API Key,请确认提交 Key 不是企业级素材上传 Key" + } + return message +} + +func (s *Service) fetchRunningHubWorkflowJSON(ctx context.Context, root string, config providerConfig, workflowID string) (map[string]interface{}, error) { + var response map[string]any + if err := s.runningHubJSON(withProviderRequestKind(ctx, "workflow-schema"), config, root+"/api/openapi/getJsonApiFormat", map[string]any{ + "apiKey": runningHubAPIKey(config), + "workflowId": workflowID, + }, &response); err != nil { + return nil, err + } + code, valid := runningHubPayloadCode(response) + if valid && code != 0 { + return nil, errors.New(runningHubFailureMessage(response)) + } + data, _ := response["data"].(map[string]interface{}) + if data == nil { + return nil, errors.New("RunningHub 工作流参数响应缺少 data") + } + raw := data["prompt"] + if raw == nil { + return nil, errors.New("RunningHub 工作流参数响应缺少 prompt") + } + if text, ok := raw.(string); ok { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return nil, err + } + return parsed, nil + } + parsed, ok := raw.(map[string]interface{}) + if !ok { + return nil, errors.New("RunningHub 工作流参数格式无效") + } + return parsed, nil +} + +func (s *Service) uploadRunningHubMedia(ctx context.Context, root string, config providerConfig, media providerMedia) (string, error) { + raw, mimeType, err := mediaBytes(media) + if err != nil && isPublicMediaURL(strings.TrimSpace(media.URL)) { + // 任务里可能只携带了已公开的参考图/视频 URL;RunningHub 上传接口需要字节, + // 这里在 SSRF 白名单校验后下载一次,不把外部 URL 直接交给供应商。 + raw, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "upload"), strings.TrimSpace(media.URL)) + } + if err != nil { + return "", err + } + if len(raw) == 0 { + return "", errors.New("RunningHub 参考素材为空") + } + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + apiKey := strings.TrimSpace(config.RunningHubUploadKey) + if apiKey == "" { + return "", errors.New("RunningHub 参考素材上传需要企业级 API Key,请在 RunningHub 设置中填写“素材上传 API Key(企业级)”") + } + _ = writer.WriteField("apiKey", apiKey) + _ = writer.WriteField("fileType", "input") + filename := providerMediaFilename(media, mimeType) + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": filename})) + header.Set("Content-Type", mimeType) + part, err := writer.CreatePart(header) + if err != nil { + return "", err + } + if _, err := part.Write(raw); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + req, err := http.NewRequestWithContext(withProviderRequestKind(ctx, "upload"), http.MethodPost, root+"/task/openapi/upload", body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + ApplyOutboundHeaders(req, config.Headers) + data, _, err := doBinary(req) + if err != nil { + if message := runningHubUploadAuthFailure(err); message != "" { + return "", errors.New(message) + } + return "", fmt.Errorf("RunningHub 参考素材上传失败:%w", err) + } + var rawResponse map[string]any + if err := json.Unmarshal(data, &rawResponse); err != nil { + return "", errors.New("RunningHub 上传响应不是有效 JSON") + } + code, validCode := runningHubPayloadCode(rawResponse) + if !validCode { + // 上传接口有少量代理只返回 data.fileName;文件名存在即可视为成功。 + validCode = runningHubFileName(rawResponse) != "" + code = 0 + } + if !validCode || code != 0 { + return "", fmt.Errorf("RunningHub 上传素材失败:%s", runningHubFailureMessage(rawResponse)) + } + fileName := runningHubFileName(rawResponse) + if fileName == "" { + return "", fmt.Errorf("RunningHub 上传素材失败:%s", runningHubFailureMessage(rawResponse)) + } + return fileName, nil +} + +func runningHubUploadAuthFailure(err error) string { + var httpErr providerHTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusUnauthorized { + return "" + } + if !strings.Contains(strings.ToLower(httpErr.Body), "apikey verification failed") { + return "" + } + return "RunningHub 参考素材上传接口认证失败(HTTP 401):ApiKey verification failed。请确认“素材上传 API Key(企业级)”有效,并且它与 Base URL 属于同一个 RunningHub 站点" +} + +func workflowFieldsFromManagement(workflow map[string]interface{}, mode string) ([]WorkflowField, error) { + rawFields := collectManagementWorkflowFields(workflow, mode) + encoded, err := json.Marshal(rawFields) + if err != nil { + return nil, fmt.Errorf("工作流字段映射编码失败:%w", err) + } + var fields []WorkflowField + if err := json.Unmarshal(encoded, &fields); err != nil { + return nil, fmt.Errorf("工作流字段映射解析失败:%w", err) + } + return fields, nil +} + +func workflowFieldsForMode(fields []WorkflowField, mode string) []WorkflowField { + if len(fields) == 0 { + return fields + } + normalized := make([]WorkflowField, len(fields)) + copy(normalized, fields) + for index := range normalized { + field := &normalized[index] + if canonical := workflowNamedDynamicSource(field.Source, mode); canonical != "" { + field.Source = canonical + } + inferred := workflowDynamicSourceForMode(field.FieldName, field.FieldType, mode) + if strings.TrimSpace(field.Source) == "" && !field.sourceConfigured && !field.BindPrompt { + field.Source = inferred + if inferred != "" { + field.SourceAutomatic = ptr(true) + } + continue + } + if shouldRepairLegacyWorkflowSource(field.FieldName, field.Source, inferred, mode) { + field.Source = inferred + } + } + return normalized +} + +func shouldRepairLegacyWorkflowSource(fieldName string, source string, inferred string, mode string) bool { + sourceKey := normalizeManagementFieldName(source) + inferredKey := normalizeManagementFieldName(inferred) + if sourceKey == "" || inferredKey == "" || sourceKey == inferredKey { + return false + } + fieldKey := normalizeManagementFieldName(fieldName) + wasMistakenForMedia := sourceKey == "referenceimage" || sourceKey == "referencevideo" || sourceKey == "referenceaudio" + if wasMistakenForMedia && (workflowDimensionNameSource(fieldKey) != "" || inferredKey == "aspectratio" || inferredKey == "vquality") { + return true + } + mode = strings.ToLower(strings.TrimSpace(mode)) + return (fieldKey == "resolution" && (mode == "video" && sourceKey == "size" || mode == "image" && sourceKey == "vquality")) || (fieldKey == "quality" && sourceKey == "vquality") +} + +func validateWorkflowMediaInputs(fields []WorkflowField, input canvasGenerationInput) error { + capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0} + hasMaskMapping := false + for _, field := range fields { + if field.Enabled != nil && !*field.Enabled { + continue + } + source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") + if source == "mask" { + hasMaskMapping = true + continue + } + key := "" + switch source { + case "referenceimage", "image", "referenceimages": + key = "referenceimage" + case "referencevideo", "video", "referencevideos": + key = "referencevideo" + case "referenceaudio", "audio", "referenceaudios": + key = "referenceaudio" + } + if key == "" { + continue + } + index := field.SourceIndex + if field.ImageOrder > 0 { + index = field.ImageOrder - 1 + } + if index < 0 { + index = 0 + } + if index+1 > capacities[key] { + capacities[key] = index + 1 + } + } + checks := []struct { + label string + count int + capacity int + }{ + {label: "参考图片", count: len(input.ReferenceImages), capacity: capacities["referenceimage"]}, + {label: "参考视频", count: len(input.ReferenceVideos), capacity: capacities["referencevideo"]}, + {label: "参考音频", count: len(input.ReferenceAudios), capacity: capacities["referenceaudio"]}, + } + for _, check := range checks { + if check.count > check.capacity { + return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在工作流字段映射中补齐槽位", check.capacity, check.label, check.count) + } + } + if input.Mask != nil && !hasMaskMapping { + return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射") + } + return nil +} + +func runningHubNodeInfo(fields []WorkflowField, files map[string]string, input canvasGenerationInput) ([]map[string]any, error) { + return runningHubNodeInfoWithWorkflow(fields, files, input, nil) +} + +func runningHubNodeInfoWithWorkflow(fields []WorkflowField, files map[string]string, input canvasGenerationInput, workflow map[string]interface{}) ([]map[string]any, error) { + items := make([]map[string]any, 0, len(fields)+3) + for _, field := range fields { + if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { + continue + } + // RunningHub 的 nodeInfoList 只接受字符串 fieldValue。部分内部节点不会把 + // 字符串恢复为数值,因此安全边界必须由服务端强制执行,不能依赖前端隐藏。 + if isRunningHubUnsafeInternalField(workflow, field) { + continue + } + value, present, err := resolveWorkflowFieldValue(field, files, input) + if err != nil { + return nil, err + } + if !present { + if field.Required { + return nil, fmt.Errorf("工作流字段 %s 缺少值", firstNonEmptyString(field.ID, field.FieldName)) + } + continue + } + if err := validateRunningHubWorkflowFieldValue(field, value); err != nil { + return nil, err + } + encoded := workflowScalarString(value) + if !shouldSendRunningHubWorkflowField(workflow, field, encoded) { + continue + } + items = append(items, map[string]any{"nodeId": field.NodeID, "fieldName": field.FieldName, "fieldValue": encoded}) + } + return items, nil +} + +func shouldSendRunningHubWorkflowField(workflow map[string]interface{}, field WorkflowField, encoded string) bool { + if len(workflow) == 0 { + // AI App 只有公开参数列表,没有可用于比较的工作流 JSON。 + return true + } + node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}) + if !ok { + return true + } + inputs, ok := node["inputs"].(map[string]interface{}) + if !ok { + return true + } + original, exists := inputs[strings.TrimSpace(field.FieldName)] + if exists && isWorkflowLinkValue(original) { + // 已连接输入属于工作流拓扑,不能被旧字段映射拆成字符串覆盖。 + return false + } + if normalizeWorkflowFieldSource(field) != "" || field.RandomEnabled { + return !isRunningHubAutomaticInternalBinding(node, field) + } + if !exists { + return true + } + // 拉取参数时会保存所有 widget 的默认值;未修改项无需重复覆盖工作流。 + return encoded != workflowScalarString(original) +} + +func isRunningHubAutomaticInternalBinding(node map[string]interface{}, field WorkflowField) bool { + if field.SourceAutomatic != nil && !*field.SourceAutomatic { + return false + } + classType := strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) + if classType != "imageresize+" { + return false + } + // 旧配置没有 sourceAutomatic,但 ImageResize+ 的宽高来源是按同名字段自动误判的。 + // 保留节点自身尺寸链;用户仍可将来源设为默认并明确修改静态值。 + source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") + return source == "size" || source == "imagesize" || source == "sizewidth" || source == "width" || source == "imagewidth" || source == "videowidth" || source == "sizeheight" || source == "height" || source == "imageheight" || source == "videoheight" +} + +func isRunningHubUnsafeInternalField(workflow map[string]interface{}, field WorkflowField) bool { + if field.SafeToOverride != nil && !*field.SafeToOverride { + return true + } + classType := strings.ToLower(strings.TrimSpace(field.ClassType)) + if node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}); ok { + classType = strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) + } + fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) + if classType == "int" && fieldName == "value" { + return true + } + return classType == "imageresize+" && (fieldName == "width" || fieldName == "height" || fieldName == "multiple_of") +} + +func validateRunningHubWorkflowFieldValue(field WorkflowField, value interface{}) error { + fieldID := firstNonEmptyString(strings.TrimSpace(field.ID), strings.TrimSpace(field.NodeID)+"."+strings.TrimSpace(field.FieldName)) + fieldType := strings.ToUpper(strings.TrimSpace(field.FieldType)) + if fieldType == "NUMBER" || fieldType == "FLOAT" || fieldType == "INTEGER" || fieldType == "INT" || fieldType == "SLIDER" { + numeric, ok := workflowNumericBound(value) + if !ok { + return fmt.Errorf("工作流字段 %s 不是有效数字", fieldID) + } + minValue, hasMin := workflowNumericBound(field.Min) + maxValue, hasMax := workflowNumericBound(field.Max) + stepValue, hasStep := workflowNumericBound(field.Step) + if hasMin && hasMax && minValue > maxValue { + return fmt.Errorf("工作流字段 %s 的最小值不能大于最大值", fieldID) + } + if hasStep && stepValue <= 0 { + return fmt.Errorf("工作流字段 %s 的步长必须大于 0", fieldID) + } + if (hasMin && numeric < minValue) || (hasMax && numeric > maxValue) { + return fmt.Errorf("工作流字段 %s 的值超出允许范围", fieldID) + } + if hasStep { + start := float64(0) + if hasMin { + start = minValue + } + steps := (numeric - start) / stepValue + if math.Abs(steps-math.Round(steps)) > 1e-7 { + return fmt.Errorf("工作流字段 %s 的值不符合步长", fieldID) + } + } + } + if fieldType == "BOOLEAN" || fieldType == "BOOL" { + switch item := value.(type) { + case bool: + case string: + normalized := strings.ToLower(strings.TrimSpace(item)) + if normalized != "true" && normalized != "false" { + return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) + } + default: + return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) + } + } + if options := workflowFieldAllowedOptions(field); len(options) > 0 { + encoded := strings.TrimSpace(workflowScalarString(value)) + matched := false + hasScalarOption := false + for _, option := range options { + if workflowOptionIsRange(option) { + continue + } + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate == "" || candidate == "" { + continue + } + hasScalarOption = true + if candidate == encoded { + matched = true + break + } + } + if hasScalarOption && !matched { + return fmt.Errorf("工作流字段 %s 的值不在允许选项中", fieldID) + } + } + return nil +} + +func workflowOptionIsRange(value interface{}) bool { + object, ok := value.(map[string]interface{}) + if !ok { + return false + } + for _, key := range []string{"min", "max", "step", "minValue", "maxValue", "stepValue"} { + if _, exists := object[key]; exists { + return true + } + } + if nested, ok := object["range"].(map[string]interface{}); ok { + return workflowOptionIsRange(nested) + } + return false +} + +func resolveWorkflowFieldValue(field WorkflowField, files map[string]string, input canvasGenerationInput) (interface{}, bool, error) { + source := normalizeWorkflowFieldSource(field) + // 画布参数按 nodeId + fieldName 独立保存时 source 可能为空;枚举字段仍必须 + // 使用工作流原始 options 的完整值,不能把用户界面上的比例简称直接发给 ComfyUI。 + if source == "" && strings.EqualFold(strings.TrimSpace(field.FieldName), "aspect_ratio") { + // 旧版前端只把比例写入 Config.Size,没有同步改写字段映射。此时不能 + // 直接使用工作流保存的默认值,否则用户选择 9:16 会在提交前静默回到 1:1。 + // 仅对非 1:1 的显式比例做回填,避免没有动态参数时覆盖工作流自己的 1:1 默认值。 + requested := strings.TrimSpace(input.Config.Size) + if requested != "" && !strings.EqualFold(workflowAspectRatio(requested), "1:1") { + value := workflowAspectRatioValue(field, requested) + if strings.TrimSpace(fmt.Sprint(value)) != "" { + return value, true, nil + } + } + raw := strings.TrimSpace(workflowOptionString(firstNonNilWorkflowValue(field.FieldValue, field.Value))) + if raw != "" { + value := workflowAspectRatioValue(field, raw) + return value, true, nil + } + } + if source != "" { + switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { + case "prompt", "text", "positiveprompt", "positive": + return input.Prompt, strings.TrimSpace(input.Prompt) != "", nil + case "size", "imagesize": + return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil + case "resolution": + if strings.EqualFold(strings.TrimSpace(input.Mode), "video") { + value := workflowVideoResolutionValue(field, input.Config.VQuality) + return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil + } + return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil + case "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio": + value := workflowAspectRatioValue(field, input.Config.Size) + return value, value != "", nil + case "sizewidth", "width", "imagewidth", "videowidth": + value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 0) + return value, value != "", nil + case "sizeheight", "height", "imageheight", "videoheight": + value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 1) + return value, value != "", nil + case "quality": + return input.Config.Quality, strings.TrimSpace(input.Config.Quality) != "", nil + case "count", "batch", "batchsize": + return input.Config.Count, strings.TrimSpace(input.Config.Count) != "", nil + case "videoseconds", "video_seconds", "duration": + value := workflowVideoDurationValue(field, input.Config.VideoSeconds) + return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil + case "vquality", "videoquality", "video_quality": + value := workflowVideoResolutionValue(field, input.Config.VQuality) + return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil + case "videogenerateaudio", "video_generate_audio", "generateaudio": + return input.Config.VideoGenerateAudio, strings.TrimSpace(input.Config.VideoGenerateAudio) != "", nil + case "videowatermark", "video_watermark", "watermark": + return input.Config.VideoWatermark, strings.TrimSpace(input.Config.VideoWatermark) != "", nil + case "audioformat", "audio_format": + return input.Config.AudioFormat, strings.TrimSpace(input.Config.AudioFormat) != "", nil + case "systemprompt", "system_prompt": + return input.Config.SystemPrompt, strings.TrimSpace(input.Config.SystemPrompt) != "", nil + case "transparentbackground", "transparent_background": + return input.Config.TransparentBackground, strings.TrimSpace(input.Config.TransparentBackground) != "", nil + case "audiovoice", "audio_voice", "voice": + return input.Config.AudioVoice, strings.TrimSpace(input.Config.AudioVoice) != "", nil + case "audiospeed", "audio_speed": + return input.Config.AudioSpeed, strings.TrimSpace(input.Config.AudioSpeed) != "", nil + case "audioinstructions", "audio_instructions": + return input.Config.AudioInstructions, strings.TrimSpace(input.Config.AudioInstructions) != "", nil + } + index := field.SourceIndex + if field.ImageOrder > 0 { + index = field.ImageOrder - 1 + } + var media providerMedia + switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { + case "referenceimage", "image", "referenceimages": + if index < 0 || index >= len(input.ReferenceImages) { + return nil, false, nil + } + media = input.ReferenceImages[index] + case "referencevideo", "video", "referencevideos": + if index < 0 || index >= len(input.ReferenceVideos) { + return nil, false, nil + } + media = input.ReferenceVideos[index] + case "referenceaudio", "audio", "referenceaudios": + if index < 0 || index >= len(input.ReferenceAudios) { + return nil, false, nil + } + media = input.ReferenceAudios[index] + case "mask": + if input.Mask == nil { + return nil, false, nil + } + media = *input.Mask + default: + return nil, false, fmt.Errorf("不支持的工作流字段来源:%s", field.Source) + } + name := files[media.ID] + if name == "" { + return nil, false, errors.New("工作流参考素材尚未上传") + } + return name, true, nil + } + if field.RandomEnabled { + randomMax := field.Max + if isRunningHubInterface(input.Config.InterfaceType) && isWorkflowSeedField(field.FieldName) { + // RunningHub 的随机 Seed 按 uint32 传输,不能沿用 JavaScript 安全整数上限。 + const runningHubSeedMax int64 = 1<<32 - 1 + maxValue, err := workflowIntegerBound(field.Max, runningHubSeedMax) + if err != nil { + return nil, false, err + } + if maxValue > runningHubSeedMax { + maxValue = runningHubSeedMax + } + randomMax = maxValue + } + value, err := randomWorkflowInteger(field.Min, randomMax) + return value, err == nil, err + } + if field.FieldValue != nil { + return field.FieldValue, true, nil + } + if field.Value != nil { + return field.Value, true, nil + } + return nil, false, nil +} + +func firstNonNilWorkflowValue(values ...interface{}) interface{} { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func normalizeWorkflowFieldSource(field WorkflowField) string { + source := strings.ToLower(strings.TrimSpace(field.Source)) + if source == "" && field.BindPrompt { + return "prompt" + } + if source != "" { + return source + } + if !field.SourceFromUpstream { + return "" + } + fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) + if fieldName == "prompt" || fieldName == "text" || fieldName == "positive_prompt" || fieldName == "positiveprompt" { + return "prompt" + } + switch strings.ToLower(strings.TrimSpace(field.FieldType)) { + case "image", "img", "photo", "picture": + return "referenceimage" + case "video", "movie": + return "referencevideo" + case "audio", "sound", "music", "voice": + return "referenceaudio" + default: + return "" + } +} + +func workflowDimensionPart(mode string, size string, videoQuality string, index int) string { + if index < 0 || index > 1 { + return "" + } + if dimensions, ok := workflowPixelDimensions(size); ok { + return strconv.Itoa(dimensions[index]) + } + if strings.EqualFold(strings.TrimSpace(mode), "video") { + dimensions, ok := workflowVideoDimensions(size, videoQuality) + if !ok { + return "" + } + return strconv.Itoa(dimensions[index]) + } + dimensions, ok := workflowImageDimensions(size) + if !ok { + return "" + } + return strconv.Itoa(dimensions[index]) +} + +func workflowPixelDimensions(value string) ([2]int, bool) { + var result [2]int + normalized := strings.ToLower(strings.TrimSpace(value)) + if !strings.Contains(normalized, "x") { + return result, false + } + parts := strings.FieldsFunc(normalized, func(r rune) bool { return r == 'x' || r == ' ' || r == ',' }) + if len(parts) != 2 { + return result, false + } + for index, part := range parts { + parsed, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || parsed <= 0 { + return result, false + } + result[index] = parsed + } + return result, true +} + +func workflowAspectRatio(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + // RunningHub 的 ResolutionSelector 选项带有展示文案(例如 + // "9:16 (Portrait Widescreen)"),协议比较只需要比例前缀。 + if cut := strings.IndexAny(normalized, " ("); cut >= 0 { + normalized = strings.TrimSpace(normalized[:cut]) + } + for _, suffix := range []string{"-1k", "-2k", "-4k"} { + if strings.HasSuffix(normalized, suffix) { + normalized = strings.TrimSuffix(normalized, suffix) + break + } + } + if width, height, ok := workflowRatioParts(normalized); ok { + divisor := workflowGreatestCommonDivisor(width, height) + return fmt.Sprintf("%d:%d", width/divisor, height/divisor) + } + dimensions, ok := workflowPixelDimensions(normalized) + if !ok { + return "" + } + ratio := float64(dimensions[0]) / float64(dimensions[1]) + known := [][2]int{{1, 1}, {3, 2}, {2, 3}, {4, 3}, {3, 4}, {4, 5}, {5, 4}, {16, 9}, {9, 16}, {2, 1}, {1, 2}, {21, 9}} + bestDifference := math.MaxFloat64 + best := [2]int{} + for _, candidate := range known { + expected := float64(candidate[0]) / float64(candidate[1]) + difference := math.Abs(ratio-expected) / expected + if difference < bestDifference { + bestDifference = difference + best = candidate + } + } + if bestDifference <= 0.03 { + return fmt.Sprintf("%d:%d", best[0], best[1]) + } + divisor := workflowGreatestCommonDivisor(dimensions[0], dimensions[1]) + return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor) +} + +func workflowAspectRatioValue(field WorkflowField, value string) string { + raw := strings.TrimSpace(value) + if options := workflowFieldAllowedOptions(field); len(options) > 0 { + requested := workflowAspectRatio(raw) + for _, option := range options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && strings.EqualFold(workflowAspectRatio(candidate), requested) { + return candidate + } + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" { + return fallback + } + return raw + } + // “auto/adaptive” 是工作流的真实模式,不应被画布当前像素尺寸反推成 + // 一个并不存在的比例;宽高字段仍按工作流自身的默认值或显式尺寸处理。 + if fallback := strings.TrimSpace(workflowFieldConfiguredDefault(field)); strings.EqualFold(fallback, "auto") || strings.EqualFold(fallback, "adaptive") { + return fallback + } + return workflowAspectRatio(raw) +} + +func workflowFieldAllowedOptions(field WorkflowField) []interface{} { + classType := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.ClassType), "_", ""), "-", "")) + fieldName := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.FieldName), "_", ""), "-", "")) + if classType == "resolutionselector" && fieldName == "aspectratio" { + // RunningHub 的工作流 API 不返回 object_info;ResolutionSelector 的完整枚举 + // 是节点协议的一部分,通用比例预设(9:16 等)不能直接提交给该节点。 + return []interface{}{ + "1:1 (Square)", + "2:3 (Portrait Photo)", + "3:2 (Photo)", + "3:4 (Portrait Standard)", + "4:3 (Standard)", + "9:16 (Portrait Widescreen)", + "16:9 (Widescreen)", + "21:9 (Ultrawide)", + } + } + return field.Options +} + +func workflowRatioParts(value string) (int, int, bool) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, 0, false + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0 +} + +func workflowGreatestCommonDivisor(left int, right int) int { + for right != 0 { + left, right = right, left%right + } + if left <= 0 { + return 1 + } + return left +} + +func workflowImageDimensions(value string) ([2]int, bool) { + normalized := strings.ToLower(strings.TrimSpace(value)) + preset := map[string][2]int{ + "1:1": {1024, 1024}, "3:2": {1536, 1024}, "2:3": {1024, 1536}, + "4:3": {1360, 1024}, "3:4": {1024, 1360}, "4:5": {1024, 1280}, "5:4": {1280, 1024}, + "16:9": {1824, 1024}, "9:16": {1024, 1824}, "2:1": {2048, 1024}, "1:2": {1024, 2048}, + "21:9": {2352, 1008}, "1:1-2k": {2048, 2048}, "16:9-2k": {2048, 1152}, + "9:16-2k": {1152, 2048}, "16:9-4k": {3840, 2160}, "9:16-4k": {2160, 3840}, + } + if dimensions, ok := preset[normalized]; ok { + return dimensions, true + } + ratio := workflowAspectRatio(normalized) + widthRatio, heightRatio, ok := workflowRatioParts(ratio) + if !ok { + return [2]int{}, false + } + tier := "1k" + if strings.HasSuffix(normalized, "-2k") { + tier = "2k" + } else if strings.HasSuffix(normalized, "-4k") { + tier = "4k" + } + if tier == "1k" { + if widthRatio >= heightRatio { + return [2]int{workflowRoundToStep(1024*float64(widthRatio)/float64(heightRatio), 16), 1024}, true + } + return [2]int{1024, workflowRoundToStep(1024*float64(heightRatio)/float64(widthRatio), 16)}, true + } + longEdge := 2048 + if tier == "4k" { + longEdge = 3840 + } + if widthRatio >= heightRatio { + return [2]int{longEdge, workflowRoundToStep(float64(longEdge)*float64(heightRatio)/float64(widthRatio), 16)}, true + } + return [2]int{workflowRoundToStep(float64(longEdge)*float64(widthRatio)/float64(heightRatio), 16), longEdge}, true +} + +func workflowVideoDimensions(size string, quality string) ([2]int, bool) { + ratio := workflowAspectRatio(size) + widthRatio, heightRatio, ok := workflowRatioParts(ratio) + shortEdge := workflowVideoResolutionPixels(quality) + if !ok || shortEdge <= 0 { + return [2]int{}, false + } + if widthRatio >= heightRatio { + return [2]int{workflowRoundToStep(float64(shortEdge)*float64(widthRatio)/float64(heightRatio), 2), shortEdge}, true + } + return [2]int{shortEdge, workflowRoundToStep(float64(shortEdge)*float64(heightRatio)/float64(widthRatio), 2)}, true +} + +func workflowRoundToStep(value float64, step int) int { + if step <= 1 { + return int(math.Round(value)) + } + return int(math.Round(value/float64(step))) * step +} + +func workflowVideoResolutionPixels(value string) int { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "low": + return 480 + case "auto", "default", "medium", "high": + return 720 + case "2k": + return 1440 + case "4k": + return 2160 + } + parsed, err := strconv.Atoi(strings.TrimSuffix(normalized, "p")) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} + +func workflowVideoResolutionValue(field WorkflowField, value string) interface{} { + raw := strings.TrimSpace(value) + if raw == "" { + return "" + } + if len(field.Options) > 0 { + requested := normalizeWorkflowResolutionToken(raw) + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowResolutionToken(candidate) == requested { + // nodeInfoList 的 fieldValue 是字符串;对象选项只取其 value/id 等标量。 + return candidate + } + } + // 工作流明确声明了可选项但画布值不在其中时,保留工作流默认值, + // 不再把 2K/4K/720 等普通模型别名硬塞给工作流。 + if fallback := workflowFieldConfiguredDefault(field); fallback != "" { + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowResolutionToken(candidate) == normalizeWorkflowResolutionToken(fallback) { + return candidate + } + } + return fallback + } + return raw + } + if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { + return numeric + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { + return fallback + } + return raw +} + +func workflowVideoDurationValue(field WorkflowField, value string) interface{} { + raw := strings.TrimSpace(value) + if raw == "" { + return "" + } + if len(field.Options) > 0 { + requested := normalizeWorkflowDurationToken(raw) + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowDurationToken(candidate) == requested { + return candidate + } + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" { + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowDurationToken(candidate) == normalizeWorkflowDurationToken(fallback) { + return candidate + } + } + return fallback + } + return raw + } + if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { + return numeric + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { + return fallback + } + return raw +} + +func normalizeWorkflowDurationToken(value string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "s"), "秒"))) +} + +func workflowFieldConfiguredDefault(field WorkflowField) string { + value := field.FieldValue + if value == nil { + value = field.Value + } + if value == nil { + return "" + } + return strings.TrimSpace(workflowOptionString(value)) +} + +func normalizeWorkflowResolutionToken(value string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P"))) +} + +func workflowNumericValue(value string) *float64 { + parsed, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P")), 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return nil + } + return &parsed +} + +func workflowResolutionShapeCompatible(defaultValue string, requested string) bool { + defaultNumeric := workflowNumericValue(defaultValue) + requestedNumeric := workflowNumericValue(requested) + if defaultNumeric != nil && requestedNumeric != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(defaultValue), strings.TrimSpace(requested)) +} + +func workflowNumericResolutionValue(field WorkflowField, raw string) interface{} { + parsed := workflowNumericValue(raw) + if parsed == nil { + return nil + } + min, minOK := workflowNumericBound(field.Min) + max, maxOK := workflowNumericBound(field.Max) + step, stepOK := workflowNumericBound(field.Step) + if !minOK && !maxOK && !stepOK && !strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") { + return nil + } + value := *parsed + if minOK && value < min { + value = min + } + if maxOK && value > max { + value = max + } + if stepOK && step > 0 { + anchor := min + if !minOK { + anchor = 0 + } + value = anchor + math.Round((value-anchor)/step)*step + } + if strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") && math.Trunc(value) == value { + return int64(value) + } + return value +} + +func workflowNumericBound(value interface{}) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(value)), 64) + return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) +} + +func workflowOptionString(value interface{}) string { + if object, ok := value.(map[string]interface{}); ok { + for _, key := range []string{"value", "id", "key", "label", "name"} { + if candidate := strings.TrimSpace(fmt.Sprint(object[key])); candidate != "" && candidate != "" { + return candidate + } + } + } + return fmt.Sprint(value) +} + +func runningHubPromptFallback(workflow map[string]interface{}, prompt string) []map[string]any { + if strings.TrimSpace(prompt) == "" || len(workflow) == 0 { + return nil + } + bestNodeID := "" + bestFieldName := "" + bestScore := -1000 + nodeIDs := make([]string, 0, len(workflow)) + for nodeID := range workflow { + nodeIDs = append(nodeIDs, nodeID) + } + sort.Slice(nodeIDs, func(i, j int) bool { return managementNodeIDLess(nodeIDs[i], nodeIDs[j]) }) + for _, nodeID := range nodeIDs { + node, ok := workflow[nodeID].(map[string]interface{}) + if !ok { + continue + } + inputs, ok := node["inputs"].(map[string]interface{}) + if !ok { + continue + } + fieldNames := make([]string, 0, len(inputs)) + for fieldName := range inputs { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + classType := strings.ToLower(strings.TrimSpace(fmt.Sprint(node["class_type"]))) + metaTitle := strings.ToLower(strings.TrimSpace(fmt.Sprint(workflowNodeMetaTitle(node)))) + for _, fieldName := range fieldNames { + if !isWorkflowPromptCandidate(fieldName, classType, metaTitle) { + continue + } + if isWorkflowLinkValue(inputs[fieldName]) { + continue + } + score := workflowPromptCandidateScore(fieldName, classType, metaTitle) + if bestNodeID == "" || score > bestScore { + bestNodeID = nodeID + bestFieldName = fieldName + bestScore = score + } + } + } + if bestNodeID != "" { + return []map[string]any{{"nodeId": bestNodeID, "fieldName": bestFieldName, "fieldValue": prompt}} + } + return nil +} + +func isWorkflowMediaSource(source string) bool { + normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(source)), "_", ""), "-", "") + switch normalized { + case "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask": + return true + default: + return false + } +} + +func workflowPromptCandidateScore(fieldName string, classType string, metaTitle string) int { + descriptor := strings.ToLower(strings.TrimSpace(fieldName + " " + metaTitle)) + score := 0 + for _, marker := range []string{"negative", "neg prompt", "负面", "反向", "负向"} { + if strings.Contains(descriptor, marker) { + score -= 100 + break + } + } + for _, marker := range []string{"positive", "正面", "正向"} { + if strings.Contains(descriptor, marker) { + score += 20 + break + } + } + if strings.Contains(classType, "cliptextencode") { + score += 5 + } + if isWorkflowPromptFieldName(fieldName) { + score += 2 + } + return score +} + +func randomWorkflowInteger(rawMin interface{}, rawMax interface{}) (int64, error) { + const defaultMax int64 = 9007199254740991 + minValue, err := workflowIntegerBound(rawMin, 0) + if err != nil { + return 0, err + } + maxValue, err := workflowIntegerBound(rawMax, defaultMax) + if err != nil { + return 0, err + } + if maxValue < minValue { + return 0, errors.New("工作流随机值最大值不能小于最小值") + } + rangeSize := new(big.Int).Sub(big.NewInt(maxValue), big.NewInt(minValue)) + rangeSize.Add(rangeSize, big.NewInt(1)) + offset, err := cryptorand.Int(cryptorand.Reader, rangeSize) + if err != nil { + return 0, fmt.Errorf("生成工作流随机值失败:%w", err) + } + return new(big.Int).Add(offset, big.NewInt(minValue)).Int64(), nil +} + +func workflowIntegerBound(value interface{}, fallback int64) (int64, error) { + if value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" { + return fallback, nil + } + parsed, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64) + if err != nil { + return 0, fmt.Errorf("工作流随机值范围不是有效整数:%v", value) + } + return parsed, nil +} + +func workflowFieldsBindPrompt(fields []WorkflowField) bool { + for _, field := range fields { + if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { + continue + } + source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") + switch source { + case "prompt", "text", "positiveprompt", "positive": + return true + } + } + return false +} + +func upsertRunningHubNodeInfo(items []map[string]any, overrides []map[string]any) []map[string]any { + for _, override := range overrides { + nodeID := strings.TrimSpace(stringValue(override["nodeId"])) + fieldName := strings.TrimSpace(stringValue(override["fieldName"])) + replaced := false + for index, item := range items { + if strings.TrimSpace(stringValue(item["nodeId"])) == nodeID && strings.TrimSpace(stringValue(item["fieldName"])) == fieldName { + items[index] = override + replaced = true + break + } + } + if !replaced { + items = append(items, override) + } + } + return items +} + +func isWorkflowLinkValue(value interface{}) bool { + items, ok := value.([]interface{}) + return ok && len(items) == 2 && fmt.Sprint(items[0]) != "" && isIntegerLike(items[1]) +} + +func isIntegerLike(value interface{}) bool { + switch item := value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case float64: + return item == float64(int(item)) + case json.Number: + _, err := strconv.Atoi(string(item)) + return err == nil + default: + return false + } +} + +func isWorkflowPromptFieldName(value string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) + switch normalized { + case "text", "prompt", "positiveprompt", "positive", "caption", "description": + return true + default: + return false + } +} + +func isWorkflowPromptCandidate(fieldName string, classType string, metaTitle string) bool { + if isWorkflowPromptFieldName(fieldName) { + return true + } + normalizedField := strings.ToLower(strings.TrimSpace(fieldName)) + if normalizedField != "value" { + return false + } + return strings.Contains(classType, "text") || strings.Contains(classType, "string") || strings.Contains(classType, "prompt") || strings.Contains(metaTitle, "text") || strings.Contains(metaTitle, "prompt") || strings.Contains(metaTitle, "提示词") || strings.Contains(metaTitle, "文本") +} + +func workflowNodeMetaTitle(node map[string]interface{}) string { + meta, _ := node["_meta"].(map[string]interface{}) + return fmt.Sprint(meta["title"]) +} + +func workflowScalarString(value interface{}) string { + switch item := value.(type) { + case string: + return item + case nil: + return "" + default: + encoded, err := json.Marshal(item) + if err == nil { + return string(encoded) + } + return fmt.Sprint(item) + } +} + +func (s *Service) runningHubJSON(ctx context.Context, config providerConfig, endpoint string, body interface{}, target *map[string]any) error { + encoded, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + ApplyOutboundHeaders(req, config.Headers) + data, _, err := doBinary(req) + if err != nil { + return err + } + if err := json.Unmarshal(data, target); err != nil { + return err + } + return nil +} + +func (s *Service) pollRunningHubWorkflow(ctx context.Context, config providerConfig, root string, taskID string) (map[string]interface{}, error) { + for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { + var response map[string]any + err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response) + if err != nil { + return nil, fmt.Errorf("RunningHub 查询任务失败:%w", err) + } + code, validCode := runningHubPayloadCode(response) + if !validCode { + // 成功响应有时只有 outputs/results,没有显式状态码。 + validCode = len(runningHubOutputURLs(response["data"])) > 0 + code = 0 + } + if !validCode { + return nil, errors.New("RunningHub 查询响应缺少可识别状态") + } + if code == 0 { + urls := runningHubOutputURLs(response["data"]) + if len(urls) == 0 { + return nil, errors.New("RunningHub 任务成功但没有返回产物") + } + for index, rawURL := range urls { + urls[index] = resolveRunningHubOutputURL(root, rawURL) + } + _ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil) + return s.downloadWorkflowOutputs(ctx, urls) + } + if code == 805 || code == 806 { + return nil, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response)) + } + stage := "running" + if code == 813 { + stage = "queued" + } else if code != 804 { + // RunningHub 会扩展暂态码;只对明确失败码结束任务,其余状态继续轮询到终态或超时。 + stage = "pending" + } + _ = s.updateWorkflowProviderState(ctx, taskID, stage, ptr(time.Now().Add(2500*time.Millisecond))) + if err := sleepContext(ctx, 2500*time.Millisecond); err != nil { + return nil, err + } + } + return nil, fmt.Errorf("RunningHub 任务超时(%s)", taskID) +} + +// RunningHub 工作流的管理、提交和轮询固定使用积分 API Key;企业级 Key 仅由上传接口单独读取。 +func runningHubAPIKey(config providerConfig) string { + return strings.TrimSpace(config.APIKey) +} + +func (s *Service) downloadWorkflowOutputs(ctx context.Context, urls []string) (map[string]interface{}, error) { + images := make([]map[string]interface{}, 0) + var video, audio map[string]interface{} + for _, rawURL := range urls { + if strings.HasPrefix(rawURL, "data:") { + mimeType, data, err := decodeProviderDataURL(rawURL) + if err != nil { + return nil, err + } + if item := workflowOutputValue(mimeType, data); item != nil { + switch value := item.(type) { + case map[string]interface{}: + if strings.HasPrefix(mimeType, "image/") { + images = append(images, value) + } else if strings.HasPrefix(mimeType, "video/") { + video = value + } else if strings.HasPrefix(mimeType, "audio/") { + audio = value + } + } + } + continue + } + if !isPublicMediaURL(rawURL) { + continue + } + data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL) + if err != nil { + return nil, fmt.Errorf("下载 RunningHub 产物失败:%w", err) + } + mimeType = runningHubOutputMimeType(rawURL, mimeType) + item := workflowOutputValue(mimeType, data) + if item == nil { + continue + } + value := item.(map[string]interface{}) + switch { + case strings.HasPrefix(mimeType, "image/"): + images = append(images, value) + case strings.HasPrefix(mimeType, "video/"): + video = value + case strings.HasPrefix(mimeType, "audio/"): + audio = value + } + } + result := map[string]interface{}{"mode": "image"} + if len(images) > 0 { + result["images"] = images + } + if video != nil { + result["mode"] = "video" + result["video"] = video + } + if audio != nil { + result["mode"] = "audio" + result["audio"] = audio + } + if len(images) == 0 && video == nil && audio == nil { + return nil, errors.New("RunningHub 返回的产物类型不受支持") + } + return result, nil +} + +func runningHubOutputMimeType(rawURL string, declared string) string { + declared = strings.TrimSpace(strings.Split(declared, ";")[0]) + if declared != "" && declared != "application/octet-stream" { + return declared + } + pathValue := rawURL + if parsed, err := url.Parse(rawURL); err == nil && parsed.Path != "" { + pathValue = parsed.Path + } + pathValue = strings.ToLower(strings.Split(pathValue, "?")[0]) + for suffix, mimeType := range map[string]string{ + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", + ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/mp4", + ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac", + } { + if strings.HasSuffix(pathValue, suffix) { + return mimeType + } + } + return declared +} + +func workflowOutputValue(mimeType string, data []byte) interface{} { + mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) + if mimeType == "" { + mimeType = "application/octet-stream" + } + if len(data) == 0 { + return nil + } + return map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "bytes": len(data)} +} + +func runningHubRootURL(value string) string { + base := strings.TrimRight(strings.TrimSpace(value), "/") + if base == "" { + base = "https://www.runninghub.cn" + } + // 设置页可能保存根地址、/openapi/v2 或带尾部斜杠的任一形式;任务 + // OpenAPI 使用根地址下的 /task/openapi/*,统一剥掉 API 前缀。 + lower := strings.ToLower(base) + for _, suffix := range []string{"/openapi/v2", "/openapi"} { + if strings.HasSuffix(lower, suffix) { + base = base[:len(base)-len(suffix)] + break + } + } + return strings.TrimRight(base, "/") +} + +func resolveRunningHubOutputURL(root string, rawURL string) string { + rawURL = rewriteRunningHubOutputHost(strings.TrimSpace(rawURL)) + if rawURL == "" || strings.HasPrefix(rawURL, "data:") || isPublicMediaURL(rawURL) { + return rawURL + } + if strings.HasPrefix(rawURL, "//") { + return "https:" + rawURL + } + if strings.HasPrefix(rawURL, "/") { + return strings.TrimRight(root, "/") + rawURL + } + // API 可能返回 output/foo、assets/foo 这类无前导斜杠的相对路径。 + for _, prefix := range []string{"output/", "assets/", "input/"} { + if strings.HasPrefix(strings.ToLower(rawURL), prefix) { + return strings.TrimRight(root, "/") + "/" + rawURL + } + } + if runningHubRelativeOutputPath(rawURL) { + return strings.TrimRight(root, "/") + "/" + strings.TrimLeft(rawURL, "/") + } + return rawURL +} + +// RunningHub 某些区域会返回旧 COS 域名,参考项目已将其迁移到可访问域名; +// 这里只做固定 host 映射,不接受响应内容提供任意代理目标。 +func rewriteRunningHubOutputHost(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return rawURL + } + if strings.EqualFold(parsed.Host, "rh-images-1252422369.cos.ap-beijing.myqcloud.com") { + parsed.Host = "rh-images.xiaoyaoyou.com" + return parsed.String() + } + return rawURL +} + +func runningHubTaskID(payload map[string]any) string { + for _, keys := range [][]string{{"data", "taskId"}, {"data", "task_id"}, {"data", "taskID"}, {"data", "id"}, {"taskId"}, {"task_id"}, {"taskID"}, {"id"}} { + if len(keys) == 2 { + if value := nestedString(payload, keys[0], keys[1]); value != "" { + return value + } + } else if value := stringValue(payload[keys[0]]); value != "" { + return value + } + } + return "" +} + +func runningHubOutputURLs(value interface{}) []string { + result := make([]string, 0) + var visit func(interface{}) + visit = func(current interface{}) { + switch item := current.(type) { + case string: + if strings.HasPrefix(item, "http://") || strings.HasPrefix(item, "https://") || strings.HasPrefix(item, "data:") || (strings.HasPrefix(item, "/") && !strings.HasPrefix(item, "//")) || runningHubRelativeOutputPath(item) { + result = append(result, item) + } + case []interface{}: + for _, child := range item { + visit(child) + } + case map[string]interface{}: + for key, child := range item { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + if lowerKey == "fileurl" || lowerKey == "file_url" || lowerKey == "url" || lowerKey == "downloadurl" || lowerKey == "download_url" || lowerKey == "src" || lowerKey == "output" || lowerKey == "outputs" || lowerKey == "results" || lowerKey == "files" || lowerKey == "data" || lowerKey == "images" || lowerKey == "videos" || lowerKey == "audio" || lowerKey == "audios" || lowerKey == "result" { + visit(child) + } + } + } + } + visit(value) + seen := map[string]bool{} + deduped := make([]string, 0, len(result)) + for _, item := range result { + if !seen[item] { + seen[item] = true + deduped = append(deduped, item) + } + } + return deduped +} + +func runningHubRelativeOutputPath(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" || strings.Contains(value, "://") || strings.HasPrefix(value, "//") { + return false + } + for _, segment := range strings.Split(strings.Split(value, "?")[0], "/") { + if segment == ".." { + return false + } + } + for _, prefix := range []string{"output/", "assets/", "input/"} { + if strings.HasPrefix(value, prefix) { + return true + } + } + for _, suffix := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg", ".m4a", ".flac"} { + if strings.HasSuffix(strings.Split(value, "?")[0], suffix) { + return true + } + } + return false +} + +func runningHubFailureMessage(payload map[string]any) string { + for _, key := range []string{"msg", "message", "error", "failReason", "failedReason", "errorMessage"} { + if value := stringValue(payload[key]); value != "" { + return runningHubActionableFailureMessage(value) + } + } + if nested, ok := payload["data"].(map[string]interface{}); ok { + return runningHubFailureMessage(nested) + } + return "上游未提供失败原因" +} + +func isWorkflowSeedField(value string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) + return normalized == "seed" || strings.HasSuffix(normalized, "seed") || strings.Contains(normalized, "noiseseed") +} + +func runningHubActionableFailureMessage(message string) string { + normalized := strings.ToLower(message) + if strings.Contains(normalized, "node_info_mismatch") || strings.Contains(normalized, "node_not_found_in_workflow") { + return "RunningHub AI 应用的公开参数已变化,请到“设置 → RunningHub 工作流”重新选择该 App 并点击“拉取参数”后再试(上游详情:" + message + ")" + } + return message +} + +// runningHubPayloadCode 兼容 code/statusCode 以及部分网关返回的字符串状态。 +// 统一映射到 RunningHub 公开状态码:0 成功、804 运行中、813 排队中、805/806 失败。 +func runningHubPayloadCode(payload map[string]any) (int, bool) { + if payload == nil { + return 0, false + } + topCode, topValid := runningHubDirectCode(payload) + if nested, ok := payload["data"].(map[string]interface{}); ok { + if nestedCode, nestedValid := runningHubPayloadCode(nested); nestedValid { + // 有些网关把 HTTP 成功包装成顶层 code=0,同时把真实任务状态放在 + // data.code/status;真实任务状态优先,避免把排队任务误判成完成。 + if !topValid || topCode == 0 || nestedCode != 0 { + return nestedCode, true + } + } + } + return topCode, topValid +} + +func runningHubDirectCode(payload map[string]any) (int, bool) { + primaryCode := 0 + primaryValid := false + for _, key := range []string{"code", "statusCode", "status_code"} { + if value, ok := payload[key]; ok { + if code, valid := runningHubCode(value); valid { + if (key == "statusCode" || key == "status_code") && code >= 200 && code < 300 { + code = 0 + } + primaryCode, primaryValid = code, true + break + } + } + } + for _, key := range []string{"status", "state", "taskStatus", "task_status"} { + if code, valid := runningHubStatusCode(payload[key]); valid { + if !primaryValid || primaryCode == 0 || code != 0 { + return code, true + } + break + } + } + if primaryValid { + return primaryCode, true + } + // errorCode=0 通常只是“无错误”标记,不能覆盖 data.status=running。 + for _, key := range []string{"errorCode", "error_code"} { + if value, ok := payload[key]; ok { + if code, valid := runningHubCode(value); valid && code != 0 { + return code, true + } + } + } + return 0, false +} + +func runningHubStatusCode(value interface{}) (int, bool) { + text := strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) + if text == "" || text == "" { + return 0, false + } + if numeric, ok := runningHubCode(text); ok { + switch numeric { + case 0, 804, 813, 805, 806: + return numeric, true + } + } + text = strings.NewReplacer("_", "", "-", "", " ", "").Replace(text) + switch text { + case "success", "succeeded", "complete", "completed", "done", "finished", "finish", "3": + return 0, true + case "queued", "queue", "pending", "waiting", "created", "submitted": + return 813, true + case "running", "processing", "executing", "inprogress", "started", "working", "1", "2": + return 804, true + case "failed", "failure", "error", "rejected", "cancelled", "canceled", "expired", "aborted", "4", "5": + return 805, true + default: + return 0, false + } +} + +func nestedString(payload map[string]any, first string, second string) string { + nested, _ := payload[first].(map[string]interface{}) + return stringValue(nested[second]) +} + +func runningHubFileName(payload map[string]any) string { + for _, key := range []string{"fileName", "file_name", "filename", "name"} { + if value := stringValue(payload[key]); value != "" { + return value + } + } + for _, container := range []string{"data", "result", "file", "files"} { + if nested, ok := payload[container].(map[string]interface{}); ok { + for _, key := range []string{"fileName", "file_name", "filename", "name"} { + if value := stringValue(nested[key]); value != "" { + return value + } + } + } + } + return "" +} + +func runningHubCode(value interface{}) (int, bool) { + switch item := value.(type) { + case int: + if item < 0 { + return 0, false + } + return item, true + case int64: + if item < 0 { + return 0, false + } + return int(item), true + case float64: + code := int(item) + if item < 0 || float64(code) != item { + return 0, false + } + return code, true + case json.Number, string: + code := mapNumber(item) + return code, code >= 0 + default: + return 0, false + } +} + +func mapNumber(value interface{}) int { + switch item := value.(type) { + case int: + return item + case int64: + return int(item) + case float64: + return int(item) + case json.Number: + parsed, _ := strconv.Atoi(string(item)) + return parsed + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(item)) + return parsed + default: + return -1 + } +} diff --git a/canvas-agent/README.md b/canvas-agent/README.md index 781da0d9f..893b492b3 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -26,6 +26,41 @@ Connect token: xxxxxx 在画布右上角点击 `Agent`,填入地址和 token 后连接。 +## ComfyUI Bridge + +Bridge 让云端后端把工作流请求投递到运行 Bridge 的机器,再由该进程访问 ComfyUI。`--comfy` 可填写本机 `127.0.0.1:8188`、局域网地址或公网 HTTP/HTTPS 地址,只要运行 Bridge 的机器能够访问即可;网页和云端不直接访问该地址。 + +部署镜像会用 Go 标准库把 Bridge 交叉编译成站点根目录下的 Windows x64、Linux x64 和 Linux ARM64 原生程序。它们不捆绑 Node.js/Bun 运行时,运行 Bridge 的机器不需要安装 Node.js、npm 或项目源码;画布“设置 → ComfyUI Bridge”会按平台生成带当前地址、令牌和工作流目录的完整命令。 + +```powershell +$bridgeDir = Join-Path $env:LOCALAPPDATA "OpenAICanvas" +New-Item -ItemType Directory -Force -Path $bridgeDir | Out-Null +$bridgeFile = Join-Path $bridgeDir "OpenAICanvas-ComfyBridge.exe" +Invoke-WebRequest "https://你的画布服务地址/OpenAICanvas-ComfyBridge.exe" -OutFile $bridgeFile +$bridgeStream = [System.IO.File]::OpenRead($bridgeFile) +try { $bridgeHeader0 = $bridgeStream.ReadByte(); $bridgeHeader1 = $bridgeStream.ReadByte() } finally { $bridgeStream.Dispose() } +if ($bridgeHeader0 -ne 0x4D -or $bridgeHeader1 -ne 0x5A) { throw "Bridge 下载失败:服务器未返回 Windows 可执行程序,请联系管理员重新部署 Bridge" } +& $bridgeFile --server "https://你的画布服务地址" --token "你的 Bridge Token" --comfy "http://127.0.0.1:8188" --workflow-dir "D:\\ComfyUI\\workflows" +``` + +Linux x64 云服务器(ARM64 服务器请把文件名中的 `amd64` 改为 `arm64`): + +```bash +bridge_dir="./openai-canvas-bridge" +mkdir -p "$bridge_dir" +curl --fail --location "https://你的画布服务地址/OpenAICanvas-ComfyBridge-linux-amd64" --output "$bridge_dir/OpenAICanvas-ComfyBridge-linux-amd64" +chmod +x "$bridge_dir/OpenAICanvas-ComfyBridge-linux-amd64" +"$bridge_dir/OpenAICanvas-ComfyBridge-linux-amd64" --server "https://你的画布服务地址" --token "你的 Bridge Token" --comfy "http://127.0.0.1:8188" --workflow-dir "/opt/ComfyUI/user/default/workflows" +``` + +如果 ComfyUI 和 Bridge 都在云端 Linux,`--comfy` 建议使用 `http://127.0.0.1:8188`,不要把 ComfyUI 的 8188 端口暴露给公网。生产环境可将上面的 Bridge 命令配置为 systemd 服务,确保云服务器重启后自动恢复;Bridge Token 应通过受限的环境文件或服务管理器注入,不要写进公开脚本。 + +工作流可以在“设置 → ComfyUI Bridge”中选择 Bridge 发现的 API JSON、粘贴 ComfyUI API 格式 JSON,也可以只填写 `workflowId`,让 Bridge 从 `--workflow-dir` 下读取同名 `.json` 文件。Bridge 会分析本机工作流,并把可配置字段回传给可视化映射面板;面板可设置任务提示词、固定文本、参考图片/视频/音频顺序、蒙版、尺寸、宽高、数量、质量、视频时长、音频参数、随机 Seed 以及必选/可选规则。未保存字段映射时,Bridge 也会在执行前按同一规则分析工作流;正向 Prompt 优先于负向文本节点。参考素材会先上传到本机 ComfyUI,多余素材或缺失的必选槽位会直接报错,可选媒体缺失时不会继续使用工作流模板中的旧文件名。映射同时兼容原项目配置里的 `node`、`input`、`default` 和 `bind_prompt` 字段。RunningHub 工作流在独立的“设置 → RunningHub 工作流”中管理,不属于模型渠道。 + +Bridge Token 只用于主动轮询和回传结果,不要提交到 Git 或写入公开日志。Bridge 执行长任务时每 30 秒发送心跳;远程参考素材只允许解析到公网地址,`127.0.0.1`、localhost、私网和链路本地地址会被拒绝。请求、领取状态和完成结果由后端持久化:后端重启后未领取请求会继续投递,已领取请求的迟到结果也能由恢复后的任务读取;Bridge 进程自身在执行中退出时,本机 ComfyUI 任务仍不能自动接管。工作流请求 JSON 上限为 16MB,结果 JSON(含 base64 媒体)上限为 64MB,超大视频建议改为资源上传链路。 + +Bridge 首次使用网页生成的命令启动后,会把服务器地址、Token、ComfyUI 地址和工作流目录保存到本机用户配置目录(权限为仅当前用户可读)。之后重启或断线恢复可以直接再次启动 Bridge 程序,不需要重新填写这些参数;如需更换连接目标,再使用带参数的启动命令覆盖旧配置。 + Codex app 插件会读取启动输出里的 Local URL 和 Connect token,并直接打开画布网页地址;Canvas Agent 不负责生成画布打开 URL。 Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接后,Canvas Agent 会记录该网页 Origin;之后其他 Origin 不能复用这个本地 Agent,除非用户清理 `~/.infinite-canvas/canvas-agent.json` 里的 `origins`。 diff --git a/canvas-agent/native/comfy-bridge/go.mod b/canvas-agent/native/comfy-bridge/go.mod new file mode 100644 index 000000000..11fa04959 --- /dev/null +++ b/canvas-agent/native/comfy-bridge/go.mod @@ -0,0 +1,3 @@ +module open-ai-canvas/comfy-bridge + +go 1.24 diff --git a/canvas-agent/native/comfy-bridge/main.go b/canvas-agent/native/comfy-bridge/main.go new file mode 100644 index 000000000..48aa35610 --- /dev/null +++ b/canvas-agent/native/comfy-bridge/main.go @@ -0,0 +1,668 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +type jsonMap map[string]any + +type bridgeOptions struct { + Server string `json:"server"` + Token string `json:"token"` + Comfy string `json:"comfy"` + WorkflowDir string `json:"workflowDir"` + PollSeconds int `json:"pollSeconds"` +} + +type bridgeRequest struct { + ID string `json:"id"` + TaskID string `json:"taskId"` + BridgeID string `json:"bridgeId"` + Payload jsonMap `json:"payload"` +} + +var bridgeHTTP = &http.Client{Timeout: 70 * time.Second} +var comfyHTTP = &http.Client{Timeout: 10 * time.Minute} + +func main() { + options, err := parseOptions(os.Args[1:]) + if err != nil { + fatal(err) + } + fmt.Printf("ComfyUI Bridge connecting to %s\n", options.Server) + go heartbeatLoop(options) + for { + request, err := pollRequest(options) + if err != nil { + fmt.Fprintf(os.Stderr, "ComfyUI Bridge: %v\n", err) + time.Sleep(2500 * time.Millisecond) + continue + } + if request != nil { + executeRequest(options, *request) + } + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +func parseOptions(args []string) (bridgeOptions, error) { + value := func(name string) string { + for index, item := range args { + if item == name && index+1 < len(args) { + return strings.TrimSpace(args[index+1]) + } + } + return "" + } + options := bridgeOptions{PollSeconds: 20} + if persisted, err := loadBridgeOptions(); err != nil { + return options, err + } else if persisted != nil { + options = *persisted + } + if raw := value("--server"); raw != "" { + options.Server = strings.TrimRight(raw, "/") + } + if raw := value("--token"); raw != "" { + options.Token = raw + } + if raw := value("--comfy"); raw != "" { + options.Comfy = strings.TrimRight(raw, "/") + } + if raw := value("--workflow-dir"); raw != "" { + options.WorkflowDir = raw + } + if raw := value("--poll-seconds"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + options.PollSeconds = parsed + } + } + if options.Comfy == "" { + options.Comfy = "http://127.0.0.1:8188" + } + if options.Server == "" || options.Token == "" { + return options, errors.New("需要 --server 和 --token;首次启动请使用网页生成的启动命令") + } + if options.PollSeconds < 0 { + options.PollSeconds = 0 + } + if options.PollSeconds > 60 { + options.PollSeconds = 60 + } + if err := validateHTTPURL(options.Comfy, "--comfy"); err != nil { + return options, err + } + if err := validateHTTPURL(options.Server, "--server"); err != nil { + return options, err + } + if err := saveBridgeOptions(options); err != nil { + return options, fmt.Errorf("保存 Bridge 本机配置失败:%w", err) + } + return options, nil +} + +func bridgeOptionsPath() (string, error) { + if runtime.GOOS == "windows" { + base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if base == "" { + return "", errors.New("LOCALAPPDATA 未设置,无法保存 Bridge 本机配置") + } + return filepath.Join(base, "OpenAICanvas", "comfy-bridge.json"), nil + } + base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("无法确定用户配置目录:%w", err) + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "open-ai-canvas", "comfy-bridge.json"), nil +} + +func loadBridgeOptions() (*bridgeOptions, error) { + path, err := bridgeOptionsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("读取 Bridge 本机配置失败:%w", err) + } + var options bridgeOptions + if err := json.Unmarshal(data, &options); err != nil { + return nil, fmt.Errorf("Bridge 本机配置格式无效:%w", err) + } + return &options, nil +} + +func saveBridgeOptions(options bridgeOptions) error { + path, err := bridgeOptionsPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(options, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + temporary := path + ".tmp" + if err := os.WriteFile(temporary, data, 0600); err != nil { + return err + } + if err := os.Rename(temporary, path); err != nil { + _ = os.Remove(temporary) + return err + } + return nil +} + +func validateHTTPURL(value, label string) error { + parsed, err := url.Parse(value) + if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return fmt.Errorf("%s 只支持不含账号密码的完整 HTTP/HTTPS 地址", label) + } + return nil +} + +func pollRequest(options bridgeOptions) (*bridgeRequest, error) { + var data struct { + Request *bridgeRequest `json:"request"` + } + endpoint := fmt.Sprintf("%s/api/comfy-bridge/poll?wait=%d", options.Server, options.PollSeconds) + if err := requestBridgeJSON(options, http.MethodGet, endpoint, nil, &data); err != nil { + return nil, err + } + return data.Request, nil +} + +func executeRequest(options bridgeOptions, request bridgeRequest) { + completion := jsonMap{"requestId": request.ID, "status": "succeeded"} + result, err := runComfyRequest(options, request.Payload) + if err != nil { + completion["status"] = "failed" + completion["error"] = err.Error() + } else { + completion["result"] = result + } + if err := submitResultWithRetry(options, completion); err != nil { + fmt.Fprintf(os.Stderr, "ComfyUI Bridge 结果回传失败(请求 %s):%v\n", request.ID, err) + } +} + +func runComfyRequest(options bridgeOptions, payload jsonMap) (jsonMap, error) { + mode := stringValue(payload["mode"]) + if mode == "" { + mode = "image" + } + workflow, err := loadWorkflow(options, payload) + if err != nil { + return nil, err + } + fields := sliceValue(payload["workflowFields"]) + if len(fields) == 0 { + fields = discoverWorkflowFields(workflow, mode) + payload["workflowFields"] = fields + } + if err := validateWorkflowMediaInputs(fields, payload); err != nil { + return nil, err + } + files, err := uploadReferences(options.Comfy, payload) + if err != nil { + return nil, err + } + if err := applyWorkflowFields(workflow, payload, files); err != nil { + return nil, err + } + applyPromptFallback(workflow, stringValue(payload["prompt"]), fields) + stripCanvasAnnotationNodes(workflow) + promptID, err := submitPrompt(options.Comfy, workflow) + if err != nil { + return nil, err + } + history, err := waitForHistory(options.Comfy, promptID) + if err != nil { + return nil, err + } + return collectResult(options.Comfy, mode, history) +} + +func heartbeatLoop(options bridgeOptions) { + heartbeat := func() { + capabilities, err := bridgeCapabilities(options) + if err != nil { + fmt.Fprintf(os.Stderr, "ComfyUI Bridge 能力扫描失败:%v\n", err) + return + } + if err := requestBridgeJSON(options, http.MethodPost, options.Server+"/api/comfy-bridge/heartbeat", jsonMap{"capabilities": capabilities}, nil); err != nil { + fmt.Fprintf(os.Stderr, "ComfyUI Bridge 心跳失败:%v\n", err) + } + } + heartbeat() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + heartbeat() + } +} + +func bridgeCapabilities(options bridgeOptions) (jsonMap, error) { + workflows, err := listWorkflowFiles(options) + if err != nil { + return nil, err + } + return jsonMap{ + "comfyUrl": options.Comfy, + "workflowDir": workflowRoot(options), + "comfyOnline": checkComfyUI(options.Comfy), + "workflows": workflows, + }, nil +} + +func workflowRoot(options bridgeOptions) string { + if options.WorkflowDir != "" { + absolute, _ := filepath.Abs(options.WorkflowDir) + return absolute + } + current, _ := os.Getwd() + return filepath.Join(current, "workflows") +} + +func listWorkflowFiles(options bridgeOptions) ([]jsonMap, error) { + root := workflowRoot(options) + output := make([]jsonMap, 0) + err := filepath.WalkDir(root, func(filePath string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + if errors.Is(walkErr, os.ErrNotExist) { + return nil + } + return walkErr + } + if len(output) >= 500 || entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json") || strings.HasSuffix(strings.ToLower(entry.Name()), ".config.json") { + return nil + } + relative, _ := filepath.Rel(root, filePath) + workflowID := filepath.ToSlash(relative) + item := jsonMap{"workflowId": workflowID, "title": strings.TrimSuffix(workflowID, filepath.Ext(workflowID)), "fields": []any{}, "format": "ui"} + if workflow, err := readWorkflowFile(filePath); err == nil { + item["fields"] = discoverWorkflowFields(workflow, "") + if graph := discoverWorkflowGraph(workflow); len(graph) > 0 { + item["workflowGraph"] = graph + } + if converted := convertComfyCanvasWorkflow(workflow); len(converted) > 0 { + item["workflowJson"] = converted + item["fields"] = discoverWorkflowFields(converted, "") + item["format"] = "api" + } + // UI JSON 只有画布布局,不能当作 API prompt 提交;API JSON 才回传 + // 完整拓扑,网页才能绘制节点和连线。画布 JSON 会先转换为 API JSON, + // 同时保留 workflowGraph 作为轻量预览,避免提交布局字段。 + if isComfyAPIWorkflow(workflow) { + item["workflowJson"] = workflow + item["format"] = "api" + } + } + output = append(output, item) + return nil + }) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + sortWorkflowList(output) + return output, nil +} + +func isComfyAPIWorkflow(workflow jsonMap) bool { + if len(workflow) == 0 { + return false + } + for _, rawNode := range workflow { + node, ok := mapValue(rawNode) + if !ok || strings.TrimSpace(stringValue(node["class_type"])) == "" { + continue + } + if _, ok := mapValue(node["inputs"]); ok { + return true + } + } + return false +} + +func loadWorkflow(options bridgeOptions, payload jsonMap) (jsonMap, error) { + if raw, ok := mapValue(payload["workflowJson"]); ok && len(raw) > 0 { + return cloneMap(unwrapWorkflow(raw)) + } + workflowID := strings.TrimSpace(stringValue(payload["workflowId"])) + if workflowID == "" { + return nil, errors.New("任务没有 workflowJson 或 workflowId") + } + if !strings.HasSuffix(strings.ToLower(workflowID), ".json") { + workflowID += ".json" + } + root := workflowRoot(options) + filePath, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(workflowID))) + if err != nil { + return nil, err + } + rootAbs, _ := filepath.Abs(root) + relative, err := filepath.Rel(rootAbs, filePath) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) { + return nil, errors.New("workflowId 路径不合法") + } + realRoot, err := filepath.EvalSymlinks(rootAbs) + if err != nil { + return nil, err + } + realFile, err := filepath.EvalSymlinks(filePath) + if err != nil { + return nil, err + } + realRelative, err := filepath.Rel(realRoot, realFile) + if err != nil || realRelative == "." || realRelative == ".." || strings.HasPrefix(realRelative, ".."+string(filepath.Separator)) || filepath.IsAbs(realRelative) { + return nil, errors.New("workflowId 不能指向 workflows 目录之外") + } + return readWorkflowFile(realFile) +} + +func readWorkflowFile(filePath string) (jsonMap, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + var value jsonMap + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return unwrapWorkflow(value), nil +} + +func unwrapWorkflow(value jsonMap) jsonMap { + current := value + for index := 0; index < 3; index++ { + if nested, ok := mapValue(firstValue(current["prompt"], current["workflow"])); ok { + current = nested + continue + } + if text := stringValue(firstValue(current["prompt"], current["workflow"])); text != "" { + var nested jsonMap + if json.Unmarshal([]byte(text), &nested) == nil { + current = nested + continue + } + } + if raw, ok := mapValue(current["raw"]); ok { + current = raw + continue + } + break + } + return current +} + +func submitPrompt(comfy string, workflow jsonMap) (string, error) { + body := jsonMap{"prompt": workflow, "client_id": randomID()} + var response jsonMap + if err := requestComfyJSON(http.MethodPost, comfy+"/prompt", body, &response); err != nil { + return "", fmt.Errorf("ComfyUI 提交工作流失败:%w", err) + } + promptID := stringValue(response["prompt_id"]) + if promptID == "" { + return "", errors.New("ComfyUI 未返回 prompt_id") + } + return promptID, nil +} + +func waitForHistory(comfy, promptID string) (jsonMap, error) { + deadline := time.Now().Add(30 * time.Minute) + emptyOutputPolls := 0 + for time.Now().Before(deadline) { + var payload jsonMap + if err := requestComfyJSON(http.MethodGet, comfy+"/history/"+url.PathEscape(promptID), nil, &payload); err == nil { + if history, ok := mapValue(payload[promptID]); ok { + completed, err := completedHistory(history) + if err != nil { + return nil, err + } + if completed { + return history, nil + } + if historyCompletedWithoutOutputs(history) { + emptyOutputPolls++ + if emptyOutputPolls >= 10 { + return nil, errors.New("ComfyUI 工作流已完成,但没有返回可下载产物;请检查启用的输出节点") + } + } else { + emptyOutputPolls = 0 + } + } + } + time.Sleep(1500 * time.Millisecond) + } + return nil, errors.New("ComfyUI 任务超时") +} + +func historyCompletedWithoutOutputs(history jsonMap) bool { + status, _ := mapValue(history["status"]) + statusText := strings.ToLower(firstNonEmpty(stringValue(status["status_str"]), stringValue(status["status"]))) + completed := boolValue(status["completed"]) || statusText == "success" + outputs, _ := mapValue(history["outputs"]) + return completed && statusText == "success" && len(outputs) == 0 +} + +func completedHistory(history jsonMap) (bool, error) { + status, _ := mapValue(history["status"]) + statusText := strings.ToLower(firstNonEmpty(stringValue(status["status_str"]), stringValue(status["status"]))) + completed := boolValue(status["completed"]) || statusText == "success" || statusText == "error" || statusText == "failed" + outputs, _ := mapValue(history["outputs"]) + if len(outputs) > 0 && statusText == "" { + return true, nil + } + if !completed { + return false, nil + } + if statusText == "error" || statusText == "failed" { + return false, fmt.Errorf("ComfyUI 工作流执行失败:%.500s", stringValue(firstValue(status["messages"], statusText))) + } + // ComfyUI can publish status=success before persisting the output map. + // Keep polling so a transient empty output is not reported as a failed task. + if len(outputs) == 0 { + return false, nil + } + return true, nil +} + +func submitResultWithRetry(options bridgeOptions, body jsonMap) error { + var last error + for attempt := 0; attempt < 3; attempt++ { + last = requestBridgeJSON(options, http.MethodPost, options.Server+"/api/comfy-bridge/result", body, nil) + if last == nil { + return nil + } + if attempt < 2 { + time.Sleep(time.Duration(attempt+1) * time.Second) + } + } + return last +} + +func requestBridgeJSON(options bridgeOptions, method, endpoint string, body any, target any) error { + return requestJSON(bridgeHTTP, method, endpoint, body, target, func(request *http.Request) { + request.Header.Set("Authorization", "Bearer "+options.Token) + }) +} + +func requestComfyJSON(method, endpoint string, body any, target any) error { + return requestJSON(comfyHTTP, method, endpoint, body, target, nil) +} + +func requestJSON(client *http.Client, method, endpoint string, body any, target any, headers func(*http.Request)) error { + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(context.Background(), method, endpoint, reader) + if err != nil { + return err + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if headers != nil { + headers(request) + } + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + data, err := io.ReadAll(io.LimitReader(response.Body, 64<<20)) + if err != nil { + return err + } + if client == bridgeHTTP { + var envelope struct { + Code any `json:"code"` + Data json.RawMessage `json:"data"` + Msg string `json:"msg"` + Error string `json:"error"` + } + if json.Unmarshal(data, &envelope) != nil { + return fmt.Errorf("Bridge 服务返回非 JSON:HTTP %d", response.StatusCode) + } + if response.StatusCode < 200 || response.StatusCode >= 300 || numberValue(envelope.Code) != 0 { + return errors.New(firstNonEmpty(envelope.Msg, envelope.Error, fmt.Sprintf("Bridge 服务 HTTP %d", response.StatusCode))) + } + if target != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { + return json.Unmarshal(envelope.Data, target) + } + return nil + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("HTTP %d: %.500s", response.StatusCode, string(data)) + } + if target != nil { + return json.Unmarshal(data, target) + } + return nil +} + +func checkComfyUI(comfy string) bool { + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Get(comfy + "/system_stats") + if err != nil { + return false + } + response.Body.Close() + return response.StatusCode >= 200 && response.StatusCode < 300 +} + +func randomID() string { + data := make([]byte, 16) + if _, err := rand.Read(data); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(data) +} + +func cloneMap(value jsonMap) (jsonMap, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var cloned jsonMap + if err := json.Unmarshal(data, &cloned); err != nil { + return nil, err + } + return cloned, nil +} + +func mapValue(value any) (jsonMap, bool) { + if item, ok := value.(jsonMap); ok { + return item, true + } + item, ok := value.(map[string]any) + return jsonMap(item), ok +} + +func sliceValue(value any) []any { + items, _ := value.([]any) + return items +} + +func stringValue(value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + data, _ := json.Marshal(value) + return strings.TrimSpace(string(data)) +} + +func boolValue(value any) bool { + result, _ := value.(bool) + return result +} + +func numberValue(value any) float64 { + switch item := value.(type) { + case float64: + return item + case json.Number: + result, _ := item.Float64() + return result + case string: + result, _ := strconv.ParseFloat(item, 64) + return result + default: + return 0 + } +} + +func firstValue(values ...any) any { + for _, value := range values { + if value != nil && stringValue(value) != "" { + return value + } + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/canvas-agent/native/comfy-bridge/media.go b/canvas-agent/native/comfy-bridge/media.go new file mode 100644 index 000000000..ec0fcc50e --- /dev/null +++ b/canvas-agent/native/comfy-bridge/media.go @@ -0,0 +1,381 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net" + "net/http" + "net/textproto" + "net/url" + "path/filepath" + "strings" + "time" +) + +const maxReferenceBytes int64 = 64 << 20 + +type outputItem struct { + Filename string + Subfolder string + Type string + Kind string +} + +func uploadReferences(comfy string, payload jsonMap) (map[string]string, error) { + files := map[string]string{} + for _, group := range []any{payload["referenceImages"], payload["referenceVideos"], payload["referenceAudios"], payload["mask"]} { + values := sliceValue(group) + if len(values) == 0 && group != nil { + values = []any{group} + } + for _, raw := range values { + media, ok := mapValue(raw) + if !ok { + continue + } + data, mimeType, err := mediaBytes(media) + if err != nil { + return nil, err + } + if len(data) == 0 { + continue + } + name := safeName(firstNonEmpty(stringValue(media["id"]), randomID())) + extensionForMime(mimeType) + uploadedName, err := uploadComfyMedia(comfy, name, mimeType, data) + if err != nil { + return nil, err + } + if id := stringValue(media["id"]); id != "" { + files[id] = uploadedName + } + } + } + return files, nil +} + +func mediaBytes(media jsonMap) ([]byte, string, error) { + dataURL := stringValue(media["dataUrl"]) + mimeType := firstNonEmpty(stringValue(media["mimeType"]), stringValue(media["type"])) + if strings.HasPrefix(dataURL, "data:") { + comma := strings.IndexByte(dataURL, ',') + if comma < 0 { + return nil, "", errors.New("参考素材 data URL 格式无效") + } + header := dataURL[5:comma] + if mimeType == "" { + mimeType = strings.Split(header, ";")[0] + } + data, err := base64.StdEncoding.DecodeString(dataURL[comma+1:]) + if err != nil { + return nil, "", errors.New("参考素材 data URL 编码无效") + } + if int64(len(data)) > maxReferenceBytes { + return nil, "", errors.New("参考素材超过 64MB") + } + return data, firstNonEmpty(mimeType, "application/octet-stream"), nil + } + remoteURL := stringValue(media["url"]) + if remoteURL == "" { + return nil, "", nil + } + if err := assertPublicRemoteURL(remoteURL); err != nil { + return nil, "", err + } + client := publicMediaClient() + response, err := client.Get(remoteURL) + if err != nil { + return nil, "", fmt.Errorf("下载远程参考素材失败:%w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, "", fmt.Errorf("下载远程参考素材失败 HTTP %d", response.StatusCode) + } + if response.ContentLength > maxReferenceBytes { + return nil, "", errors.New("远程参考素材超过 64MB") + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxReferenceBytes+1)) + if err != nil { + return nil, "", err + } + if int64(len(data)) > maxReferenceBytes { + return nil, "", errors.New("远程参考素材超过 64MB") + } + if mimeType == "" { + mimeType = strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0]) + } + return data, firstNonEmpty(mimeType, "application/octet-stream"), nil +} + +func uploadComfyMedia(comfy, name, mimeType string, data []byte) (string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "image", "filename": name})) + header.Set("Content-Type", mimeType) + part, err := writer.CreatePart(header) + if err != nil { + return "", err + } + if _, err := part.Write(data); err != nil { + return "", err + } + if err := writer.Close(); err != nil { + return "", err + } + request, err := http.NewRequest(http.MethodPost, comfy+"/upload/image", body) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", writer.FormDataContentType()) + response, err := comfyHTTP.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + dataResponse, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", fmt.Errorf("ComfyUI 上传参考素材失败 HTTP %d: %.500s", response.StatusCode, dataResponse) + } + var payload jsonMap + if json.Unmarshal(dataResponse, &payload) != nil { + return "", errors.New("ComfyUI 上传响应不是有效 JSON") + } + uploadedName := stringValue(payload["name"]) + if uploadedName == "" { + return "", errors.New("ComfyUI 上传响应缺少文件名") + } + return uploadedName, nil +} + +func assertPublicRemoteURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil { + return errors.New("远程参考素材只允许无凭证的 HTTP/HTTPS 公网地址") + } + hostname := strings.ToLower(strings.Trim(parsed.Hostname(), "[]")) + if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") || strings.HasSuffix(hostname, ".local") { + return errors.New("拒绝访问本机或私网远程参考素材地址") + } + addresses, err := net.DefaultResolver.LookupIPAddr(context.Background(), hostname) + if err != nil || len(addresses) == 0 { + return errors.New("远程参考素材域名无法解析") + } + for _, address := range addresses { + if isPrivateIP(address.IP) { + return errors.New("拒绝访问解析到本机或私网的远程参考素材地址") + } + } + return nil +} + +func publicMediaClient() *http.Client { + transport := &http.Transport{DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(addresses) == 0 { + return nil, errors.New("远程参考素材域名无法解析") + } + for _, candidate := range addresses { + if isPrivateIP(candidate.IP) { + return nil, errors.New("拒绝访问解析到本机或私网的远程参考素材地址") + } + } + dialer := &net.Dialer{Timeout: 15 * time.Second} + return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + }} + return &http.Client{ + Timeout: 2 * time.Minute, + Transport: transport, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("远程参考素材重定向次数过多") + } + return assertPublicRemoteURL(request.URL.String()) + }, + } +} + +func isPrivateIP(ip net.IP) bool { + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return true + } + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4[0] == 0 || (ipv4[0] == 100 && ipv4[1] >= 64 && ipv4[1] <= 127) || (ipv4[0] == 192 && ipv4[1] == 0) || (ipv4[0] == 198 && (ipv4[1] == 18 || ipv4[1] == 19)) || ipv4[0] >= 224 + } + return false +} + +func collectResult(comfy, mode string, history jsonMap) (jsonMap, error) { + outputs := make([]outputItem, 0) + root, _ := mapValue(history["outputs"]) + for _, nodeOutput := range root { + collectOutputItems(nodeOutput, "", &outputs) + } + if len(outputs) == 0 { + return nil, errors.New("ComfyUI 没有返回可下载产物") + } + images := make([]any, 0) + var video, audio jsonMap + for _, item := range outputs { + query := url.Values{"filename": {item.Filename}, "subfolder": {item.Subfolder}, "type": {firstNonEmpty(item.Type, "output")}} + response, err := comfyHTTP.Get(comfy + "/view?" + query.Encode()) + if err != nil { + continue + } + data, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<20)) + response.Body.Close() + if readErr != nil || response.StatusCode < 200 || response.StatusCode >= 300 { + continue + } + mimeType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0]) + if mimeType == "" || mimeType == "application/octet-stream" { + mimeType = mimeForName(item.Filename) + } + value := jsonMap{"dataUrl": "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), "mimeType": mimeType, "bytes": len(data), "name": item.Filename} + switch item.Kind { + case "video": + video = value + case "audio": + audio = value + case "image": + images = append(images, value) + } + } + if mode == "video" && video != nil { + return jsonMap{"mode": "video", "video": video}, nil + } + if mode == "audio" && audio != nil { + return jsonMap{"mode": "audio", "audio": audio}, nil + } + if len(images) > 0 { + return jsonMap{"mode": "image", "images": images}, nil + } + if video != nil { + return jsonMap{"mode": "video", "video": video}, nil + } + if audio != nil { + return jsonMap{"mode": "audio", "audio": audio}, nil + } + return nil, errors.New("ComfyUI 产物下载失败") +} + +func collectOutputItems(value any, forcedKind string, output *[]outputItem) { + if items := sliceValue(value); len(items) > 0 { + for _, child := range items { + collectOutputItems(child, forcedKind, output) + } + return + } + item, ok := mapValue(value) + if !ok { + return + } + if filename := stringValue(item["filename"]); filename != "" { + kind := forcedKind + if kind == "" { + kind = outputKind(filename) + } + if kind != "other" { + *output = append(*output, outputItem{Filename: filename, Subfolder: stringValue(item["subfolder"]), Type: firstNonEmpty(stringValue(item["type"]), "output"), Kind: kind}) + } + return + } + for key, child := range item { + kind := "" + switch key { + case "videos": + kind = "video" + case "audio", "audios": + kind = "audio" + case "images", "gifs", "files": + kind = forcedKind + } + collectOutputItems(child, kind, output) + } +} + +func safeName(value string) string { + var result strings.Builder + for _, character := range value { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || (character >= '0' && character <= '9') || character == '_' || character == '-' { + result.WriteRune(character) + } + if result.Len() >= 64 { + break + } + } + if result.Len() == 0 { + return "reference" + } + return result.String() +} + +func extensionForMime(value string) string { + lower := strings.ToLower(value) + switch { + case strings.Contains(lower, "jpeg"): + return ".jpg" + case strings.Contains(lower, "webp"): + return ".webp" + case strings.Contains(lower, "video"): + return ".mp4" + case strings.Contains(lower, "audio"): + return ".wav" + default: + return ".png" + } +} + +func mimeForName(value string) string { + extension := strings.ToLower(filepath.Ext(value)) + switch extension { + case ".webm": + return "video/webm" + case ".mov": + return "video/quicktime" + case ".mp4", ".m4v", ".mkv": + return "video/mp4" + case ".wav": + return "audio/wav" + case ".ogg": + return "audio/ogg" + case ".flac": + return "audio/flac" + case ".m4a", ".aac": + return "audio/mp4" + case ".mp3": + return "audio/mpeg" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + default: + return "image/png" + } +} + +func outputKind(value string) string { + extension := strings.ToLower(filepath.Ext(value)) + if stringIn(extension, ".mp4", ".webm", ".mov", ".m4v", ".mkv") { + return "video" + } + if stringIn(extension, ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac") { + return "audio" + } + if stringIn(extension, ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp") { + return "image" + } + return "other" +} diff --git a/canvas-agent/native/comfy-bridge/workflow.go b/canvas-agent/native/comfy-bridge/workflow.go new file mode 100644 index 000000000..8bb61111d --- /dev/null +++ b/canvas-agent/native/comfy-bridge/workflow.go @@ -0,0 +1,1055 @@ +package main + +import ( + "errors" + "fmt" + "math" + "math/rand/v2" + "regexp" + "sort" + "strconv" + "strings" +) + +var numberPattern = regexp.MustCompile(`^-?\d+(\.\d+)?$`) + +func sortWorkflowList(items []jsonMap) { + sort.Slice(items, func(i, j int) bool { + return strings.ToLower(stringValue(items[i]["title"])) < strings.ToLower(stringValue(items[j]["title"])) + }) +} + +func discoverWorkflowFields(workflow jsonMap, capability string) []any { + fields := make([]any, 0) + nodeIDs := sortedNodeIDs(workflow) + promptNode, promptField := findWorkflowPromptTarget(workflow, nodeIDs) + imageOrder, videoOrder, audioOrder := 0, 0, 0 + for _, nodeID := range nodeIDs { + node, ok := mapValue(workflow[nodeID]) + if !ok { + continue + } + inputs, ok := mapValue(node["inputs"]) + if !ok { + continue + } + title := "" + if meta, ok := mapValue(node["_meta"]); ok { + title = stringValue(meta["title"]) + } + classType := stringValue(node["class_type"]) + if isCanvasAnnotationNode(classType) { + continue + } + fieldNames := make([]string, 0, len(inputs)) + for fieldName := range inputs { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + for _, fieldName := range fieldNames { + fieldValue := inputs[fieldName] + if isWorkflowLink(fieldValue) { + continue + } + fieldType := discoveredFieldType(fieldName, fieldValue, classType) + label := fieldName + if title != "" { + label = title + " · " + fieldName + } + field := jsonMap{"id": nodeID + "::" + fieldName, "nodeId": nodeID, "fieldName": fieldName, "fieldValue": fieldValue, "fieldType": fieldType, "label": label, "enabled": true} + if nodeID == promptNode && fieldName == promptField { + field["source"] = "prompt" + field["required"] = true + } else if source := discoveredDynamicSource(fieldName, fieldType, capability); source != "" { + field["source"] = source + } + switch normalizeSourceName(stringValue(field["source"])) { + case "referenceimage": + imageOrder++ + field["imageOrder"] = imageOrder + field["sourceIndex"] = imageOrder - 1 + field["required"] = imageOrder == 1 + case "referencevideo": + field["sourceIndex"] = videoOrder + field["required"] = videoOrder == 0 + videoOrder++ + case "referenceaudio": + field["sourceIndex"] = audioOrder + field["required"] = audioOrder == 0 + audioOrder++ + case "mask": + field["required"] = false + } + if isSeedField(fieldName) { + field["randomEnabled"] = true + } + fields = append(fields, field) + } + } + return fields +} + +// discoverWorkflowGraph 将 ComfyUI 画布 JSON 压缩成网页只读预览所需的节点和连线。 +// 画布 JSON 不能直接提交给 /prompt,因此预览数据必须和可执行 API JSON 分开传输。 +func discoverWorkflowGraph(workflow jsonMap) jsonMap { + rawNodes := sliceValue(workflow["nodes"]) + if len(rawNodes) == 0 { + return nil + } + + nodes := make([]any, 0, len(rawNodes)) + nodeIDs := make(map[string]bool, len(rawNodes)) + for _, raw := range rawNodes { + node, ok := mapValue(raw) + if !ok { + continue + } + nodeID := strings.TrimSpace(stringValue(node["id"])) + if nodeID == "" || nodeIDs[nodeID] { + continue + } + classType := firstNonEmpty(stringValue(node["type"]), stringValue(node["class_type"]), "Unknown") + title := stringValue(node["title"]) + if title == "" { + if properties, ok := mapValue(node["properties"]); ok { + title = stringValue(properties["Node name for S&R"]) + } + } + if title == "" { + title = classType + } + nodes = append(nodes, jsonMap{"id": nodeID, "title": title, "classType": classType}) + nodeIDs[nodeID] = true + } + if len(nodes) == 0 { + return nil + } + + edges := make([]any, 0) + edgeKeys := make(map[string]bool) + for _, raw := range sliceValue(workflow["links"]) { + items := sliceValue(raw) + if len(items) < 4 { + continue + } + from := strings.TrimSpace(stringValue(items[1])) + to := strings.TrimSpace(stringValue(items[3])) + if from == "" || to == "" || from == to || !nodeIDs[from] || !nodeIDs[to] { + continue + } + key := from + ":" + to + if edgeKeys[key] { + continue + } + edgeKeys[key] = true + edges = append(edges, jsonMap{"from": from, "to": to}) + } + return jsonMap{"nodes": nodes, "edges": edges} +} + +// convertComfyCanvasWorkflow 将 ComfyUI 保存的画布 JSON 转成 /prompt 接受的 API JSON。 +// 画布节点的 link 使用全局 link ID,API 节点则直接保存 [来源节点, 输出槽位]。 +// 只转换画布中明确声明的输入和 widget 默认值,避免把布局字段带入执行请求。 +func convertComfyCanvasWorkflow(workflow jsonMap) jsonMap { + rawNodes := sliceValue(workflow["nodes"]) + if len(rawNodes) == 0 { + return nil + } + links := make(map[string][]any) + for _, raw := range sliceValue(workflow["links"]) { + items := sliceValue(raw) + if len(items) < 4 { + continue + } + links[workflowNumberKey(items[0])] = items + } + + converted := make(jsonMap, len(rawNodes)) + for _, raw := range rawNodes { + node, ok := mapValue(raw) + if !ok { + continue + } + nodeID := strings.TrimSpace(stringValue(node["id"])) + classType := strings.TrimSpace(firstNonEmpty(stringValue(node["type"]), stringValue(node["class_type"]))) + // 画布备注只用于编辑器展示,不是可执行节点。若将其转换进 /prompt, + // ComfyUI 会按自定义节点校验,缺少 MarkdownNote 时直接让整个任务失败。 + if nodeID == "" || classType == "" || isCanvasAnnotationNode(classType) { + continue + } + inputs := make(jsonMap) + namedWidgets := make(jsonMap) + if rawNamed, ok := mapValue(node["widgets_values_named"]); ok { + namedWidgets = rawNamed + } + widgetValues := sliceValue(node["widgets_values"]) + widgetIndex := 0 + for _, rawInput := range sliceValue(node["inputs"]) { + input, ok := mapValue(rawInput) + if !ok { + continue + } + fieldName := strings.TrimSpace(stringValue(input["name"])) + if fieldName == "" { + continue + } + if linkKey := workflowNumberKey(input["link"]); linkKey != "" { + if link, exists := links[linkKey]; exists { + fromNode := strings.TrimSpace(stringValue(link[1])) + fromSlot := int(numberValue(link[2])) + if fromNode != "" { + inputs[fieldName] = []any{fromNode, fromSlot} + } + } + continue + } + widgetName := fieldName + if widget, ok := mapValue(input["widget"]); ok { + widgetName = firstNonEmpty(stringValue(widget["name"]), fieldName) + } + if value, exists := namedWidgets[widgetName]; exists { + inputs[fieldName] = value + widgetIndex++ + continue + } + if _, hasWidget := input["widget"]; hasWidget && widgetIndex < len(widgetValues) { + inputs[fieldName] = widgetValues[widgetIndex] + widgetIndex++ + } + } + apiNode := jsonMap{"class_type": classType, "inputs": inputs} + if title := firstNonEmpty(stringValue(node["title"]), nodeTitle(node)); title != "" { + apiNode["_meta"] = jsonMap{"title": title} + } + converted[nodeID] = apiNode + } + if len(converted) == 0 { + return nil + } + return converted +} + +func isCanvasAnnotationNode(classType string) bool { + normalized := strings.ToLower(strings.TrimSpace(classType)) + return normalized == "note" || + strings.HasPrefix(normalized, "note:") || + normalized == "markdownnote" || + strings.HasPrefix(normalized, "markdownnote:") || + // rgthree labels are canvas-only headings and must not be sent to /prompt. + normalized == "label (rgthree)" || + normalized == "label" || + normalized == "addlabel" || + normalized == "fast groups bypasser (rgthree)" || + normalized == "fast groups bypasser" +} + +func stripCanvasAnnotationNodes(workflow jsonMap) { + for nodeID, raw := range workflow { + node, ok := mapValue(raw) + if !ok || !isCanvasAnnotationNode(firstNonEmpty(stringValue(node["class_type"]), stringValue(node["type"]))) { + continue + } + delete(workflow, nodeID) + } +} + +func nodeTitle(node jsonMap) string { + if properties, ok := mapValue(node["properties"]); ok { + return stringValue(properties["Node name for S&R"]) + } + return "" +} + +func workflowNumberKey(value any) string { + if value == nil || strings.TrimSpace(stringValue(value)) == "" { + return "" + } + return strconv.FormatInt(int64(numberValue(value)), 10) +} + +func sortedNodeIDs(workflow jsonMap) []string { + items := make([]string, 0, len(workflow)) + for nodeID := range workflow { + items = append(items, nodeID) + } + sort.Slice(items, func(i, j int) bool { + left, leftErr := strconv.ParseInt(items[i], 10, 64) + right, rightErr := strconv.ParseInt(items[j], 10, 64) + if leftErr == nil && rightErr == nil { + return left < right + } + return items[i] < items[j] + }) + return items +} + +func findWorkflowPromptTarget(workflow jsonMap, nodeIDs []string) (string, string) { + bestNode, bestField, bestScore := "", "", math.MinInt + for _, nodeID := range nodeIDs { + node, ok := mapValue(workflow[nodeID]) + if !ok { + continue + } + inputs, ok := mapValue(node["inputs"]) + if !ok { + continue + } + classType := strings.ToLower(stringValue(node["class_type"])) + title := "" + if meta, ok := mapValue(node["_meta"]); ok { + title = strings.ToLower(stringValue(meta["title"])) + } + for fieldName, value := range inputs { + if _, ok := value.(string); !ok || !isPromptCandidate(fieldName, classType, title) { + continue + } + descriptor := strings.ToLower(fieldName + " " + title) + score := 0 + if strings.Contains(descriptor, "negative") || strings.Contains(descriptor, "负面") || strings.Contains(descriptor, "反向") || strings.Contains(descriptor, "负向") { + score -= 100 + } + if strings.Contains(descriptor, "positive") || strings.Contains(descriptor, "正面") || strings.Contains(descriptor, "正向") { + score += 20 + } + if strings.Contains(classType, "cliptextencode") { + score += 5 + } + if isPromptWorkflowField(jsonMap{"fieldName": fieldName}) { + score += 2 + } + if bestNode == "" || score > bestScore { + bestNode, bestField, bestScore = nodeID, fieldName, score + } + } + } + return bestNode, bestField +} + +func isPromptCandidate(fieldName, classType, title string) bool { + key := normalizeSourceName(fieldName) + if stringIn(key, "text", "prompt", "positiveprompt", "positive", "caption", "description") { + return true + } + if strings.ToLower(fieldName) != "value" { + return false + } + descriptor := classType + " " + title + return strings.Contains(descriptor, "cliptextencode") || strings.Contains(descriptor, "text") || strings.Contains(descriptor, "string") || strings.Contains(descriptor, "prompt") || strings.Contains(descriptor, "提示词") || strings.Contains(descriptor, "文本") +} + +func discoveredFieldType(fieldName string, value any, classType string) string { + key, classKey := normalizeSourceName(fieldName), normalizeSourceName(classType) + if strings.Contains(key, "mask") { + return "IMAGE" + } + if strings.Contains(classKey, "loadimage") && stringIn(key, "image", "file", "path") { + return "IMAGE" + } + if strings.Contains(classKey, "loadvideo") && stringIn(key, "video", "file", "path") { + return "VIDEO" + } + if strings.Contains(classKey, "loadaudio") && stringIn(key, "audio", "file", "path") { + return "AUDIO" + } + if discoveredNamedDynamicSource(fieldName, "") != "" { + return scalarFieldType(value) + } + switch value.(type) { + case bool: + return "BOOLEAN" + case float64, float32, int, int64: + return "NUMBER" + } + if mediaFieldName(key, "image") || mediaValueType(value) == "IMAGE" { + return "IMAGE" + } + if mediaFieldName(key, "video") || mediaValueType(value) == "VIDEO" { + return "VIDEO" + } + if mediaFieldName(key, "audio") || mediaValueType(value) == "AUDIO" { + return "AUDIO" + } + return "TEXT" +} + +func mediaValueType(value any) string { + if items := sliceValue(value); len(items) > 0 { + value = items[0] + } + text := strings.ToLower(stringValue(value)) + if index := strings.IndexAny(text, "?#"); index >= 0 { + text = text[:index] + } + if hasAnySuffix(text, ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".avif") { + return "IMAGE" + } + if hasAnySuffix(text, ".mp4", ".webm", ".mov", ".m4v", ".mkv") { + return "VIDEO" + } + if hasAnySuffix(text, ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac") { + return "AUDIO" + } + return "" +} + +func discoveredDynamicSource(fieldName, fieldType, capability string) string { + if source := discoveredNamedDynamicSource(fieldName, capability); source != "" { + return source + } + switch strings.ToUpper(strings.TrimSpace(fieldType)) { + case "IMAGE": + return "referenceImage" + case "VIDEO": + return "referenceVideo" + case "AUDIO": + return "referenceAudio" + default: + return "" + } +} + +func discoveredNamedDynamicSource(fieldName, capability string) string { + key := normalizeSourceName(fieldName) + if strings.Contains(key, "mask") { + return "mask" + } + if source := dimensionFieldSource(key); source != "" { + return source + } + if stringIn(key, "ratio", "aspectratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio") { + return "aspectRatio" + } + if stringIn(key, "videoresolution", "videoquality", "vquality") { + return "vquality" + } + if stringIn(key, "size", "imagesize", "imageresolution") { + return "size" + } + if key == "resolution" { + if capability == "video" { + return "vquality" + } + if capability == "image" { + return "size" + } + } + aliases := map[string]string{ + "batch": "count", "batchsize": "count", "count": "count", "numimages": "count", "numberofimages": "count", "imagecount": "count", "imagescount": "count", + "quality": "quality", "duration": "videoSeconds", "seconds": "videoSeconds", "durationseconds": "videoSeconds", "videoseconds": "videoSeconds", "videoduration": "videoSeconds", "videodurationseconds": "videoSeconds", "videolength": "videoSeconds", "clipduration": "videoSeconds", + "generateaudio": "videoGenerateAudio", "videogenerateaudio": "videoGenerateAudio", "watermark": "videoWatermark", "videowatermark": "videoWatermark", + "audioformat": "audioFormat", "voice": "audioVoice", "audiovoice": "audioVoice", "audiospeed": "audioSpeed", "audioinstructions": "audioInstructions", + "transparent": "transparentBackground", "transparentbackground": "transparentBackground", + } + return aliases[key] +} + +var dimensionPrefixes = []string{"", "image", "video", "size", "output", "target", "latent", "frame", "canvas", "source", "resolution", "final"} + +func dimensionFieldSource(key string) string { + for _, prefix := range dimensionPrefixes { + if key == prefix+"width" { + return "width" + } + if key == prefix+"height" { + return "height" + } + } + if key == "pixelwidth" || key == "widthpixels" { + return "width" + } + if key == "pixelheight" || key == "heightpixels" { + return "height" + } + return "" +} + +func scalarFieldType(value any) string { + switch value.(type) { + case bool: + return "BOOLEAN" + case float64, float32, int, int64: + return "NUMBER" + default: + return "TEXT" + } +} + +func mediaFieldName(key, mediaType string) bool { + if key == mediaType { + return true + } + for _, prefix := range []string{"input", "reference", "ref", "source", "init", "start", "end", "first", "last", "control", "controlnet", "style", "subject"} { + if key == prefix+mediaType { + return true + } + } + for _, suffix := range []string{"file", "path", "filename", "upload"} { + if key == mediaType+suffix { + return true + } + } + return mediaType == "image" && stringIn(key, "firstframe", "lastframe", "startframe", "endframe") +} + +func isWorkflowLink(value any) bool { + items := sliceValue(value) + if len(items) != 2 || stringValue(items[0]) == "" { + return false + } + index := numberValue(items[1]) + return index == math.Trunc(index) +} + +func isSeedField(value string) bool { + key := normalizeSourceName(value) + return key == "seed" || strings.HasSuffix(key, "seed") || strings.Contains(key, "noiseseed") +} + +func normalizeSourceName(value string) string { + replacer := strings.NewReplacer(" ", "", "_", "", "-", "") + return strings.ToLower(replacer.Replace(strings.TrimSpace(value))) +} + +func validateWorkflowMediaInputs(fields []any, payload jsonMap) error { + capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0} + hasMask := false + for _, raw := range fields { + field, ok := mapValue(raw) + if !ok || field["enabled"] == false { + continue + } + source := normalizedWorkflowFieldSource(field) + if source == "mask" { + hasMask = true + continue + } + key := canonicalMediaSource(source) + if _, ok := capacities[key]; !ok { + continue + } + index := workflowSourceIndex(field) + if index+1 > capacities[key] { + capacities[key] = index + 1 + } + } + checks := []struct { + label string + key string + value any + }{{"参考图片", "referenceimage", payload["referenceImages"]}, {"参考视频", "referencevideo", payload["referenceVideos"]}, {"参考音频", "referenceaudio", payload["referenceAudios"]}} + for _, check := range checks { + count := len(sliceValue(check.value)) + if count > capacities[check.key] { + return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在字段映射中补齐槽位", capacities[check.key], check.label, count) + } + } + if payload["mask"] != nil && !hasMask { + return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射") + } + return nil +} + +func applyWorkflowFields(workflow jsonMap, payload jsonMap, files map[string]string) error { + fields := sliceValue(payload["workflowFields"]) + removed := map[string]bool{} + for _, raw := range fields { + field, ok := mapValue(raw) + if !ok || field["enabled"] == false { + continue + } + nodeID := firstNonEmpty(stringValue(field["nodeId"]), stringValue(field["node"])) + fieldName := firstNonEmpty(stringValue(field["fieldName"]), stringValue(field["input"])) + if nodeID == "" || fieldName == "" { + continue + } + node, ok := mapValue(workflow[nodeID]) + if !ok { + if boolValue(field["required"]) { + return fmt.Errorf("工作流缺少必填映射节点 %s", nodeID) + } + continue + } + inputs, ok := mapValue(node["inputs"]) + if !ok { + inputs = jsonMap{} + } + source := normalizedWorkflowFieldSource(field) + value := firstValue(field["value"], field["fieldValue"], field["default"]) + if source != "" { + value = resolveSource(source, field, payload, files) + } else if boolValue(field["randomEnabled"]) || boolValue(field["random_enabled"]) { + var err error + value, err = randomWorkflowValue(field) + if err != nil { + return err + } + } + if !emptyWorkflowValue(value) { + inputs[fieldName] = normalizeComfyValue(value) + } else if boolValue(field["required"]) { + return fmt.Errorf("工作流必填字段 %s.%s 缺少值", nodeID, fieldName) + } else if isMediaSource(source) { + delete(inputs, fieldName) + } + node["inputs"] = inputs + if len(inputs) == 0 && isMediaSource(source) { + removed[nodeID] = true + } + } + for nodeID := range removed { + delete(workflow, nodeID) + } + if len(removed) == 0 { + return nil + } + for _, rawNode := range workflow { + node, ok := mapValue(rawNode) + if !ok { + continue + } + inputs, ok := mapValue(node["inputs"]) + if !ok { + continue + } + for fieldName, value := range inputs { + link := sliceValue(value) + if len(link) > 0 && removed[stringValue(link[0])] { + delete(inputs, fieldName) + } + } + } + return nil +} + +func normalizedWorkflowFieldSource(field jsonMap) string { + source := stringValue(field["source"]) + if source == "" && (boolValue(field["bindPrompt"]) || boolValue(field["bind_prompt"])) { + source = "prompt" + } + if source == "" && (boolValue(field["sourceFromUpstream"]) || boolValue(field["source_from_upstream"])) { + fieldType := strings.ToLower(firstNonEmpty(stringValue(field["fieldType"]), stringValue(field["type"]))) + switch fieldType { + case "image": + source = "referenceImage" + case "video": + source = "referenceVideo" + case "audio": + source = "referenceAudio" + default: + if isPromptWorkflowField(field) { + source = "prompt" + } + } + } + return normalizeSourceName(source) +} + +func randomWorkflowValue(field jsonMap) (int64, error) { + minimum, err := integerFieldBound(field["min"], 0) + if err != nil { + return 0, err + } + maximum, err := integerFieldBound(field["max"], math.MaxInt32) + if err != nil { + return 0, err + } + if maximum < minimum { + return 0, errors.New("工作流随机值最大值不能小于最小值") + } + return minimum + rand.Int64N(maximum-minimum+1), nil +} + +func integerFieldBound(value any, fallback int64) (int64, error) { + if value == nil || stringValue(value) == "" { + return fallback, nil + } + parsed, err := strconv.ParseInt(strings.Trim(stringValue(value), `"`), 10, 64) + if err != nil { + return 0, fmt.Errorf("工作流随机值范围不是有效整数:%s", stringValue(value)) + } + return parsed, nil +} + +func resolveSource(source string, field, payload jsonMap, files map[string]string) any { + normalized := normalizeSourceName(source) + if stringIn(normalized, "prompt", "text", "positiveprompt", "positive") { + return stringValue(payload["prompt"]) + } + params, _ := mapValue(payload["params"]) + switch { + case stringIn(normalized, "size", "imagesize", "imageresolution"): + return params["size"] + case normalized == "resolution": + if strings.ToLower(stringValue(payload["mode"])) == "video" { + return videoResolutionValue(field, params["vquality"]) + } + return params["size"] + case stringIn(normalized, "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio"): + return aspectRatioValue(field, params["size"]) + case stringIn(normalized, "sizewidth", "width", "imagewidth", "videowidth"): + return dimensionPart(stringValue(payload["mode"]), stringValue(params["size"]), stringValue(params["vquality"]), 0) + case stringIn(normalized, "sizeheight", "height", "imageheight", "videoheight"): + return dimensionPart(stringValue(payload["mode"]), stringValue(params["size"]), stringValue(params["vquality"]), 1) + } + aliases := map[string]string{ + "quality": "quality", "count": "count", "batch": "count", "batchsize": "count", "videoseconds": "videoSeconds", "duration": "videoSeconds", + "videogenerateaudio": "videoGenerateAudio", "generateaudio": "videoGenerateAudio", "videowatermark": "videoWatermark", "watermark": "videoWatermark", + "audioformat": "audioFormat", "systemprompt": "systemPrompt", "transparentbackground": "transparentBackground", "audiovoice": "audioVoice", "voice": "audioVoice", "audiospeed": "audioSpeed", "audioinstructions": "audioInstructions", + } + if stringIn(normalized, "vquality", "videoquality", "videoresolution") { + return videoResolutionValue(field, params["vquality"]) + } + if key := aliases[normalized]; key != "" { + return params[key] + } + if normalized == "mask" { + media, _ := mapValue(payload["mask"]) + return files[stringValue(media["id"])] + } + values := payload["referenceImages"] + if strings.Contains(normalized, "video") { + values = payload["referenceVideos"] + } else if strings.Contains(normalized, "audio") { + values = payload["referenceAudios"] + } + items := sliceValue(values) + index := workflowSourceIndex(field) + if index < 0 || index >= len(items) { + return nil + } + media, _ := mapValue(items[index]) + return files[stringValue(media["id"])] +} + +func applyPromptFallback(workflow jsonMap, prompt string, fields []any) { + if prompt == "" { + return + } + for _, raw := range fields { + field, ok := mapValue(raw) + if !ok { + continue + } + source := normalizedWorkflowFieldSource(field) + if boolValue(field["bind_prompt"]) || stringIn(source, "prompt", "text", "positiveprompt", "positive") { + return + } + } + nodeID, fieldName := findWorkflowPromptTarget(workflow, sortedNodeIDs(workflow)) + node, ok := mapValue(workflow[nodeID]) + if !ok { + return + } + inputs, ok := mapValue(node["inputs"]) + if ok { + inputs[fieldName] = prompt + } +} + +func isPromptWorkflowField(field jsonMap) bool { + for _, key := range []string{"fieldName", "input", "label", "name"} { + if stringIn(normalizeSourceName(stringValue(field[key])), "prompt", "text", "positiveprompt", "caption", "description", "提示词", "正向提示词") { + return true + } + } + return false +} + +func normalizeComfyValue(value any) any { + text, ok := value.(string) + if !ok { + return value + } + trimmed := strings.TrimSpace(text) + if strings.EqualFold(trimmed, "true") { + return true + } + if strings.EqualFold(trimmed, "false") { + return false + } + if numberPattern.MatchString(trimmed) { + if number, err := strconv.ParseFloat(trimmed, 64); err == nil { + return number + } + } + return value +} + +func emptyWorkflowValue(value any) bool { + return value == nil || (func() bool { text, ok := value.(string); return ok && strings.TrimSpace(text) == "" })() +} + +func workflowSourceIndex(field jsonMap) int { + if order := int(numberValue(field["imageOrder"])); order > 0 { + return order - 1 + } + index := int(numberValue(field["sourceIndex"])) + if index < 0 { + return 0 + } + return index +} + +func canonicalMediaSource(source string) string { + switch source { + case "image", "referenceimages": + return "referenceimage" + case "video", "referencevideos": + return "referencevideo" + case "audio", "referenceaudios": + return "referenceaudio" + default: + return source + } +} + +func isMediaSource(source string) bool { + return stringIn(source, "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask") +} + +func dimensionPart(mode, size, quality string, index int) any { + if pixels, ok := pixelDimensions(size); ok { + return pixels[index] + } + var dimensions [2]int + var ok bool + if strings.EqualFold(strings.TrimSpace(mode), "video") { + dimensions, ok = videoDimensions(size, quality) + } else { + dimensions, ok = imageDimensions(size) + } + if !ok { + return nil + } + return dimensions[index] +} + +func pixelDimensions(value string) ([2]int, bool) { + parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), "x") + if len(parts) != 2 { + return [2]int{}, false + } + width, errWidth := strconv.Atoi(strings.TrimSpace(parts[0])) + height, errHeight := strconv.Atoi(strings.TrimSpace(parts[1])) + return [2]int{width, height}, errWidth == nil && errHeight == nil && width > 0 && height > 0 +} + +func aspectRatio(value string) any { + normalized := strings.ToLower(strings.TrimSpace(value)) + if match := regexp.MustCompile(`^(\d+)\s*:\s*(\d+)`).FindStringSubmatch(normalized); len(match) == 3 { + width, widthErr := strconv.Atoi(match[1]) + height, heightErr := strconv.Atoi(match[2]) + if widthErr == nil && heightErr == nil && width > 0 && height > 0 { + divisor := gcd(width, height) + return fmt.Sprintf("%d:%d", width/divisor, height/divisor) + } + } + normalized = regexp.MustCompile(`-(1k|2k|4k)$`).ReplaceAllString(normalized, "") + if parts, ok := ratioParts(normalized); ok { + divisor := gcd(parts[0], parts[1]) + return fmt.Sprintf("%d:%d", parts[0]/divisor, parts[1]/divisor) + } + dimensions, ok := pixelDimensions(normalized) + if !ok { + return nil + } + actual := float64(dimensions[0]) / float64(dimensions[1]) + known := [][2]int{{1, 1}, {3, 2}, {2, 3}, {4, 3}, {3, 4}, {4, 5}, {5, 4}, {16, 9}, {9, 16}, {2, 1}, {1, 2}, {21, 9}} + best, difference := [2]int{}, math.Inf(1) + for _, candidate := range known { + expected := float64(candidate[0]) / float64(candidate[1]) + current := math.Abs(actual-expected) / expected + if current < difference { + best, difference = candidate, current + } + } + if difference <= 0.03 { + return fmt.Sprintf("%d:%d", best[0], best[1]) + } + divisor := gcd(dimensions[0], dimensions[1]) + return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor) +} + +// aspectRatioValue keeps the canonical canvas ratio while restoring the exact +// enum value expected by ComfyUI nodes such as ResolutionSelector. +func aspectRatioValue(field jsonMap, value any) any { + canonical, _ := aspectRatio(stringValue(value)).(string) + if canonical == "" { + return value + } + candidates := sliceValue(field["options"]) + for _, candidate := range []any{field["value"], field["fieldValue"], field["default"]} { + if !emptyWorkflowValue(candidate) { + candidates = append(candidates, candidate) + } + } + for _, candidate := range candidates { + option := optionString(candidate) + if option == "" { + continue + } + optionCanonical, _ := aspectRatio(option).(string) + if optionCanonical == canonical { + return option + } + } + return canonical +} + +func ratioParts(value string) ([2]int, bool) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return [2]int{}, false + } + width, errWidth := strconv.Atoi(parts[0]) + height, errHeight := strconv.Atoi(parts[1]) + return [2]int{width, height}, errWidth == nil && errHeight == nil && width > 0 && height > 0 +} + +func imageDimensions(value string) ([2]int, bool) { + normalized := strings.ToLower(strings.TrimSpace(value)) + presets := map[string][2]int{"1:1": {1024, 1024}, "3:2": {1536, 1024}, "2:3": {1024, 1536}, "4:3": {1360, 1024}, "3:4": {1024, 1360}, "4:5": {1024, 1280}, "5:4": {1280, 1024}, "16:9": {1824, 1024}, "9:16": {1024, 1824}, "2:1": {2048, 1024}, "1:2": {1024, 2048}, "21:9": {2352, 1008}, "1:1-2k": {2048, 2048}, "16:9-2k": {2048, 1152}, "9:16-2k": {1152, 2048}, "16:9-4k": {3840, 2160}, "9:16-4k": {2160, 3840}} + if result, ok := presets[normalized]; ok { + return result, true + } + ratio, ok := aspectRatio(normalized).(string) + if !ok { + return [2]int{}, false + } + parts, ok := ratioParts(ratio) + if !ok { + return [2]int{}, false + } + longEdge := 1024 + if strings.HasSuffix(normalized, "-4k") { + longEdge = 3840 + } else if strings.HasSuffix(normalized, "-2k") { + longEdge = 2048 + } + if parts[0] >= parts[1] { + if longEdge == 1024 { + return [2]int{roundStep(1024*parts[0]/parts[1], 16), 1024}, true + } + return [2]int{longEdge, roundStep(longEdge*parts[1]/parts[0], 16)}, true + } + if longEdge == 1024 { + return [2]int{1024, roundStep(1024*parts[1]/parts[0], 16)}, true + } + return [2]int{roundStep(longEdge*parts[0]/parts[1], 16), longEdge}, true +} + +func videoDimensions(size, quality string) ([2]int, bool) { + ratio, ok := aspectRatio(size).(string) + if !ok { + return [2]int{}, false + } + parts, ok := ratioParts(ratio) + shortEdge := videoResolutionPixels(quality) + if !ok || shortEdge == 0 { + return [2]int{}, false + } + if parts[0] >= parts[1] { + return [2]int{roundStep(shortEdge*parts[0]/parts[1], 2), shortEdge}, true + } + return [2]int{shortEdge, roundStep(shortEdge*parts[1]/parts[0], 2)}, true +} + +func videoResolutionPixels(value any) int { + normalized := strings.ToLower(strings.Trim(strings.TrimSpace(stringValue(value)), `"`)) + switch normalized { + case "low": + return 480 + case "auto", "default", "medium", "high": + return 720 + case "2k": + return 1440 + case "4k": + return 2160 + } + parsed, _ := strconv.Atoi(strings.TrimSuffix(normalized, "p")) + return parsed +} + +func videoResolutionValue(field jsonMap, value any) any { + raw := strings.Trim(stringValue(value), `"`) + if raw == "" { + return nil + } + pixels := videoResolutionPixels(raw) + if pixels == 0 { + return value + } + candidates := []string{raw, strconv.Itoa(pixels), strconv.Itoa(pixels) + "p"} + if pixels == 1440 { + candidates = append(candidates, "2k") + } else if pixels == 2160 { + candidates = append(candidates, "4k") + } + for _, option := range sliceValue(field["options"]) { + for _, candidate := range candidates { + if strings.EqualFold(optionString(option), candidate) { + return option + } + } + } + defaultValue := firstValue(field["value"], field["fieldValue"], field["default"]) + if _, ok := defaultValue.(float64); ok { + return pixels + } + normalizedDefault := strings.ToLower(strings.Trim(stringValue(defaultValue), `"`)) + if strings.HasSuffix(normalizedDefault, "p") { + return strconv.Itoa(pixels) + "p" + } + if normalizedDefault == "2k" || normalizedDefault == "4k" { + if pixels == 1440 { + return "2k" + } + if pixels == 2160 { + return "4k" + } + } + return value +} + +func optionString(value any) string { + if item, ok := mapValue(value); ok { + for _, key := range []string{"value", "id", "key", "label", "name"} { + if candidate := strings.Trim(stringValue(item[key]), `"`); candidate != "" { + return candidate + } + } + } + return strings.Trim(stringValue(value), `"`) +} + +func gcd(left, right int) int { + for right != 0 { + left, right = right, left%right + } + if left <= 0 { + return 1 + } + return left +} + +func roundStep(value, step int) int { + return int(math.Round(float64(value)/float64(step))) * step +} + +func stringIn(value string, candidates ...string) bool { + for _, candidate := range candidates { + if value == candidate { + return true + } + } + return false +} + +func hasAnySuffix(value string, suffixes ...string) bool { + for _, suffix := range suffixes { + if strings.HasSuffix(value, suffix) { + return true + } + } + return false +} diff --git a/canvas-agent/scripts/build-comfy-bridge.ts b/canvas-agent/scripts/build-comfy-bridge.ts new file mode 100644 index 000000000..62e400a73 --- /dev/null +++ b/canvas-agent/scripts/build-comfy-bridge.ts @@ -0,0 +1,71 @@ +import { existsSync, openSync, closeSync, readSync } from "node:fs"; +import path from "node:path"; + +const outputDir = path.resolve(import.meta.dir, "../../web/public"); +const outputs = { + windows: path.join(outputDir, "OpenAICanvas-ComfyBridge.exe"), + linuxAmd64: path.join(outputDir, "OpenAICanvas-ComfyBridge-linux-amd64"), + linuxArm64: path.join(outputDir, "OpenAICanvas-ComfyBridge-linux-arm64"), +} as const; + +function isWindowsExecutable(file: string) { + if (!existsSync(file)) return false; + const descriptor = openSync(file, "r"); + try { + const header = Buffer.alloc(2); + return readSync(descriptor, header, 0, header.length, 0) === 2 && header[0] === 0x4d && header[1] === 0x5a; + } finally { + closeSync(descriptor); + } +} + +function isLinuxExecutable(file: string) { + if (!existsSync(file)) return false; + const descriptor = openSync(file, "r"); + try { + const header = Buffer.alloc(4); + return readSync(descriptor, header, 0, header.length, 0) === 4 + && header[0] === 0x7f + && header[1] === 0x45 + && header[2] === 0x4c + && header[3] === 0x46; + } finally { + closeSync(descriptor); + } +} + +function assertBuildOutput(target: keyof typeof outputs) { + const valid = target === "windows" + ? isWindowsExecutable(outputs[target]) + : isLinuxExecutable(outputs[target]); + if (!valid) throw new Error(`Bridge 构建产物缺失或格式无效:${path.basename(outputs[target])}`); +} + +function build(target: keyof typeof outputs, goos: string, goarch: string) { + const command = Bun.spawnSync({ + cmd: ["go", "build", "-trimpath", "-ldflags=-s -w", "-o", outputs[target], "."], + cwd: path.resolve(import.meta.dir, "../native/comfy-bridge"), + env: { + ...process.env, + GOOS: goos, + GOARCH: goarch, + CGO_ENABLED: "0", + GOTELEMETRY: "off", + }, + stdout: "inherit", + stderr: "inherit", + }); + if (command.exitCode !== 0) process.exit(command.exitCode); + assertBuildOutput(target); +} + +if (process.env.CANVAS_PREBUILT_BRIDGE === "1") { + assertBuildOutput("windows"); + assertBuildOutput("linuxAmd64"); + assertBuildOutput("linuxArm64"); + process.exit(0); +} + +build("windows", "windows", "amd64"); +build("linuxAmd64", "linux", "amd64"); +build("linuxArm64", "linux", "arm64"); diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx new file mode 100644 index 000000000..592d623c1 --- /dev/null +++ b/docs/content/docs/backend/backend-database.mdx @@ -0,0 +1,19 @@ +--- +title: 后端数据库 +description: 影策后端核心持久化表与状态边界 +--- + +# 后端数据库 + +后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。服务启动时由 `backend/internal/database/schema.go` 执行迁移;业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 + +## ComfyUI Bridge + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `comfy_bridges` | 保存用户注册的 Bridge 身份、Token 摘要、启用状态和心跳能力 | Token 明文只在注册时返回一次;查询和日志只使用摘要 | +| `comfy_bridge_requests` | 保存云端任务交给本地 Bridge 的请求、领取状态和完成结果 | `id` 同时作为任务的 `provider_request_id`;状态按 `queued -> claimed -> succeeded/failed/cancelled` 单向流转 | + +`comfy_bridge_requests` 是 Bridge 交接的真相源。后端进程重启后,未领取请求继续从数据库投递;已领取请求的迟到结果先落库,任务 worker 恢复后使用原请求 ID 读取结果,不会创建第二次本地工作流执行。请求有效期当前为两小时,过期的排队或执行记录会转为失败。 + +请求表保存工作流执行 payload 和结果,但不保存 Bridge Token。任务取消时,未领取请求可以确定取消;已经领取的本机工作流无法可靠中止,迟到结果会被丢弃并进入费用核对流程。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index afeaa6fa6..84ec56a7d 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## RunningHub 与 ComfyUI Bridge 工作流 + +- [x] 后端全量测试、前端类型检查、Canvas Agent 构建和原生 Bridge Go 测试通过。 +- [x] 覆盖后端重启后请求领取、结果先落库再恢复、原请求 ID 幂等复用,以及 RunningHub API Key 不进入 URL 的自动化测试。 +- [ ] 使用真实 RunningHub Workflow 和 App 分别验证参数发现、图片/视频/音频输入映射、提交、轮询、取消和结果下载。 +- [ ] 使用真实 ComfyUI API 工作流验证 Bridge 注册、心跳、领取、完成、取消和大文件结果回传。 +- [ ] 在 Bridge 执行中重启后端,确认结果回传成功且任务不重复执行;在请求未领取时重启后端,确认原请求继续投递。 +- [ ] 验证浅色/深色主题、窄屏设置页、画布工作流节点和任务中心状态展示。 + ## AI 提示词优化器 当前代码和生产构建已完成,但以下场景仍需要在真实配置和不同窗口尺寸下由用户确认: diff --git a/nginx.conf b/nginx.conf index 992ca6e55..594a4161a 100644 --- a/nginx.conf +++ b/nginx.conf @@ -103,6 +103,32 @@ server { try_files /index.html =404; } + # Bridge 是跨平台自包含程序,缺失时必须返回 404,不能被 SPA 回退替换成 index.html。 + location = /OpenAICanvas-ComfyBridge.exe { + default_type application/octet-stream; + add_header Cache-Control "no-cache" always; + add_header Content-Disposition 'attachment; filename="OpenAICanvas-ComfyBridge.exe"' always; + try_files $uri =404; + } + + location = /OpenAICanvas-ComfyBridge-linux-amd64 { + default_type application/octet-stream; + add_header Cache-Control "no-cache" always; + add_header Content-Disposition 'attachment; filename="OpenAICanvas-ComfyBridge-linux-amd64"' always; + try_files $uri =404; + } + + location = /OpenAICanvas-ComfyBridge-linux-arm64 { + default_type application/octet-stream; + add_header Cache-Control "no-cache" always; + add_header Content-Disposition 'attachment; filename="OpenAICanvas-ComfyBridge-linux-arm64"' always; + try_files $uri =404; + } + + location = /comfy-bridge.mjs { + return 410; + } + location ^~ /share/canvas/ { access_log off; try_files $uri $uri/ /index.html; diff --git a/web/package.json b/web/package.json index 97ed46f24..d374c8ba8 100644 --- a/web/package.json +++ b/web/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { + "build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts", "dev": "vite --host 0.0.0.0 --port 3000", - "build": "tsc --noEmit && vite build", + "build": "bun run build:bridge && tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", diff --git a/web/src/components/canvas/canvas-active-task-panel.tsx b/web/src/components/canvas/canvas-active-task-panel.tsx index 2c8f5260e..6e8868ff6 100644 --- a/web/src/components/canvas/canvas-active-task-panel.tsx +++ b/web/src/components/canvas/canvas-active-task-panel.tsx @@ -1,5 +1,5 @@ import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; -import { ChevronDown, ChevronUp, Clock3, Coins, ListTodo, LoaderCircle } from "lucide-react"; +import { ChevronDown, ChevronUp, Clock3, Coins, ListTodo, LoaderCircle, XCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { formatCredits } from "@/constant/credits"; @@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; // 顶栏是绝对定位浮层,面板必须按调用方传入的 topInset 避让;专注模式隐藏顶栏时传小间距。 -export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(--canvas-topbar-offset)" }: { tasks: GenerationTask[]; align?: "left" | "right"; topInset?: string }) { +export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var(--canvas-topbar-offset)", onCancelTask }: { tasks: GenerationTask[]; align?: "left" | "right"; topInset?: string; onCancelTask?: (task: GenerationTask) => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const reducedMotion = useReducedMotion(); @@ -96,6 +96,7 @@ export function CanvasActiveTaskPanel({ tasks, align = "right", topInset = "var( theme={theme} expanded={expandedTaskId === task.id} onToggle={() => setExpandedTaskId((current) => (current === task.id ? null : task.id))} + onCancelTask={onCancelTask} reducedMotion={Boolean(reducedMotion)} creditsEnabled={creditsEnabled} /> @@ -116,6 +117,7 @@ function ActiveTaskCard({ theme, expanded, onToggle, + onCancelTask, reducedMotion, creditsEnabled, }: { @@ -124,6 +126,7 @@ function ActiveTaskCard({ theme: (typeof canvasThemes)[keyof typeof canvasThemes]; expanded: boolean; onToggle: () => void; + onCancelTask?: (task: GenerationTask) => void; reducedMotion: boolean; creditsEnabled: boolean; }) { @@ -221,6 +224,21 @@ function ActiveTaskCard({ {generationTaskStageLabel(task)} + {onCancelTask && (task.status === "queued" || task.status === "running") ? ( + + ) : null} ) : null} diff --git a/web/src/components/canvas/canvas-config-composer.tsx b/web/src/components/canvas/canvas-config-composer.tsx index 97f1affff..194766a25 100644 --- a/web/src/components/canvas/canvas-config-composer.tsx +++ b/web/src/components/canvas/canvas-config-composer.tsx @@ -4,6 +4,7 @@ import { Button, Image } from "antd"; import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, Video, X } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; +import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import { useThemeStore } from "@/stores/use-theme-store"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { NodeGenerationInput } from "./canvas-node-generation"; @@ -52,6 +53,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene const [imagePreview, setImagePreview] = useState(null); const [presetOpen, setPresetOpen] = useState(false); const simpleMode = workspaceMode === "simple"; + const workflowVideoReferenceMode = generationMode === "video" && isCanvasWorkflowProvider(metadata); const tokens = useMemo(() => parseComposerTokens(value), [value]); const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]); const videoFrameOptions = useMemo( @@ -168,7 +170,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
{simpleMode ? "快速生成" : "组装提示词"}
-
{simpleMode ? "已连接素材会自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}
+
{simpleMode ? "已连接素材会自动带入" : workflowVideoReferenceMode ? "已连接媒体会按工作流字段顺序自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}
{simpleMode ? null : } @@ -177,7 +179,17 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
{generationMode === "video" && onMetadataChange && !simpleMode ? (
- + input.type === "image" || input.type === "character").length, + videoCount: inputs.filter((input) => input.type === "video").length, + audioCount: inputs.filter((input) => input.type === "audio").length, + }} + onMetadataChange={onMetadataChange} + />
) : null}
diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 9c30b51ec..1cba87dd1 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -1,14 +1,15 @@ -import type { CSSProperties } from "react"; -import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react"; -import { Button, InputNumber, Segmented, Select } from "antd"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { ChevronDown, Dice5, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Sparkles, Video, Workflow as WorkflowIcon } from "lucide-react"; +import { Button, Input, InputNumber, Segmented, Select, Slider, Switch, Tooltip } from "antd"; import { ModelPicker } from "@/components/model-picker"; -import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { configuredModelMatchesCapability, defaultConfig, modelOptionName, normalizeRunningHubCapability, resolveModelChannel, useEffectiveConfig, type AiConfig, type RunningHubCapability, type RunningHubWorkflow, type RunningHubWorkflowKind } from "@/stores/use-config-store"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; -import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; -import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection"; +import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities"; +import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection"; +import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -27,6 +28,15 @@ type CanvasConfigNodePanelProps = { workspaceMode?: CanvasWorkspaceMode; }; +type WorkflowSelectOption = { + label: ReactNode; + value?: string; + workflowId?: string; + kind?: RunningHubWorkflowKind; + title?: string; + options?: WorkflowSelectOption[]; +}; + const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperation }> = [ { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, @@ -40,12 +50,26 @@ const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperat { label: "版本对比", value: "compare_versions" }, ]; +function capabilityLabel(capability: RunningHubCapability) { + return capability === "video" ? "视频" : capability === "audio" ? "音频" : "图片"; +} + +function runningHubWorkflowKind(workflow: RunningHubWorkflow): RunningHubWorkflowKind { + return workflow.kind === "app" ? "app" : "workflow"; +} + +function runningHubWorkflowEntryKey(workflow: RunningHubWorkflow): string { + return `${runningHubWorkflowKind(workflow)}:${workflow.workflowId.trim()}`; +} + export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) { const globalConfig = useEffectiveConfig(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const mode = node.metadata?.generationMode || "image"; const simpleMode = workspaceMode === "simple"; + const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata); + const workflowCapability: RunningHubCapability = mode === "video" ? "video" : mode === "audio" ? "audio" : "image"; const requirements: ModelRequirements = { capability: mode, input: inputSummary, @@ -66,29 +90,96 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC }, mode), }; const config = buildNodeConfig(globalConfig, node, mode, requirements); - const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined; + const defaultWorkflowCapability = normalizeRunningHubCapability(globalConfig.runningHub.capability); + const selectedRunningHubWorkflow = globalConfig.runningHub.workflows.find((item) => ( + item.workflowId.trim() === node.metadata?.runningHubWorkflowId?.trim() + && (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind) + )); + const selectedRunningHubCapability = selectedRunningHubWorkflow ? normalizeRunningHubCapability(selectedRunningHubWorkflow.capability, defaultWorkflowCapability) : undefined; + const selectedComfyBridgeWorkflow = globalConfig.comfyBridge.workflows.find((item) => item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim()); + const selectedWorkflowFields: WorkflowVideoFieldLike[] = workflowProvider === "runninghub" + ? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson) + : workflowProvider === "comfyui" + ? selectedComfyBridgeWorkflow?.fields?.length ? selectedComfyBridgeWorkflow.fields : workflowVideoFieldsFromJson(selectedComfyBridgeWorkflow?.workflowJson) + : []; + const dynamicWorkflowFields = workflowParameterFields(selectedWorkflowFields); + useEffect(() => { + if (mode === "text") return; + if (workflowProvider === "runninghub" && !node.metadata?.runningHubWorkflowId?.trim()) { + const capability = normalizeRunningHubCapability(globalConfig.runningHub.capability); + // 模式切换后优先按当前能力选择工作流,不能沿用全局默认的视频工作流, + // 否则点击“生图”会在下一次渲染时被自动改回“视频”。 + const workflow = globalConfig.runningHub.workflows.find((item) => ( + normalizeRunningHubCapability(item.capability, capability) === workflowCapability + && item.workflowId.trim() === globalConfig.runningHub.workflowId.trim() + && runningHubWorkflowKind(item) === globalConfig.runningHub.selectedKind + )) + || globalConfig.runningHub.workflows.find((item) => normalizeRunningHubCapability(item.capability, capability) === workflowCapability); + if (workflow) { + const workflowMode = normalizeRunningHubCapability(workflow.capability, capability); + onConfigChange(node.id, { generationMode: workflowMode, runningHubWorkflowId: workflow.workflowId, runningHubWorkflowKind: runningHubWorkflowKind(workflow), workflowParameters: {} }); + } + return; + } + if (workflowProvider === "comfyui" && !node.metadata?.comfyBridgeWorkflowId?.trim()) { + const workflow = globalConfig.comfyBridge.workflows.find((item) => item.capability === workflowCapability); + if (workflow) onConfigChange(node.id, { generationMode: workflow.capability, comfyBridgeWorkflowId: workflow.workflowId, workflowParameters: {} }); + } + }, [globalConfig.comfyBridge.workflows, globalConfig.comfyBridge.workflowId, globalConfig.runningHub.capability, globalConfig.runningHub.selectedKind, globalConfig.runningHub.workflowId, globalConfig.runningHub.workflows, mode, node.id, node.metadata?.comfyBridgeWorkflowId, node.metadata?.runningHubWorkflowId, onConfigChange, workflowCapability, workflowProvider]); + const runningHubEntries = globalConfig.runningHub.workflows + // 正常情况下只显示当前模式的条目;旧画布已引用的错配条目保留在列表中, + // 这样用户可以看到并重新选择,而不是出现“选项消失”的死路。 + .filter((item) => normalizeRunningHubCapability(item.capability, defaultWorkflowCapability) === workflowCapability || ( + item.workflowId.trim() === node.metadata?.runningHubWorkflowId?.trim() + && (!node.metadata?.runningHubWorkflowKind || runningHubWorkflowKind(item) === node.metadata.runningHubWorkflowKind) + )) + .map((item) => ({ + label: item.title || item.workflowId, + value: runningHubWorkflowEntryKey(item), + workflowId: item.workflowId, + kind: runningHubWorkflowKind(item), + title: `${item.title || item.workflowId} · ${capabilityLabel(normalizeRunningHubCapability(item.capability, defaultWorkflowCapability))}`, + })); + const runningHubApps = runningHubEntries.filter((item) => item.kind === "app"); + const runningHubWorkflows = runningHubEntries.filter((item) => item.kind === "workflow"); + const runningHubOptions: WorkflowSelectOption[] = [ + ...(runningHubApps.length ? [{ label: , options: runningHubApps }] : []), + ...(runningHubWorkflows.length ? [{ label: , options: runningHubWorkflows }] : []), + ]; + const comfyBridgeEntries = globalConfig.comfyBridge.workflows + .filter((item) => item.capability === workflowCapability || item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim()) + .map((item) => ({ label: item.title || item.workflowId, value: item.workflowId, kind: "workflow" as const, title: `${item.title || item.workflowId} · ${capabilityLabel(item.capability)}` })); + const comfyBridgeOptions: WorkflowSelectOption[] = comfyBridgeEntries.length ? [{ label: , options: comfyBridgeEntries }] : []; + const videoProfile = mode === "video" ? (workflowProvider === "model" ? modelCapabilityConfigFor(config, config.model).video! : undefined) : undefined; const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions; const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const textCountValue = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1)))); const priceChannel = resolveModelChannel(config, config.model); const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 }); - const hasPrice = creditsEnabled && credits !== null; + const hasPrice = workflowProvider === "model" && creditsEnabled && credits !== null; const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount); const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); - const capabilityError = modelCompatibilityError(config, config.model, requirements); + const workflowParameterError = workflowProvider === "model" ? "" : 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) + : 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) + : modelCompatibilityError(config, config.model, requirements)); const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError; return ( -
event.stopPropagation()}> -
-
{simpleMode ? "快速生成" : "生成配置"}
- {simpleMode ? 自动配置 :
event.stopPropagation()}> +
event.stopPropagation()}> +
+
{simpleMode ? "快速生成" : workflowProvider === "model" ? "生成配置" : "工作流生成"}
+ {simpleMode ? 自动配置 :
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> onConfigChange(node.id, { generationMode: value as CanvasGenerationMode })} + onChange={(value) => onConfigChange(node.id, workflowProvider === "model" + ? { generationMode: value as CanvasGenerationMode } + : { generationMode: value as CanvasGenerationMode, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} })} options={[ { value: "image", @@ -131,7 +222,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
}
-
+
@@ -144,46 +235,129 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
{mode === "video" && !simpleMode ? ( -
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> - ( +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + {menu} +
+ )} + onChange={(value) => onConfigChange(node.id, { videoEditOperation: value })} + /> + ) : ( +
+ + 全能参考 + 已连接媒体自动映射 +
+ )}
) : null} {simpleMode ? ( -
将使用当前默认模型与生成参数
+
将使用当前默认模型与生成参数
) : ( -
event.stopPropagation()}> - onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} /> - {mode === "text" ? ( -
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> +
+ {mode !== "text" ?
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + { + const nextProvider = value as "model" | "runninghub" | "comfyui"; + if (nextProvider === "model") { + onConfigChange(node.id, { workflowProvider: "model", workflowTitle: undefined, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} }); + } else if (nextProvider === "runninghub") { + onConfigChange(node.id, { workflowProvider: "runninghub", workflowTitle: "RunningHub 工作流", comfyBridgeWorkflowId: undefined, workflowParameters: {} }); + } else { + onConfigChange(node.id, { workflowProvider: "comfyui", workflowTitle: "ComfyUI Bridge", runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, workflowParameters: {} }); + } + }} + /> +
: null} +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + {workflowProvider === "runninghub" ? ( + + className="canvas-compact-control !h-9 w-full" + value={selectedRunningHubWorkflow ? runningHubWorkflowEntryKey(selectedRunningHubWorkflow) : undefined} + title={selectedRunningHubWorkflow ? `${selectedRunningHubWorkflow.kind === "app" ? "App" : "工作流"} · ${selectedRunningHubWorkflow.title || selectedRunningHubWorkflow.workflowId}` : undefined} + placeholder={`选择${capabilityLabel(workflowCapability)}工作流或 App`} + showSearch + options={runningHubOptions} + optionFilterProp="label" + optionLabelProp="label" + labelRender={(selected) => { + const workflow = runningHubEntries.find((item) => item.value === selected.value); + return ; + }} + notFoundContent={`暂无${capabilityLabel(workflowCapability)}工作流`} + popupMatchSelectWidth={false} + virtual={false} + listHeight={320} + styles={{ popup: { root: { minWidth: 320, maxWidth: "min(420px, calc(100vw - 32px))" } } }} + optionRender={(option) => { + if (option.data.options) return option.label; + return ; + }} + onChange={(value) => { + const workflow = runningHubEntries.find((item) => item.value === value); + onConfigChange(node.id, { runningHubWorkflowId: workflow?.workflowId, runningHubWorkflowKind: workflow?.kind, workflowParameters: {} }); + }} + /> + ) : workflowProvider === "comfyui" ? ( + + className="canvas-compact-control !h-9 w-full" + value={node.metadata?.comfyBridgeWorkflowId} + title={selectedComfyBridgeWorkflow?.title || selectedComfyBridgeWorkflow?.workflowId} + placeholder={`选择${capabilityLabel(workflowCapability)}工作流`} + showSearch + options={comfyBridgeOptions} + optionFilterProp="label" + optionLabelProp="label" + labelRender={(selected) => item.value === selected.value)?.label || String(selected.label || "")} />} + notFoundContent={`暂无${capabilityLabel(workflowCapability)}工作流`} + popupMatchSelectWidth={false} + virtual={false} + listHeight={320} + styles={{ popup: { root: { minWidth: 320, maxWidth: "min(420px, calc(100vw - 32px))" } } }} + optionRender={(option) => { + if (option.data.options) return option.label; + return ; + }} + onChange={(value) => onConfigChange(node.id, { comfyBridgeWorkflowId: value, workflowParameters: {} })} + /> + ) : ( + onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} /> + )} + {workflowProvider === "model" && mode === "text" ? ( +
文本份数 onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" />
- ) : mode === "video" ? ( + ) : workflowProvider === "model" && mode === "video" ? ( onConfigChange(node.id, videoConfigPatch(key, value))} /> - ) : mode === "image" ? ( + ) : workflowProvider === "model" && mode === "image" ? ( onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} /> - ) : mode === "audio" ? ( + ) : workflowProvider === "model" && mode === "audio" ? ( onConfigChange(node.id, audioConfigPatch(key, value))} /> ) : null} +
)} - {capabilityError ?
{capabilityError}
: null} + {workflowProvider !== "model" && dynamicWorkflowFields.length ? : null} + + {capabilityError ?
{capabilityError}
: null}
+ {expanded ? fields.map((field) => { + const key = workflowFieldKey(field); + const randomKey = workflowFieldRandomKey(field); + const value = workflowFieldSubmissionValue(field, workflowFieldCurrentValue(field, values) ?? ""); + const randomEnabled = typeof values[randomKey] === "boolean" ? values[randomKey] === true : field.randomEnabled === true; + const rawFieldType = String(field.fieldType || "").toUpperCase(); + const fieldType = ["FLOAT", "INT", "INTEGER"].includes(rawFieldType) ? "NUMBER" : rawFieldType; + const options = workflowFieldChoiceValues(field); + const selectOptions = options.length ? options : fieldType === "SELECT" && value !== "" ? [value] : []; + const bounds = workflowFieldNumberBounds(field); + const numeric = fieldType === "NUMBER" || fieldType === "SLIDER" || typeof value === "number" || bounds.min !== undefined || bounds.max !== undefined || bounds.step !== undefined; + const numericValue = value === "" || !Number.isFinite(Number(value)) ? undefined : Number(value); + const valueError = randomEnabled ? "" : workflowFieldValueError(field, value); + const control = fieldType === "SELECT" || selectOptions.length ? ( + update(field, event.target.value)} /> + ); + return ( + + ); + }) : null} +
+ ); +} + +export function firstWorkflowParameterError(fields: WorkflowVideoFieldLike[], values: Record) { + for (const field of fields) { + const randomKey = workflowFieldRandomKey(field); + const randomEnabled = typeof values[randomKey] === "boolean" ? values[randomKey] === true : field.randomEnabled === true; + if (randomEnabled) continue; + const key = workflowFieldKey(field); + const value = workflowFieldCurrentValue(field, values) ?? ""; + const error = workflowFieldValueError(field, value); + if (error) return `${field.label || field.fieldName}:${error}`; + } + return ""; +} + +function workflowParameterOptionValue(value: unknown): string | number { + if (value && typeof value === "object" && !Array.isArray(value)) { + const item = value as Record; + for (const key of ["value", "id", "key", "name", "label"]) { + if (item[key] !== undefined && item[key] !== null) return typeof item[key] === "number" ? item[key] : String(item[key]); + } + } + return typeof value === "number" ? value : String(value ?? ""); +} + +function workflowParameterOptionLabel(value: unknown) { + return String(workflowParameterOptionValue(value)); +} + function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) { return (
@@ -239,7 +516,86 @@ function InputChip({ label, value, style }: { label: string; value: string; styl ); } +function WorkflowOptionGroupLabel({ label, count }: { label: string; count: number }) { + return {label}{count}; +} + +function WorkflowOptionLabel({ kind, label, title }: { kind: "app" | "workflow"; label: string; title: string }) { + return
+ {kind === "app" ? : } + {label} +