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

891 lines
27 KiB
Markdown
Raw Permalink 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.
# 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`)
```python
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`)
```python
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`)
```python
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`)
```python
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`)
```python
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`)
```python
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`)
```python
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 启动服务器
```bash
# 安装依赖
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 调用示例
```bash
# 列出模型
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 客户端
```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
```yaml
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*