Skill: zeron-g/comfyui-painter (version 2.0.0)

--- name: comfyui-painter version: "2.0.0" description: "本地 ComfyUI 画图工作流 + CivitAI 集成。通过 API 控制本地 ComfyUI 生成图片（文生图/图生视频），支持 CivitAI 模型搜索/下载/更新检查/自动调参。Use when: 用户说画图、生成图片、generate image、comfyui、换模型、调参数、出图、搜模型、下载模型、c站、civitai，或在 Discord #🎨-画图 频道的任何消息。NOT for: 非图片生成相关的请求。" --- # ComfyUI Painter v2.0 通过 ComfyUI API 在本地 RTX 5090 上生成图片，集成 CivitAI 模型管理。 ## 工作流程 1. 检查 ComfyUI 是否运行，未运行则自动启动 2. 解析用户需求：提示词、模型、参数 3. **自动调参**：根据模型从 CivitAI 获取推荐参数（或使用 config 缓存） 4. 调用 generate.py 生成图片 5. 将图片发送到 Discord 6. 心跳时调用 auto_shutdown.py 检查空闲关闭 ## 脚本路径 所有脚本相对于此 skill 目录： - `scripts/comfyui_manager.py` — 启动/关闭/状态 - `scripts/generate.py` — 生成图片（Python API 调用） - `scripts/auto_shutdown.py` — 空闲自动关闭检查 - `scripts/civitai.py` — **CivitAI 集成**（搜索/详情/下载/更新检查/推荐参数） - `scripts/auto_tune.py` — **自动调参**（从 CivitAI 样图元数据提取推荐设置） - `config.json` — 配置文件（模型别名、默认参数、路径、CivitAI 关联） ## 使用方式 ### 启动 ComfyUI ```python import scripts.comfyui_manager as mgr mgr.start() # 自动从 WSL 启动 Windows ComfyUI 进程 ``` ### 生成图片 ```python from scripts.generate import generate result = generate( positive="your prompt here", model="noobv6", # 见下方模型别名 batch_size=4, width=1024, height=1536, steps=20, cfg=5, ) # result = {"ok": True, "images": ["/mnt/d/.../sdi_00201_.png", ...]} ``` ### 带自动调参生成 ```python from scripts.auto_tune import get_tuned_params from scripts.generate import generate params = get_tuned_params("janku") # 从 CivitAI 获取推荐参数 result = generate( positive="your prompt", model="janku", steps=params["steps"], cfg=params["cfg"], width=params["width"], height=params["height"], ) ``` ### CivitAI 搜索模型 ```bash python3 scripts/civitai.py search "anime checkpoint" --limit 5 ``` ### CivitAI 查看模型详情 ```bash python3 scripts/civitai.py info 140272 ``` ### CivitAI 获取推荐参数 ```bash python3 scripts/civitai.py params 140272 ``` ### CivitAI 检查本地模型更新 ```bash python3 scripts/civitai.py check-updates ``` > ⚠️ 需要计算 SHA256，每个文件约 30 秒 ### CivitAI 下载模型 ```bash python3 scripts/civitai.py download "https://civitai.com/api/download/models/XXXXX" --filename model_name.safetensors ``` ### 更新所有模型的推荐参数到 config ```bash python3 scripts/auto_tune.py update-all ``` ### 发送图片到 Discord 生成完成后，将图片 cp 到 workspace 临时目录，用 message tool 发送到 #🎨-画图 频道（channel:1476675131404193823）。 ### 关闭 ComfyUI ```python import scripts.comfyui_manager as mgr mgr.stop() ``` ## 模型别名 | 别名 | Checkpoint | CivitAI ID | 版本 | 风格 | |------|-----------|------------|------|------| | hassaku | hassakuXLIllustrious_v34 | 140272 | v3.4 | Illustrious 动漫 | | noobv6 | pornmasterPro_noobV6 | 1045588 | noob-V6 | NoobAI 动漫（默认）| | noobv4 | pornmasterPro_noobV4 | 1045588 | noob-V4 | NoobAI 动漫（旧版）| | sdxlv8 | pornmaster_proSDXLV8 | 82543 | Pro-SDXL-V8 | SDXL 写实 | | janku | JANKUTrainedNoobaiRouwei_v69 | 1277670 | v6.9 | NoobAI+RouWei 混合 | | nova3d | nova3DCGXL_ilV80 | 715287 | IL v8.0 | 3DCG/2.5D | | unholy | unholyDesireMixSinister_v70 | 1307857 | v7.0 | 暗黑美学 | | flux | flux1-dev-bnb-nf4-v2 | 638187 | BNB NF4 v2 | Flux 写实 | ## 自动调参（model_params） 每个模型在 config.json 中有 CivitAI 推荐的最佳参数。生成时： 1. 用户指定的参数优先 2. 未指定则使用 `config.json → model_params[alias]` 的推荐值 3. model_params 也没有则使用 `defaults` 全局默认 | 模型 | Steps | CFG | 采样器 | |------|-------|-----|--------| | hassaku | 20 | 7 | euler_a | | janku | 20 | 7 | euler_a | | noobv6 | 20 | 7 | euler_a | | sdxlv8 | 25 | 5 | dpmpp_2m | | nova3d | 20 | 7 | euler_a | | unholy | 20 | 7 | euler_a | | flux | 20 | 1 | euler | ## CivitAI 配置 API Key 存于 `~/.openclaw/workspace/credentials/civitai.md`，格式： ``` # CivitAI API Key [REDACTED_SECRET] ``` ## 提示词优化指南 用户给自然语言描述时，转换为专业 Stable Diffusion 提示词： - 质量标签在前：`masterpiece, best quality, absurdres, highres` - 用 `BREAK` 分隔不同语义段 - 动作/姿势用专业标签：`mating press, pov, from below, spread legs` - 细节标签：`detailed skin, sweat, wet skin, motion blur, dynamic angle` - 负面提示词加入：`censored, mosaic censoring, bar censor` ## 心跳集成 每次心跳时运行 auto_shutdown.py 检查空闲时间，超过 15 分钟无使用自动关闭 ComfyUI 释放显存。

