Files
fno2010-cm-cli-rest/docs/references/comfy-cli-rest-implementation.md
Jensen Zhang d7a15a7d5d Add comfy-cli REST API support
- Implement ComfyCliExecutor for async command execution
- Add REST API endpoints for comfy-cli:
  - Configuration management (/comfy-cli/config/*)
  - Model management (/comfy-cli/model/*)
  - Node management (/comfy-cli/node/*)
- Create API handlers: ConfigHandler, ModelHandler, NodeHandler
- Register routes in comfy_cli_routes.py
- Update main __init__.py to support dual CLI (cm-cli + comfy-cli)
- Add comprehensive documentation:
  - COMFY_CLI_API.md: Complete API reference
  - COMFY_CLI_QUICKSTART.md: Quick start guide
  - Update README.md with dual CLI support
  - Update AGENTS.md development guide
- Add comfy-cli reference documentation in docs/references/
- Remove trailing spaces from all Python and Markdown files
- Ensure all code passes syntax validation

Features:
- Async job tracking for long-running operations
- Structured JSON responses
- Comprehensive error handling
- Direct filesystem access for model listing (more reliable)
- Support for CivitAI and HuggingFace API tokens
2026-03-17 21:55:32 +08:00

27 KiB
Raw Permalink Blame History

comfy-cli REST API 实现指南

文档版本:1.0 最后更新:2026-03-16 目标:将 comfy-cli 命令转换为结构化 REST API


1. 架构设计

1.1 为什么需要 REST API?

问题 CLI 直接调用 REST API
输出格式 原始文本,需解析 结构化 JSON
异步任务 需手动管理进程 统一任务队列
错误处理 退出码 + stderr 统一错误响应
认证授权 无 可集成
并发控制 无 可限制

1.2 设计原则

  1. CLI 作为实现细节:外部调用者不应直接依赖 CLI 输出格式
  2. 结构化响应:所有响应都是 JSON,包含 success, data, error
  3. 异步任务统一处理:长时间运行的命令返回 job_id
  4. 解析层隔离:CLI 输出解析在内部完成,不暴露给调用者

1.3 系统架构

┌─────────────┐     ┌──────────────┐     ┌─────────────┐     ┌──────────┐
│   Client    │────▶│  REST API    │────▶│  Executor   │────▶│ comfy-cli│
│  (Browser/  │◀────│   Server     │◀────│   (Python)  │◀────│  (CLI)  │
│   App)      │     │  (aiohttp)   │     │             │     │          │
└─────────────┘     └──────────────┘     └─────────────┘     └──────────┘
                           │                    │
                           ▼                    ▼
                    ┌──────────────┐     ┌─────────────┐
                    │  Task Queue  │     │  Parsers    │
                    │  (in-memory) │     │  (CLI→JSON) │
                    └──────────────┘     └─────────────┘

2. 项目结构

comfy-cli-rest/
├── __init__.py              # 包初始化
├── server.py                # aiohttp 服务器入口
├── config.py                # 配置管理
├── executor.py              # CLI 执行器
├── parsers/                 # 输出解析器
│   ├── __init__.py
│   ├── base.py              # 解析器基类
│   ├── model_parser.py      # 模型命令解析
│   ├── node_parser.py       # 节点命令解析
│   └── env_parser.py        # 环境命令解析
├── handlers/                # API handlers
│   ├── __init__.py
│   ├── models.py            # 模型管理 API
│   ├── nodes.py             # 节点管理 API
│   ├── config.py            # 配置 API
│   └── jobs.py              # 任务管理 API
├── routes.py                # 路由注册
├── middleware.py            # 中间件(认证、日志、错误处理)
├── requirements.txt         # 依赖
└── README.md                # 使用说明

3. 核心组件实现

3.1 配置管理 (config.py)

from dataclasses import dataclass
from pathlib import Path
import json

@dataclass
class Config:
    workspace: str = "/basedir"
    comfy_cmd: str = "comfy"
    host: str = "0.0.0.0"
    port: int = 8189  # 注意:不是 8188(ComfyUI 端口)
    api_prefix: str = "/comfy-cli"
    max_concurrent_jobs: int = 5
    job_ttl_seconds: int = 3600

    @classmethod
    def from_file(cls, path: str) -> "Config":
        with open(path) as f:
            data = json.load(f)
        return cls(**data)

    @property
    def comfy_args(self) -> list:
        return [self.comfy_cmd, "--workspace", self.workspace]

# 默认配置
DEFAULT_CONFIG = Config()

3.2 CLI 执行器 (executor.py)

import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Dict, Optional, Any, List
from pathlib import Path
import logging

logger = logging.getLogger(__name__)

class JobStatus:
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    TIMEOUT = "timeout"

@dataclass
class Job:
    id: str
    command: List[str]
    status: str
    created_at: datetime
    started_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None
    stdout: Optional[str] = None
    stderr: Optional[str] = None
    return_code: Optional[int] = None
    error: Optional[str] = None

class ComfyCliExecutor:
    """CLI 命令执行器,管理异步任务"""

    def __init__(self, config: Config):
        self.config = config
        self.jobs: Dict[str, Job] = {}
        self._cleanup_task: Optional[asyncio.Task] = None

    async def start(self):
        """启动执行器(启动清理任务)"""
        self._cleanup_task = asyncio.create_task(self._cleanup_loop())

    async def stop(self):
        """停止执行器"""
        if self._cleanup_task:
            self._cleanup_task.cancel()
            try:
                await self._cleanup_task
            except asyncio.CancelledError:
                pass

    async def execute(
        self,
        command: List[str],
        async_mode: bool = True,
        timeout: Optional[int] = None
    ) -> Dict[str, Any]:
        """执行 CLI 命令"""
        job_id = str(uuid.uuid4())
        full_cmd = self.config.comfy_args + command

        job = Job(
            id=job_id,
            command=full_cmd,
            status=JobStatus.PENDING,
            created_at=datetime.utcnow()
        )
        self.jobs[job_id] = job

        if async_mode:
            # 异步执行,立即返回 job_id
            asyncio.create_task(self._run_job(job, timeout))
            return {
                "job_id": job_id,
                "status": JobStatus.PENDING,
                "message": f"Command queued: {' '.join(command)}"
            }
        else:
            # 同步执行,等待完成
            await self._run_job(job, timeout)
            return self._job_to_response(job)

    async def _run_job(self, job: Job, timeout: Optional[int] = None):
        """后台运行任务"""
        job.status = JobStatus.RUNNING
        job.started_at = datetime.utcnow()

        try:
            process = await asyncio.create_subprocess_exec(
                *job.command,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=self.config.workspace
            )

            try:
                stdout, stderr = await asyncio.wait_for(
                    process.communicate(),
                    timeout=timeout
                )
                job.stdout = stdout.decode('utf-8', errors='replace')
                job.stderr = stderr.decode('utf-8', errors='replace')
                job.return_code = process.returncode
                job.status = (
                    JobStatus.COMPLETED if process.returncode == 0
                    else JobStatus.FAILED
                )
            except asyncio.TimeoutError:
                process.kill()
                await process.communicate()
                job.status = JobStatus.TIMEOUT
                job.error = f"Command timed out after {timeout}s"

        except Exception as e:
            job.status = JobStatus.FAILED
            job.error = str(e)
            logger.exception(f"Job {job.id} failed")

        job.completed_at = datetime.utcnow()

    def _job_to_response(self, job: Job) -> Dict[str, Any]:
        """将任务转换为 API 响应"""
        if job.status == JobStatus.COMPLETED:
            return {
                "job_id": job.id,
                "status": job.status,
                "stdout": job.stdout,
                "stderr": job.stderr,
                "return_code": job.return_code
            }
        elif job.status == JobStatus.FAILED:
            return {
                "job_id": job.id,
                "status": job.status,
                "error": job.error or job.stderr,
                "stdout": job.stdout
            }
        else:
            return {
                "job_id": job.id,
                "status": job.status,
                "message": job.error
            }

    def get_job(self, job_id: str) -> Optional[Job]:
        """获取任务状态"""
        return self.jobs.get(job_id)

    def remove_job(self, job_id: str) -> bool:
        """移除任务"""
        if job_id in self.jobs:
            del self.jobs[job_id]
            return True
        return False

    async def _cleanup_loop(self):
        """定期清理过期任务"""
        while True:
            await asyncio.sleep(60)  # 每分钟清理一次
            now = datetime.utcnow()
            expired = [
                job_id for job_id, job in self.jobs.items()
                if job.completed_at and
                   (now - job.completed_at).total_seconds() > self.config.job_ttl_seconds
            ]
            for job_id in expired:
                del self.jobs[job_id]
            if expired:
                logger.info(f"Cleaned up {len(expired)} expired jobs")

3.3 解析器基类 (parsers/base.py)

from abc import ABC, abstractmethod
from typing import Any, Dict, Optional

class ParseError(Exception):
    """解析错误"""
    pass

class BaseParser(ABC):
    """CLI 输出解析器基类"""

    @abstractmethod
    def parse(self, raw_output: str, raw_error: str = "") -> Dict[str, Any]:
        """解析 CLI 输出为结构化数据"""
        pass

    def safe_parse(self, raw_output: str, raw_error: str = "") -> Dict[str, Any]:
        """安全解析,失败时返回原始数据"""
        try:
            return {
                "parsed": self.parse(raw_output, raw_error),
                "parse_success": True
            }
        except ParseError as e:
            return {
                "parsed": {"raw": raw_output},
                "parse_success": False,
                "parse_error": str(e)
            }

3.4 模型解析器 (parsers/model_parser.py)

from .base import BaseParser, ParseError
from typing import List, Dict, Any
from pathlib import Path
import re

class ModelListParser(BaseParser):
    """解析 `comfy model list` 输出"""

    def parse(self, raw_output: str, raw_error: str = "") -> Dict[str, Any]:
        if not raw_output.strip():
            return {"models": [], "count": 0}

        # 每行一个文件名
        models = [
            line.strip()
            for line in raw_output.strip().split('\n')
            if line.strip() and not line.startswith('#')
        ]

        return {
            "models": models,
            "count": len(models)
        }

class ModelDownloadParser(BaseParser):
    """解析 `comfy model download` 输出"""

    def parse(self, raw_output: str, raw_error: str = "") -> Dict[str, Any]:
        result = {
            "status": "unknown",
            "progress": 0,
            "filename": None,
            "path": None
        }

        # 检查错误
        if raw_error:
            result["status"] = "error"
            result["error"] = raw_error.strip()
            return result

        # 检查完成消息
        if "Successfully downloaded" in raw_output:
            result["status"] = "completed"
            result["progress"] = 100

            # 尝试提取文件名
            match = re.search(r'Successfully downloaded (.+) to (.+)', raw_output)
            if match:
                result["filename"] = match.group(1)
                result["path"] = match.group(2)

        # 检查进度
        progress_match = re.search(r'(\d+)%', raw_output)
        if progress_match:
            result["status"] = "downloading"
            result["progress"] = int(progress_match.group(1))

        return result

3.5 模型 Handler (handlers/models.py)

from aiohttp import web
from typing import Dict, Any
import logging
from pathlib import Path

from ..executor import ComfyCliExecutor, JobStatus
from ..parsers.model_parser import ModelListParser, ModelDownloadParser

logger = logging.getLogger(__name__)

class ModelHandler:
    def __init__(self, executor: ComfyCliExecutor, workspace: str = "/basedir"):
        self.executor = executor
        self.workspace = Path(workspace)
        self.list_parser = ModelListParser()
        self.download_parser = ModelDownloadParser()

    async def list_models(self, request: web.Request) -> web.Response:
        """
        GET /comfy-cli/model/list?relative_path=models/checkpoints

        响应:
        {
            "success": true,
            "data": {
                "path": "models/checkpoints",
                "models": [
                    {"filename": "model1.safetensors", "size": 6200000000},
                    ...
                ],
                "count": 5
            }
        }
        """
        relative_path = request.query.get("relative_path", "models/checkpoints")
        use_direct_fs = request.query.get("method", "cli") == "fs"

        try:
            if use_direct_fs:
                # 方法 1:直接文件系统访问(更可靠)
                data = await self._list_models_fs(relative_path)
            else:
                # 方法 2:CLI 调用
                result = await self.executor.execute(
                    ["model", "list", "--relative-path", relative_path],
                    async_mode=False
                )

                if result["status"] != JobStatus.COMPLETED:
                    return web.json_response({
                        "success": False,
                        "error": {"code": "EXECUTION_FAILED", "message": result.get("error")}
                    }, status=500)

                # 解析 CLI 输出
                parsed = self.list_parser.safe_parse(result["stdout"], result.get("stderr", ""))

                # 增强:获取文件详细信息
                models_detail = await self._enhance_models_info(
                    parsed["parsed"].get("models", []),
                    relative_path
                )

                data = {
                    "path": relative_path,
                    "models": models_detail,
                    "count": len(models_detail),
                    "cli_output": parsed
                }

            return web.json_response({
                "success": True,
                "data": data
            })

        except Exception as e:
            logger.exception("list_models failed")
            return web.json_response({
                "success": False,
                "error": {"code": "INTERNAL_ERROR", "message": str(e)}
            }, status=500)

    async def _list_models_fs(self, relative_path: str) -> Dict[str, Any]:
        """直接文件系统方式列出模型"""
        full_path = self.workspace / relative_path

        if not full_path.exists():
            return {"path": relative_path, "models": [], "count": 0, "error": "Path not found"}

        models = []
        for f in full_path.iterdir():
            if f.is_file() and f.suffix in ['.safetensors', '.ckpt', '.pt', '.bin']:
                stat = f.stat()
                models.append({
                    "filename": f.name,
                    "size": stat.st_size,
                    "size_human": self._format_size(stat.st_size),
                    "modified": stat.st_mtime
                })

        return {
            "path": relative_path,
            "absolute_path": str(full_path),
            "models": sorted(models, key=lambda x: x["filename"]),
            "count": len(models)
        }

    async def download_model(self, request: web.Request) -> web.Response:
        """
        POST /comfy-cli/model/download

        请求体:
        {
            "url": "https://...",
            "relative_path": "models/checkpoints",
            "filename": "optional.safetensors",
            "civitai_api_token": "optional"
        }

        响应(202 Accepted):
        {
            "success": true,
            "job_id": "uuid",
            "status": "pending"
        }
        """
        try:
            data = await request.json()
            url = data.get("url")

            if not url:
                return web.json_response({
                    "success": False,
                    "error": {"code": "MISSING_URL", "message": "url is required"}
                }, status=400)

            # 构建 CLI 命令
            cmd = ["model", "download", "--url", url]

            if data.get("relative_path"):
                cmd.extend(["--relative-path", data["relative_path"]])
            if data.get("filename"):
                cmd.extend(["--filename", data["filename"]])
            if data.get("civitai_api_token"):
                cmd.extend(["--set-civitai-api-token", data["civitai_api_token"]])
            if data.get("hf_api_token"):
                cmd.extend(["--set-hf-api-token", data["hf_api_token"]])

            # 异步执行
            result = await self.executor.execute(cmd, async_mode=True, timeout=1800)

            return web.json_response({
                "success": True,
                "job_id": result["job_id"],
                "status": result["status"],
                "message": result["message"]
            }, status=202)

        except Exception as e:
            logger.exception("download_model failed")
            return web.json_response({
                "success": False,
                "error": {"code": "INTERNAL_ERROR", "message": str(e)}
            }, status=500)

    async def get_download_status(self, request: web.Request) -> web.Response:
        """
        GET /comfy-cli/model/download/{job_id}/status

        响应:
        {
            "success": true,
            "data": {
                "job_id": "uuid",
                "status": "running|completed|failed",
                "progress": 45,
                "parsed_output": {...}
            }
        }
        """
        job_id = request.match_info["job_id"]

        job = self.executor.get_job(job_id)
        if not job:
            return web.json_response({
                "success": False,
                "error": {"code": "JOB_NOT_FOUND", "message": f"Job {job_id} not found"}
            }, status=404)

        response_data = {
            "job_id": job_id,
            "status": job.status,
            "created_at": job.created_at.isoformat(),
        }

        if job.status == JobStatus.RUNNING:
            # 解析进度
            parsed = self.download_parser.safe_parse(job.stdout or "", job.stderr or "")
            response_data["progress"] = parsed["parsed"].get("progress", 0)
            response_data["parsed_output"] = parsed["parsed"]

        elif job.status == JobStatus.COMPLETED:
            parsed = self.download_parser.safe_parse(job.stdout or "", job.stderr or "")
            response_data["parsed_output"] = parsed["parsed"]
            response_data["parse_success"] = parsed["parse_success"]

        elif job.status == JobStatus.FAILED:
            response_data["error"] = job.error or job.stderr

        return web.json_response({
            "success": True,
            "data": response_data
        })

    async def _enhance_models_info(self, model_names: List[str], relative_path: str) -> List[Dict]:
        """增强模型信息(添加文件大小等)"""
        full_path = self.workspace / relative_path
        result = []

        for name in model_names:
            model_file = full_path / name
            if model_file.exists():
                stat = model_file.stat()
                result.append({
                    "filename": name,
                    "size": stat.st_size,
                    "size_human": self._format_size(stat.st_size),
                    "modified": stat.st_mtime
                })
            else:
                result.append({"filename": name, "size": None, "error": "File not found"})

        return result

    def _format_size(self, bytes: int) -> str:
        for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
            if bytes < 1024:
                return f"{bytes:.1f} {unit}"
            bytes /= 1024
        return f"{bytes:.1f} TB"

4. 服务器入口 (server.py)

from aiohttp import web
import logging
from config import Config, DEFAULT_CONFIG
from executor import ComfyCliExecutor
from handlers.models import ModelHandler
from handlers.nodes import NodeHandler
from handlers.jobs import JobHandler
from routes import setup_routes
from middleware import setup_middleware

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

async def create_app(config: Config = DEFAULT_CONFIG) -> web.Application:
    """创建应用"""
    app = web.Application()

    # 创建共享组件
    executor = ComfyCliExecutor(config)
    await executor.start()

    # 创建 handlers
    model_handler = ModelHandler(executor, config.workspace)
    node_handler = NodeHandler(executor, config.workspace)
    job_handler = JobHandler(executor)

    # 存储到 app,便于清理
    app['executor'] = executor
    app['handlers'] = {
        'model': model_handler,
        'node': node_handler,
        'job': job_handler
    }

    # 设置中间件
    setup_middleware(app)

    # 设置路由
    setup_routes(app, model_handler, node_handler, job_handler)

    # 生命周期钩子
    app.on_shutdown.append(on_shutdown)

    return app

async def on_shutdown(app: web.Application):
    """清理资源"""
    await app['executor'].stop()

def main():
    app = web.run_app(create_app(), host=DEFAULT_CONFIG.host, port=DEFAULT_CONFIG.port)

if __name__ == '__main__':
    main()

5. 中间件 (middleware.py)

from aiohttp import web
import logging
import time
import json

logger = logging.getLogger(__name__)

@web.middleware
async def logging_middleware(request: web.Request, handler):
    """请求日志"""
    start = time.time()
    try:
        response = await handler(request)
        duration = time.time() - start
        logger.info(f"{request.method} {request.path} - {response.status} - {duration:.3f}s")
        return response
    except Exception as e:
        logger.exception(f"{request.method} {request.path} - Error: {e}")
        raise

@web.middleware
async def error_handler_middleware(request: web.Request, handler):
    """统一错误处理"""
    try:
        return await handler(request)
    except web.HTTPException:
        raise
    except Exception as e:
        logger.exception(f"Unhandled error in {request.path}")
        return web.json_response({
            "success": False,
            "error": {
                "code": "INTERNAL_ERROR",
                "message": "An unexpected error occurred",
                "details": str(e) if request.app['debug'] else None
            }
        }, status=500)

@web.middleware
async def json_body_middleware(request: web.Request, handler):
    """自动解析 JSON 请求体"""
    if request.content_type == 'application/json':
        try:
            request['json'] = await request.json()
        except json.JSONDecodeError:
            return web.json_response({
                "success": False,
                "error": {"code": "INVALID_JSON", "message": "Invalid JSON in request body"}
            }, status=400)
    return await handler(request)

def setup_middleware(app: web.Application):
    app.middlewares.append(logging_middleware)
    app.middlewares.append(error_handler_middleware)
    app.middlewares.append(json_body_middleware)

6. 使用示例

6.1 启动服务器

# 安装依赖
pip install aiohttp

# 启动
python -m comfy_cli_rest.server

# 或使用配置
python -c "
from comfy_cli_rest.server import create_app
from comfy_cli_rest.config import Config
from aiohttp import web

config = Config.from_file('config.json')
app = create_app(config)
web.run_app(app, host=config.host, port=config.port)
"

6.2 API 调用示例

# 列出模型
curl "http://localhost:8189/comfy-cli/model/list?relative_path=models/checkpoints"

# 下载模型
curl -X POST "http://localhost:8189/comfy-cli/model/download" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://civitai.com/models/43331",
    "relative_path": "models/checkpoints"
  }'

# 查询任务状态
curl "http://localhost:8189/comfy-cli/jobs/{job_id}"

# 安装节点
curl -X POST "http://localhost:8189/comfy-cli/node/install" \
  -H "Content-Type: application/json" \
  -d '{"nodes": ["ComfyUI-Impact-Pack"]}'

6.3 Python 客户端

import aiohttp
import asyncio

class ComfyCliClient:
    def __init__(self, base_url: str = "http://localhost:8189"):
        self.base_url = base_url

    async def list_models(self, relative_path: str = "models/checkpoints"):
        async with aiohttp.ClientSession() as session:
            async with session.get(
                f"{self.base_url}/comfy-cli/model/list",
                params={"relative_path": relative_path}
            ) as resp:
                data = await resp.json()
                return data["data"]["models"]

    async def download_model(self, url: str, **kwargs):
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{self.base_url}/comfy-cli/model/download",
                json={"url": url, **kwargs}
            ) as resp:
                return await resp.json()

    async def wait_for_job(self, job_id: str, poll_interval: float = 2.0):
        async with aiohttp.ClientSession() as session:
            while True:
                async with session.get(
                    f"{self.base_url}/comfy-cli/jobs/{job_id}"
                ) as resp:
                    data = await resp.json()
                    status = data["data"]["status"]

                    if status in ["completed", "failed", "timeout"]:
                        return data

                    await asyncio.sleep(poll_interval)

# 使用示例
async def main():
    client = ComfyCliClient()

    # 列出模型
    models = await client.list_models()
    print(f"Found {len(models)} models")

    # 下载模型并等待完成
    result = await client.download_model("https://...")
    job_id = result["job_id"]

    final = await client.wait_for_job(job_id)
    print(f"Download {final['data']['status']}")

asyncio.run(main())

7. 部署建议

7.1 Docker Compose

version: '3.8'

services:
  comfyui:
    image: mmartial/comfyui-nvidia-docker:latest
    container_name: comfyui
    # ... 其他配置

  comfy-cli-rest:
    build: ./comfy-cli-rest
    container_name: comfy-cli-rest
    volumes:
      - ./basedir:/basedir
    environment:
      - COMFY_WORKSPACE=/basedir
      - CIVITAI_API_TOKEN=${CIVITAI_API_TOKEN}
    ports:
      - "8189:8189"
    depends_on:
      - comfyui

7.2 安全建议

  1. 不要暴露到公网:默认监听 127.0.0.1
  2. 添加 API 认证:使用 API key 或 JWT
  3. 限制命令白名单:只允许安全的 CLI 命令
  4. 速率限制:防止滥用

由 Zero (零号) 生成 | 2026-03-16