Files
ttstd-infinite-canvas/canvas-agent/native/comfy-bridge/main.go

669 lines
18 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 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 ""
}