path: DESIGN.md
content: # ComfyUI Painter Skill - 设计文档

## 需求
为 Eva 创建一个 ComfyUI 本地画图 skill，集成到 Discord 的 #🎨-画图 频道。

## 环境信息
- **ComfyUI 路径**: `D:\program\aimodel\ComfyUI-aki-v1.7\ComfyUI`
- **Python 路径**: `D:\program\aimodel\ComfyUI-aki-v1.7\python\python.exe`
- **API 地址**: `http://localhost:8188`
- **输出目录**: `D:\program\aimodel\ComfyUI-aki-v1.7\ComfyUI\output\`
- **Discord 频道 ID**: `1476675131404193823` (#🎨-画图)
- **Discord Bot Token**: 从 `~/.openclaw/openclaw.json` 读取
- **GPU**: NVIDIA RTX 5090 32GB
- **系统**: WSL2 Ubuntu 调用 Windows PowerShell 启动进程

## 可用模型 (checkpoints)
- `sd\hassakuXLIllustrious_v32.safetensors` — Illustrious 风格
- `sd\pornmasterPro_noobV4.safetensors` — noobV4
- `sd\pornmaster_proSDXLV8.safetensors` — SDXL v8

## 可用 LoRA
- `WAN_dr34mj0b.safetensors`
- `ahegao_face-14b.safetensors`
- 其他 wan 相关 LoRA

## 已验证的工作流模板
用户已配置好以下工作流 JSON（在 `ComfyUI/user/default/workflows/`）：
- `tti.json` — 文生图 (Text to Image)
- `text_to_video_wan.json` — 文生视频
- `image_to_video_wan_480p_example.json` — 图生视频 480p
- `image_to_video_wan_720p_example.json` — 图生视频 720p

## 已验证的 API 调用方式

### 启动 ComfyUI（从 WSL）
```bash
/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -Command "Start-Process -FilePath 'D:\program\aimodel\ComfyUI-aki-v1.7\python\python.exe' -ArgumentList 'D:\program\aimodel\ComfyUI-aki-v1.7\ComfyUI\main.py','--listen','0.0.0.0','--port','8188' -WorkingDirectory 'D:\program\aimodel\ComfyUI-aki-v1.7\ComfyUI' -WindowStyle Hidden"
```

### 检查状态
```bash
curl -s http://localhost:8188/system_stats
```

### 提交 prompt（API 格式）
```python
import json, urllib.request
data = json.dumps({"prompt": prompt_dict}).encode()
req = urllib.request.Request("http://localhost:8188/api/prompt", data=data, headers={"Content-Type": "application/json"})
resp = urllib.request.urlopen(req)
```

### 检查队列
```bash
curl -s http://localhost:8188/api/queue
```

### 关闭 ComfyUI
```powershell
Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force
```

## 功能要求

### 核心功能
1. **自动启动**: 收到画图指令时自动启动 ComfyUI（如果未运行）
2. **提示词处理**: 用户发自然语言描述 → Eva 转换为专业 SD 提示词
3. **模型切换**: 支持用户指定模型（hassaku/noobv4/sdxlv8）
4. **参数调节**: 支持调节 steps/cfg/sampler/尺寸/批次数
5. **生成并发送**: 生成完成后自动发送图片到 Discord
6. **自动关闭**: 超过一个心跳周期（15分钟）没有新指令则自动关闭 ComfyUI 进程

### 脚本设计
- `scripts/comfyui_manager.py` — 启动/关闭/状态检查 ComfyUI 进程
- `scripts/generate.py` — 构建 prompt、提交任务、等待完成、返回图片路径
- `scripts/auto_shutdown.py` — 定时检查，超时自动关闭（由心跳 cron 调用）

### 配置
- `config.json` — 默认模型、默认参数、Discord 频道 ID、超时时间等

### SKILL.md 触发条件
- 用户说"画图"、"生成图片"、"generate image"、"comfyui"等
- 在 #🎨-画图 频道的任何消息


path: config.json
content: {
  "comfyui": {
    "python_path": "D:\\program\\aimodel\\ComfyUI-aki-v1.7\\python\\python.exe",
    "main_path": "D:\\program\\aimodel\\ComfyUI-aki-v1.7\\ComfyUI\\main.py",
    "work_dir": "D:\\program\\aimodel\\ComfyUI-aki-v1.7\\ComfyUI",
    "output_dir_linux": "/mnt/d/program/aimodel/ComfyUI-aki-v1.7/ComfyUI/output",
    "api_url": "http://localhost:8188",
    "port": 8188,
    "powershell": "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
  },
  "models": {
    "hassaku": "sd\\hassakuXLIllustrious_v34.safetensors",
    "noobv4": "sd\\pornmasterPro_noobV4.safetensors",
    "noobv6": "sd\\pornmasterPro_noobV6.safetensors",
    "sdxlv8": "sd\\pornmaster_proSDXLV8.safetensors",
    "janku": "sd\\JANKUTrainedNoobaiRouwei_v69.safetensors",
    "nova3d": "sd\\nova3DCGXL_ilV80.safetensors",
    "unholy": "sd\\unholyDesireMixSinister_v70.safetensors",
    "flux": "sd\\flux1-dev-bnb-nf4-v2.safetensors"
  },
  "civitai": {
    "api_key_file": "credentials/civitai.md",
    "checkpoints_dir": "/mnt/d/program/aimodel/ComfyUI-aki-v1.7/ComfyUI/models/checkpoints/sd",
    "known_models": {
      "hassaku": {
        "civitai_id": 140272,
        "version": "v3.4"
      },
      "noobv6": {
        "civitai_id": 1045588,
        "version": "noob-V6"
      },
      "sdxlv8": {
        "civitai_id": 82543,
        "version": "Pro-SDXL-V8"
      },
      "janku": {
        "civitai_id": 1277670,
        "version": "v6.9"
      },
      "nova3d": {
        "civitai_id": 715287,
        "version": "IL v8.0"
      },
      "unholy": {
        "civitai_id": 1307857,
        "version": "v7.0"
      },
      "flux": {
        "civitai_id": 638187,
        "version": "BNB NF4 v2"
      }
    }
  },
  "defaults": {
    "model": "noobv6",
    "width": 1024,
    "height": 1536,
    "steps": 20,
    "cfg": 5,
    "sampler": "dpmpp_2m",
    "scheduler": "normal",
    "batch_size": 4,
    "negative_prompt": "bad quality, worst quality, worst detail, sketch, censored, mosaic censoring, bar censor, artist name, signature, watermark, patreon username, patreon logo, extra limbs, bad anatomy, bad hands, deformed"
  },
  "discord": {
    "channel_id": "1476675131404193823"
  },
  "auto_shutdown_minutes": 15,
  "model_params": {
    "hassaku": {
      "steps": 20,
      "cfg": 7,
      "sampler": "euler_a",
      "scheduler": "normal",
      "width": 832,
      "height": 1216,
      "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "janku": {
      "steps": 20,
      "cfg": 7,
      "sampler": "euler_a",
      "scheduler": "normal",
      "width": 832,
      "height": 1216,
      "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "noobv4": {
      "steps": 20,
      "cfg": 7,
      "sampler": "euler_a",
      "scheduler": "normal",
      "width": 832,
      "height": 1216,
      "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "noobv6": {
      "steps": 20,
      "cfg": 7,
      "sampler": "euler_a",
      "scheduler": "normal",
      "width": 832,
      "height": 1216,
      "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "sdxlv8": {
      "steps": 25,
      "cfg": 5,
      "sampler": "dpmpp_2m",
      "scheduler": "karras",
      "width": 1024,
      "height": 1536,
      "negative_prompt": "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality"
    },
    "nova3d": {
      "steps": 20,
      "cfg": 7,
      "sampler": "euler_a",
      "scheduler": "normal",
      "width": 832,
      "height": 1216,
      "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "unholy": {
      "steps": 20,
      "cfg": 7,
      "sampler": "euler_a",
      "scheduler": "normal",
      "width": 832,
      "height": 1216,
      "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "flux": {
      "steps": 20,
      "cfg": 1,
      "sampler": "euler",
      "scheduler": "simple",
      "width": 1024,
      "height": 1024,
      "negative_prompt": ""
    }
  }
}

path: scripts/auto_shutdown.py
content: #!/usr/bin/env python3
"""ComfyUI 自动关闭：超过配置时间无使用则关闭进程"""

import json
import time
from pathlib import Path

CONFIG = json.loads((Path(__file__).parent.parent / "config.json").read_text())
LAST_USED = Path(__file__).parent.parent / ".last_used"
TIMEOUT_MIN = CONFIG.get("auto_shutdown_minutes", 15)


def check_and_shutdown() -> str:
    """检查是否需要关闭 ComfyUI"""
    from comfyui_manager import is_running, stop

    if not is_running():
        return "not_running"

    if not LAST_USED.exists():
        # 没有记录，执行关闭
        stop()
        return "shutdown_no_record"

    last_ts = float(LAST_USED.read_text().strip())
    elapsed_min = (time.time() - last_ts) / 60

    if elapsed_min >= TIMEOUT_MIN:
        stop()
        LAST_USED.unlink(missing_ok=True)
        return f"shutdown_idle_{elapsed_min:.0f}min"
    else:
        return f"active_last_used_{elapsed_min:.0f}min_ago"


if __name__ == "__main__":
    result = check_and_shutdown()
    print(result)


path: scripts/auto_tune.py
content: #!/usr/bin/env python3
"""Auto-tune generation parameters based on CivitAI model metadata.

Fetches recommended settings (sampler, steps, CFG, size, negative prompt)
from CivitAI sample images and applies them to generation.
"""

import json
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPT_DIR))

from civitai import get_recommended_params, _load_config, _save_config, _api_get


# Fallback defaults per base model type
BASE_MODEL_DEFAULTS = {
    "Illustrious": {
        "steps": 20,
        "cfg": 7,
        "sampler": "euler_a",
        "scheduler": "normal",
        "width": 832,
        "height": 1216,
        "negative_prompt": "worst quality, bad quality, lowres, bad anatomy, bad hands, text, error"
    },
    "SDXL 1.0": {
        "steps": 25,
        "cfg": 5,
        "sampler": "dpmpp_2m",
        "scheduler": "karras",
        "width": 1024,
        "height": 1536,
        "negative_prompt": "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality"
    },
    "Flux.1 D": {
        "steps": 20,
        "cfg": 1,
        "sampler": "euler",
        "scheduler": "simple",
        "width": 1024,
        "height": 1024,
        "negative_prompt": ""
    },
    "Pony": {
        "steps": 25,
        "cfg": 7,
        "sampler": "euler_a",
        "scheduler": "normal",
        "width": 832,
        "height": 1216,
        "negative_prompt": "score_4, score_3, score_2, score_1, worst quality"
    },
    "NoobAI": {
        "steps": 28,
        "cfg": 5.5,
        "sampler": "euler_a",
        "scheduler": "normal",
        "width": 832,
        "height": 1216,
        "negative_prompt": "worst quality, bad quality, lowres"
    }
}

# Known model ID mapping (local alias -> CivitAI model ID)
KNOWN_MODELS = {
    "hassaku": 140272,
    "janku": 1277670,
    "noobv4": 1045588,
    "noobv6": 1045588,
    "sdxlv8": 82543,
    "nova3d": 715287,
    "unholy": 1307857,
    "flux": 638187,
}

# Sampler name mapping: CivitAI -> ComfyUI
SAMPLER_MAP = {
    "euler a": "euler_ancestral",
    "euler": "euler",
    "dpm++ 2m": "dpmpp_2m",
    "dpm++ 2m karras": "dpmpp_2m",
    "dpm++ sde": "dpmpp_sde",
    "dpm++ sde karras": "dpmpp_sde",
    "dpm++ 2m sde": "dpmpp_2m_sde",
    "dpm++ 2s a": "dpmpp_2s_ancestral",
    "ddim": "ddim",
    "lcm": "lcm",
    "heun": "heun",
    "uni_pc": "uni_pc",
}

SCHEDULER_MAP = {
    "automatic": "normal",
    "karras": "karras",
    "exponential": "exponential",
    "sgm uniform": "sgm_uniform",
    "simple": "simple",
    "normal": "normal",
    "ddim": "ddim_uniform",
}


def _normalize_sampler(name):
    """Convert CivitAI sampler name to ComfyUI sampler name."""
    if not name:
        return None
    return SAMPLER_MAP.get(name.lower().strip(), name.lower().replace(" ", "_"))


def _normalize_scheduler(name):
    """Convert CivitAI scheduler name to ComfyUI scheduler name."""
    if not name:
        return None
    return SCHEDULER_MAP.get(name.lower().strip(), "normal")


def _parse_size(size_str):
    """Parse size string like '832x1216' into (width, height)."""
    if not size_str or "x" not in str(size_str):
        return None, None
    try:
        parts = str(size_str).split("x")
        return int(parts[0]), int(parts[1])
    except (ValueError, IndexError):
        return None, None


def get_tuned_params(model_alias):
    """Get auto-tuned parameters for a model alias.
    
    Tries CivitAI first, falls back to base model defaults.
    
    Returns:
        dict with keys: steps, cfg, sampler, scheduler, width, height, negative_prompt
    """
    model_id = KNOWN_MODELS.get(model_alias)
    
    if model_id:
        try:
            civitai_params = get_recommended_params(model_id)
            
            # Also get base model type
            model_data = _api_get(f"models/{model_id}")
            base_model = model_data["modelVersions"][0].get("baseModel", "")
            defaults = BASE_MODEL_DEFAULTS.get(base_model, BASE_MODEL_DEFAULTS["Illustrious"])
            
            # Build params, preferring CivitAI data over defaults
            params = {}
            
            sampler = _normalize_sampler(civitai_params.get("sampler"))
            params["sampler"] = sampler or defaults["sampler"]
            
            scheduler = _normalize_scheduler(civitai_params.get("scheduler"))
            params["scheduler"] = scheduler or defaults["scheduler"]
            
            steps = civitai_params.get("steps")
            params["steps"] = int(steps) if steps else defaults["steps"]
            
            cfg = civitai_params.get("cfg")
            params["cfg"] = float(cfg) if cfg else defaults["cfg"]
            
            w, h = _parse_size(civitai_params.get("size"))
            params["width"] = w or defaults["width"]
            params["height"] = h or defaults["height"]
            
            neg = civitai_params.get("negative_prompt")
            params["negative_prompt"] = neg if neg else defaults["negative_prompt"]
            
            params["source"] = "civitai"
            params["model_name"] = civitai_params.get("model_name", "")
            params["samples_analyzed"] = civitai_params.get("samples_analyzed", 0)
            
            return params
            
        except Exception as e:
            print(f"CivitAI lookup failed for {model_alias}: {e}", file=sys.stderr)
    
    # Fallback to config defaults
    cfg = _load_config()
    defaults = cfg.get("defaults", {})
    return {
        "steps": defaults.get("steps", 20),
        "cfg": defaults.get("cfg", 5),
        "sampler": defaults.get("sampler", "dpmpp_2m"),
        "scheduler": defaults.get("scheduler", "normal"),
        "width": defaults.get("width", 1024),
        "height": defaults.get("height", 1536),
        "negative_prompt": defaults.get("negative_prompt", ""),
        "source": "config_default",
    }


def update_config_with_tuned_params(model_alias):
    """Fetch tuned params and update config.json model_params section."""
    params = get_tuned_params(model_alias)
    
    cfg = _load_config()
    if "model_params" not in cfg:
        cfg["model_params"] = {}
    
    cfg["model_params"][model_alias] = {
        "steps": params["steps"],
        "cfg": params["cfg"],
        "sampler": params["sampler"],
        "scheduler": params["scheduler"],
        "width": params["width"],
        "height": params["height"],
        "negative_prompt": params["negative_prompt"],
    }
    
    _save_config(cfg)
    return params


# CLI
if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="cmd")
    
    g = sub.add_parser("get", help="Get tuned params for a model alias")
    g.add_argument("alias")
    
    u = sub.add_parser("update-all", help="Update config with tuned params for all known models")
    
    args = parser.parse_args()
    
    if args.cmd == "get":
        result = get_tuned_params(args.alias)
        print(json.dumps(result, indent=2, ensure_ascii=False))
    
    elif args.cmd == "update-all":
        for alias in KNOWN_MODELS:
            print(f"Tuning {alias}...")
            result = update_config_with_tuned_params(alias)
            print(f"  → {result.get('source')}: steps={result['steps']} cfg={result['cfg']} sampler={result['sampler']}")
        print("Done! Config updated.")
    
    else:
        parser.print_help()


path: scripts/civitai.py
content: #!/usr/bin/env python3
"""CivitAI integration for ComfyUI Painter skill.

Features:
- Search models by keyword, type, sort
- Get model details and version info
- Check local models for updates (by SHA256/AutoV2 hash)
- Download models directly to ComfyUI checkpoints dir
- Fetch recommended parameters for a model
"""

import json
import os
import subprocess
import sys
import urllib.request
import urllib.parse
import re
from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
SKILL_DIR = SCRIPT_DIR.parent
CONFIG_PATH = SKILL_DIR / "config.json"
CREDENTIALS_DIR = Path.home() / ".openclaw" / "workspace" / "credentials"

API_BASE = "https://civitai.com/api/v1"


def _load_config():
    with open(CONFIG_PATH) as f:
        return json.load(f)


def _save_config(cfg):
    with open(CONFIG_PATH, "w") as f:
        json.dump(cfg, f, indent=2, ensure_ascii=False)


def _get_api_key():
    """Load CivitAI API key from credentials."""
    cred_file = CREDENTIALS_DIR / "civitai.md"
    if cred_file.exists():
        text = cred_file.read_text()
        for line in text.splitlines():
            if line.startswith("Token:"):
                return line.split(":", 1)[1].strip()
    return os.environ.get("CIVITAI_API_KEY", "")


def _api_get(endpoint, params=None, use_auth=False):
    """Make a GET request to CivitAI API using curl (more reliable than urllib).
    """
    url = f"{API_BASE}/{endpoint}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    
    cmd = ["curl", "-s", "--max-time", "15"]
    if use_auth:
        key = _get_api_key()
        if key:
            cmd.extend(["-H", f"Authorization: Bearer {key}"])
    
    cmd.append(url)
    
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
    if result.returncode != 0:
        raise RuntimeError(f"curl failed: {result.stderr}")
    
    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError:
        raise RuntimeError(f"Invalid JSON from {url}: {result.stdout[:200]}")
    
    # Check for error responses
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(f"API error: {data['error']}")
    
    return data


def search(query, model_type="Checkpoint", sort="Highest Rated", period="AllTime",
           limit=10, nsfw=True):
    """Search CivitAI models.
    
    Args:
        query: Search keywords
        model_type: Checkpoint, LORA, TextualInversion, etc.
        sort: Highest Rated, Most Downloaded, Newest
        period: AllTime, Year, Month, Week, Day
        limit: Max results (1-100)
        nsfw: Include NSFW results
    
    Returns:
        List of model dicts with id, name, type, stats, versions
    """
    params = {
        "query": query,
        "types": model_type,
        "sort": sort,
        "period": period,
        "limit": limit,
        "nsfw": str(nsfw).lower(),
    }
    data = _api_get("models", params, use_auth=True)
    results = []
    for m in data.get("items", []):
        versions = []
        for v in m.get("modelVersions", [])[:5]:
            files = v.get("files", [])
            f = files[0] if files else {}
            versions.append({
                "id": v["id"],
                "name": v["name"],
                "baseModel": v.get("baseModel", "?"),
                "filename": f.get("name", "?"),
                "sizeGB": round(f.get("sizeKB", 0) / 1024 / 1024, 1),
                "downloadUrl": f.get("downloadUrl", ""),
                "publishedAt": v.get("publishedAt", "")[:10],
            })
        
        stats = m.get("stats", {})
        results.append({
            "id": m["id"],
            "name": m["name"],
            "type": m["type"],
            "tags": m.get("tags", []),
            "thumbsUp": stats.get("thumbsUpCount", 0),
            "downloads": stats.get("downloadCount", 0),
            "url": f"https://civitai.com/models/{m['id']}",
            "versions": versions,
        })
    return results


def get_model(model_id):
    """Get full model details by ID."""
    data = _api_get(f"models/{model_id}")
    return data


def get_version(version_id):
    """Get model version details."""
    return _api_get(f"model-versions/{version_id}")


def lookup_by_hash(hash_value):
    """Look up a model version by file hash (SHA256 or AutoV2).
    
    Works best with curl/no-auth. Uses AutoV2 (first 10 hex chars of SHA256 uppercase).
    """
    return _api_get(f"model-versions/by-hash/{hash_value}")


def get_autov2_hash(filepath):
    """Compute AutoV2 hash (first 10 chars of SHA256, uppercase) for a model file."""
    result = subprocess.run(
        ["sha256sum", filepath],
        capture_output=True, text=True, timeout=300
    )
    if result.returncode != 0:
        raise RuntimeError(f"sha256sum failed: {result.stderr}")
    return result.stdout.split()[0][:10].upper()


def check_updates(checkpoints_dir=None):
    """Check all local checkpoints for updates on CivitAI.
    
    Returns list of dicts: {filename, local_version, latest_version, has_update, download_url}
    """
    cfg = _load_config()
    if checkpoints_dir is None:
        checkpoints_dir = cfg["comfyui"]["output_dir_linux"].replace("/output", "/models/checkpoints/sd")
    
    results = []
    ckpt_dir = Path(checkpoints_dir)
    
    for f in sorted(ckpt_dir.glob("*.safetensors")):
        print(f"Checking {f.name}...", file=sys.stderr)
        try:
            autov2 = get_autov2_hash(str(f))
            version_data = lookup_by_hash(autov2)
            model_id = version_data.get("modelId")
            current_vid = version_data.get("id")
            current_vname = version_data.get("name", "?")
            
            # Get full model to check latest
            model_data = _api_get(f"models/{model_id}")
            latest = model_data["modelVersions"][0]
            latest_file = latest["files"][0] if latest.get("files") else {}
            
            has_update = latest["id"] != current_vid
            results.append({
                "filename": f.name,
                "model_name": model_data["name"],
                "model_id": model_id,
                "local_version": current_vname,
                "latest_version": latest["name"],
                "has_update": has_update,
                "latest_sizeGB": round(latest_file.get("sizeKB", 0) / 1024 / 1024, 1),
                "download_url": latest_file.get("downloadUrl", ""),
                "url": f"https://civitai.com/models/{model_id}",
            })
        except Exception as e:
            results.append({
                "filename": f.name,
                "error": str(e),
            })
    
    return results


def get_recommended_params(model_id):
    """Fetch recommended generation parameters from CivitAI model page.
    
    Extracts from model description and sample image metadata.
    """
    data = _api_get(f"models/{model_id}")
    latest = data["modelVersions"][0]
    
    # Extract params from sample images metadata
    params_list = []
    for img in latest.get("images", [])[:10]:
        meta = img.get("meta", {})
        if meta:
            params_list.append({
                "sampler": meta.get("sampler", ""),
                "steps": meta.get("steps", ""),
                "cfg": meta.get("cfgScale", ""),
                "size": meta.get("Size", ""),
                "negative": meta.get("negativePrompt", ""),
                "hires_upscaler": meta.get("Hires upscaler", ""),
                "hires_steps": meta.get("Hires steps", ""),
                "hires_upscale": meta.get("Hires upscale", ""),
                "denoising": meta.get("Denoising strength", ""),
                "scheduler": meta.get("Schedule type", ""),
                "prompt": meta.get("prompt", "")[:200],
            })
    
    # Aggregate most common params
    if not params_list:
        return {"error": "No sample metadata found"}
    
    # Find most common values
    from collections import Counter
    agg = {}
    for key in ["sampler", "steps", "cfg", "scheduler"]:
        values = [p[key] for p in params_list if p[key]]
        if values:
            most_common = Counter(values).most_common(1)[0][0]
            agg[key] = most_common
    
    # Get common negative prompt
    negs = [p["negative"] for p in params_list if p["negative"]]
    if negs:
        agg["negative_prompt"] = Counter(negs).most_common(1)[0][0]
    
    # Get common size
    sizes = [p["size"] for p in params_list if p["size"]]
    if sizes:
        agg["size"] = Counter(sizes).most_common(1)[0][0]
    
    agg["model_name"] = data["name"]
    agg["model_id"] = model_id
    agg["samples_analyzed"] = len(params_list)
    
    return agg


def download_model(url, dest_dir=None, filename=None):
    """Download a model from CivitAI.
    
    Args:
        url: CivitAI download URL
        dest_dir: Destination directory (default: checkpoints/sd)
        filename: Override filename (auto-detected from headers if None)
    
    Returns:
        Path to downloaded file
    """
    cfg = _load_config()
    if dest_dir is None:
        dest_dir = cfg["comfyui"]["output_dir_linux"].replace("/output", "/models/checkpoints/sd")
    
    [REDACTED_SECRET])
    
    # Use curl for reliable downloads with progress
    cmd = ["curl", "-L", "-#", "-o"]
    
    if filename:
        dest = os.path.join(dest_dir, filename + ".tmp")
        final = os.path.join(dest_dir, filename)
    else:
        # Download to temp, detect filename from headers
        dest = os.path.join(dest_dir, "download.tmp")
        final = None
    
    cmd.append(dest)
    
    if [REDACTED_SECRET]"-H", f"Authorization: Bearer {api_key}"])
    
    cmd.append(url)
    
    print(f"Downloading to {dest}...", file=sys.stderr)
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
    
    if result.returncode != 0:
        raise RuntimeError(f"Download failed: {result.stderr}")
    
    if final:
        os.rename(dest, final)
        return final
    
    return dest


# CLI interface
if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="CivitAI integration for ComfyUI Painter")
    sub = parser.add_subparsers(dest="command")
    
    # search
    s = sub.add_parser("search", help="Search models")
    s.add_argument("query", help="Search keywords")
    s.add_argument("--type", default="Checkpoint")
    s.add_argument("--sort", default="Highest Rated")
    s.add_argument("--limit", type=int, default=5)
    
    # info
    i = sub.add_parser("info", help="Get model info")
    i.add_argument("model_id", type=int)
    
    # params
    p = sub.add_parser("params", help="Get recommended params")
    p.add_argument("model_id", type=int)
    
    # check-updates
    sub.add_parser("check-updates", help="Check local models for updates")
    
    # download
    d = sub.add_parser("download", help="Download a model")
    d.add_argument("url", help="Download URL")
    d.add_argument("--filename", help="Override filename")
    
    args = parser.parse_args()
    
    if args.command == "search":
        results = search(args.query, model_type=args.type, sort=args.sort, limit=args.limit)
        print(json.dumps(results, indent=2, ensure_ascii=False))
    
    elif args.command == "info":
        data = get_model(args.model_id)
        # Simplified output
        s = data.get("stats", {})
        print(f"Name: {data['name']}")
        print(f"Type: {data['type']}")
        print(f"Downloads: {s.get('downloadCount', 0)}")
        print(f"Likes: {s.get('thumbsUpCount', 0)}")
        print(f"Tags: {', '.join(data.get('tags', []))}")
        print(f"\nVersions:")
        for v in data.get("modelVersions", [])[:8]:
            f = v.get("files", [{}])[0] if v.get("files") else {}
            print(f"  v{v['name']} | {v.get('baseModel','?')} | {f.get('name','?')} | {v.get('publishedAt','')[:10]}")
    
    elif args.command == "params":
        result = get_recommended_params(args.model_id)
        print(json.dumps(result, indent=2, ensure_ascii=False))
    
    elif args.command == "check-updates":
        results = check_updates()
        for r in results:
            if "error" in r:
                print(f"❌ {r['filename']}: {r['error']}")
            elif r["has_update"]:
                print(f"🆕 {r['model_name']}: {r['local_version']} → {r['latest_version']}")
                print(f"   ⬇️ {r['download_url']}")
            else:
                print(f"✅ {r['model_name']}: {r['local_version']} — up to date")
    
    else:
        parser.print_help()


path: scripts/comfyui_manager.py
content: #!/usr/bin/env python3
"""ComfyUI 进程管理：启动 / 关闭 / 状态检查"""

import json
import subprocess
import time
import urllib.request
from pathlib import Path

CONFIG = json.loads((Path(__file__).parent.parent / "config.json").read_text())
COMFY = CONFIG["comfyui"]
API_URL = COMFY["api_url"]
LAST_USED = Path(__file__).parent.parent / ".last_used"
POWERSHELL = COMFY["powershell"]


def is_running() -> bool:
    try:
        resp = urllib.request.urlopen(f"{API_URL}/system_stats", timeout=3)
        return resp.status == 200
    except Exception:
        return False


def start(timeout=60) -> bool:
    if is_running():
        _touch()
        return True
    
    cmd = (
        f"Start-Process -FilePath '{COMFY['python_path']}' "
        f"-ArgumentList '{COMFY['main_path']}','--listen','0.0.0.0','--port','{COMFY['port']}' "
        f"-WorkingDirectory '{COMFY['work_dir']}' -WindowStyle Hidden"
    )
    subprocess.run([POWERSHELL, "-Command", cmd], capture_output=True, timeout=15)
    
    # 等待启动
    for _ in range(timeout // 2):
        time.sleep(2)
        if is_running():
            _touch()
            return True
    return False


def stop() -> bool:
    subprocess.run(
        [POWERSHELL, "-Command",
         "Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force"],
        capture_output=True, timeout=10
    )
    time.sleep(1)
    return not is_running()


def status() -> dict:
    if not is_running():
        return {"running": False}
    try:
        resp = urllib.request.urlopen(f"{API_URL}/system_stats", timeout=3)
        data = json.loads(resp.read())
        return {"running": True, **data.get("system", {})}
    except Exception:
        return {"running": False}


def _touch():
    LAST_USED.write_text(str(time.time()))


if __name__ == "__main__":
    import sys
    cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
    if cmd == "start":
        ok = start()
        print(f"{'✅ ComfyUI 已启动' if ok else '❌ 启动失败'}")
    elif cmd == "stop":
        ok = stop()
        print(f"{'✅ ComfyUI 已关闭' if ok else '❌ 关闭失败'}")
    elif cmd == "status":
        s = status()
        print(json.dumps(s, indent=2))
    else:
        print(f"用法: {sys.argv[0]} [start|stop|status]")


path: scripts/generate.py
content: #!/usr/bin/env python3
"""ComfyUI 图片生成：构建 prompt → 提交 API → 等待完成 → 返回图片路径"""

import json
import time
import random
import urllib.request
from pathlib import Path

CONFIG = json.loads((Path(__file__).parent.parent / "config.json").read_text())
API_URL = CONFIG["comfyui"]["api_url"]
OUTPUT_DIR = Path(CONFIG["comfyui"]["output_dir_linux"])
MODELS = CONFIG["models"]
DEFAULTS = CONFIG["defaults"]
LAST_USED = Path(__file__).parent.parent / ".last_used"


def resolve_model(name: str) -> str:
    """模型别名 → checkpoint 文件名"""
    name = name.lower().strip()
    if name in MODELS:
        return MODELS[name]
    # 尝试模糊匹配
    for alias, ckpt in MODELS.items():
        if name in alias or name in ckpt.lower():
            return ckpt
    return MODELS[DEFAULTS["model"]]


def build_prompt(
    positive: str,
    negative: str = None,
    model: str = None,
    width: int = None,
    height: int = None,
    steps: int = None,
    cfg: float = None,
    sampler: str = None,
    scheduler: str = None,
    batch_size: int = None,
    seed: int = None,
    loras: list = None,
) -> dict:
    """构建 ComfyUI API prompt"""
    
    ckpt = resolve_model(model or DEFAULTS["model"])
    neg = negative or DEFAULTS["negative_prompt"]
    w = width or DEFAULTS["width"]
    h = height or DEFAULTS["height"]
    st = steps or DEFAULTS["steps"]
    c = cfg or DEFAULTS["cfg"]
    sam = sampler or DEFAULTS["sampler"]
    sch = scheduler or DEFAULTS["scheduler"]
    bs = batch_size or DEFAULTS["batch_size"]
    s = seed or random.randint(1, 2**32 - 1)

    prompt = {
        "3": {
            "class_type": "KSampler",
            "inputs": {
                "seed": s,
                "control_after_generate": "randomize",
                "steps": st,
                "cfg": c,
                "sampler_name": sam,
                "scheduler": sch,
                "denoise": 1,
                "model": ["4", 0],
                "positive": ["6", 0],
                "negative": ["7", 0],
                "latent_image": ["16", 0],
            },
        },
        "4": {
            "class_type": "CheckpointLoaderSimple",
            "inputs": {"ckpt_name": ckpt},
        },
        "6": {
            "class_type": "CLIPTextEncode",
            "inputs": {"text": positive, "clip": ["4", 1]},
        },
        "7": {
            "class_type": "CLIPTextEncode",
            "inputs": {"text": neg, "clip": ["4", 1]},
        },
        "8": {
            "class_type": "VAEDecode",
            "inputs": {"samples": ["3", 0], "vae": ["4", 2]},
        },
        "9": {
            "class_type": "SaveImage",
            "inputs": {"filename_prefix": "sdi", "images": ["8", 0]},
        },
        "16": {
            "class_type": "EmptyLatentImage",
            "inputs": {"width": w, "height": h, "batch_size": bs},
        },
    }

    return prompt


def submit(prompt: dict) -> str:
    """提交 prompt 到 ComfyUI，返回 prompt_id"""
    data = json.dumps({"prompt": prompt}).encode()
    req = urllib.request.Request(
        f"{API_URL}/api/prompt",
        data=data,
        headers={"Content-Type": "application/json"},
    )
    resp = urllib.request.urlopen(req, timeout=15)
    result = json.loads(resp.read())
    
    if result.get("node_errors"):
        raise RuntimeError(f"节点错误: {json.dumps(result['node_errors'])}")
    
    return result["prompt_id"]


def wait_for_completion(timeout=300, poll_interval=3) -> bool:
    """等待队列清空"""
    for _ in range(timeout // poll_interval):
        time.sleep(poll_interval)
        try:
            resp = urllib.request.urlopen(f"{API_URL}/api/queue", timeout=5)
            q = json.loads(resp.read())
            running = len(q.get("queue_running", []))
            pending = len(q.get("queue_pending", []))
            if running == 0 and pending == 0:
                return True
        except Exception:
            pass
    return False


def get_latest_images(count: int = 4, prefix: str = "sdi") -> list:
    """获取最新生成的图片路径"""
    pattern = f"{prefix}_*.png"
    files = sorted(OUTPUT_DIR.glob(pattern), key=lambda f: f.stat().st_mtime, reverse=True)
    return [str(f) for f in files[:count]]


def generate(
    positive: str,
    negative: str = None,
    model: str = None,
    width: int = None,
    height: int = None,
    steps: int = None,
    cfg: float = None,
    sampler: str = None,
    scheduler: str = None,
    batch_size: int = None,
    seed: int = None,
) -> dict:
    """完整生成流程：构建 → 提交 → 等待 → 返回图片"""
    
    # 更新最后使用时间
    LAST_USED.write_text(str(time.time()))
    
    bs = batch_size or DEFAULTS["batch_size"]
    prompt = build_prompt(
        positive=positive,
        negative=negative,
        model=model,
        width=width,
        height=height,
        steps=steps,
        cfg=cfg,
        sampler=sampler,
        scheduler=scheduler,
        batch_size=bs,
        seed=seed,
    )
    
    prompt_id = submit(prompt)
    success = wait_for_completion()
    
    if not success:
        return {"ok": False, "error": "生成超时"}
    
    images = get_latest_images(count=bs)
    
    # 更新最后使用时间
    LAST_USED.write_text(str(time.time()))
    
    return {"ok": True, "prompt_id": prompt_id, "images": images}


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("用法: generate.py <positive_prompt> [--model noobv4] [--batch 4]")
        sys.exit(1)
    
    result = generate(positive=sys.argv[1])
    print(json.dumps(result, indent=2))


path: upgrade_state.json
content: {
  "task": "ComfyUI Painter 全面升级",
  "created": "2026-02-26T16:15:00-05:00",
  "states": {
    "1_download_models": {
      "status": "in_progress",
      "desc": "下载4个模型更新",
      "subtasks": {
        "hassaku_v34": {"status": "pending", "url": "https://civitai.com/api/download/models/2615702"},
        "janku_v69": {"status": "pending", "url": "https://civitai.com/api/download/models/2578565"},
        "pornmaster_noob_v6": {"status": "pending", "url": "https://civitai.com/api/download/models/2531839"},
        "nova3dcg_v8": {"status": "pending", "url": "https://civitai.com/api/download/models/2487967"}
      }
    },
    "2_civitai_integration": {
      "status": "pending",
      "desc": "添加CivitAI搜索/下载/浏览功能"
    },
    "3_auto_tuning": {
      "status": "pending",
      "desc": "模型自动调参（从CivitAI拉取推荐参数）"
    },
    "4_update_config": {
      "status": "pending",
      "desc": "更新config.json别名+默认参数"
    },
    "5_publish_clawhub": {
      "status": "pending",
      "desc": "发布到ClaHub"
    }
  }
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)