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