Files
ttstd-infinite-canvas/backend/internal/app/runninghub_management.go

562 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
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
}