Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919b822fe0 | ||
|
|
eb513e1cc2 | ||
|
|
0c3830097b | ||
|
|
b0952e0fb3 | ||
|
|
292abd93d8 |
@@ -1,20 +0,0 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -59,6 +59,73 @@ Chat with large language models directly inside your ComfyUI workflows, with opt
|
||||
|---|---|
|
||||
| **SF LLM Chat** | Multi-model chat supporting Claude (Opus, Sonnet, Haiku), Gemini 3 (Pro, Flash), and OpenAI GPT-5 series. Supports image input and reports token usage. |
|
||||
| **SF Text Analyzer** | Analyze and process text strings within workflows. |
|
||||
| **SF Claude Code** | Send text and images to Claude through the Claude Code CLI on your own machine, using your existing Claude login instead of an API key. Image slots appear as you fill them. |
|
||||
| **SF Claude Asset Director** | Point Claude at an asset library and it chooses which references suit the brief and writes the image prompt to go with them. Outputs an IMAGE batch plus the prompt, ready to wire into any generation node. |
|
||||
|
||||
#### Using SF Claude Asset Director
|
||||
|
||||
Give it a folder of assets and a brief; it returns the references Claude chose and the prompt
|
||||
it wrote for them. Wire `images` and `prompt` straight into Nano Banana, Seedream, or anything
|
||||
else that takes an image batch and a text prompt.
|
||||
|
||||
**It decides from a manifest, not from the pixels.** Put a file in the library — `assets.json`
|
||||
by default, though any text or JSON works — describing what each asset is:
|
||||
|
||||
```json
|
||||
{"assets": [
|
||||
{"file": "knight.png", "desc": "Armoured knight character, heroic, front view"},
|
||||
{"file": "castle.png", "desc": "Stone castle environment, background location"}
|
||||
]}
|
||||
```
|
||||
|
||||
That keeps a large library cheap to work with: no image is opened unless you ask for it.
|
||||
|
||||
| Input | Purpose |
|
||||
|---|---|
|
||||
| `library_path` | The asset folder. Point it at a Google Drive for Desktop sync folder to work off Drive |
|
||||
| `manifest` | Descriptions file inside the library (default `assets.json`); falls back to filenames if absent |
|
||||
| `system_prompt` | Standing rules — e.g. *"Choose only character assets unless a background is asked for"* |
|
||||
| `instruction` | The brief for this run. Type it or wire a string in |
|
||||
| `max_images` | Ceiling on how many references Claude may pick |
|
||||
| `pad_to_fit` | **On** (default): scale each image onto a shared canvas, keeping aspect ratios. **Off**: stretch them all to one size |
|
||||
| `look_at_images` | **Off** (default). On, Claude opens the files as well — better picks on an unlabelled library, but ~5s per image |
|
||||
| `seed` | Not sent to Claude; change it to force a fresh choice rather than ComfyUI's cached one |
|
||||
|
||||
Outputs are `images` (batch), `prompt`, and `picked` (the filenames chosen, for checking its work).
|
||||
|
||||
**Why `pad_to_fit` exists:** a ComfyUI image batch is one tensor, so every frame must share
|
||||
dimensions — and an asset library won't. Padding preserves aspect ratios at the cost of black
|
||||
bars; stretching avoids bars at the cost of distortion. Which is better depends on the model
|
||||
you're feeding.
|
||||
|
||||
Requires [Claude Code](https://claude.com/claude-code) installed and signed in, same as **SF Claude Code**.
|
||||
|
||||
#### Using SF Claude Code
|
||||
|
||||
This node shells out to the **Claude Code CLI**, so it bills against your Claude
|
||||
subscription rather than an Anthropic API key.
|
||||
|
||||
**Requirements:** [Claude Code](https://claude.com/claude-code) must be installed *and signed
|
||||
in* on the machine running ComfyUI. The node searches your `PATH` plus the usual install
|
||||
locations, and gives a clear error if it can't find it.
|
||||
|
||||
| Input | Purpose |
|
||||
|---|---|
|
||||
| `prompt` | What you want Claude to do |
|
||||
| `system_prompt` | Shapes the reply, e.g. *"Reply with only the prompt text, no preamble."* Leave blank to skip |
|
||||
| `model` | `default`, `opus`, `sonnet`, or `haiku` |
|
||||
| `timeout_seconds` | Give up after this long (default 300) |
|
||||
| `seed` | Not sent to Claude — change it to force a fresh reply instead of ComfyUI's cached one |
|
||||
| `image_1…` | Connect an image and the next slot appears, up to 16 |
|
||||
|
||||
Worth knowing before you build a big batch around it:
|
||||
|
||||
- **Roughly 5–6 seconds per call minimum.** Claude Code is an agent that reads each image as a
|
||||
tool call, so it's slower than a direct vision API request.
|
||||
- **Subscription rate limits apply.** Heavy batch use will hit them; `SF LLM Chat` with an API
|
||||
key is the pay-per-token alternative that won't.
|
||||
- **Not portable.** Anyone else installing this pack needs their own Claude Code install and
|
||||
login for this particular node. Every other node in the pack is unaffected.
|
||||
|
||||
---
|
||||
|
||||
@@ -177,8 +244,10 @@ and skips row 2. Each prompt triggers a separate pass through the downstream gra
|
||||
- **Marking is manual.** Type anything into the status column — `done`, `x`, `executed` — and that
|
||||
row is skipped next time. Clear the cell and it gets queued again.
|
||||
- **Scanning stops at the first empty prompt cell**, so anything below a gap is ignored.
|
||||
- **Refresh sheet** re-reads the sheet after you've edited it in the browser and reports how many
|
||||
prompts are still pending.
|
||||
- **The sheet is re-read on every run**, so edits you make in the browser are picked up
|
||||
automatically. Add rows or clear status cells and just hit Run again.
|
||||
- **Refresh sheet** is optional — it reports how many prompts are pending without running
|
||||
the graph.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# ABOUTME: Claude picks reference images from an asset library and writes the prompt for them
|
||||
# ABOUTME: Reads a manifest describing the assets; looking at the images themselves is optional
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .sf_claude_code import MODEL_CHOICES, find_claude_binary
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
|
||||
MAX_LISTED = 2000
|
||||
|
||||
|
||||
def scan_library(root, recursive):
|
||||
"""Image files under `root`, as paths relative to it, in sorted order."""
|
||||
found = []
|
||||
if recursive:
|
||||
for folder, _, names in os.walk(root):
|
||||
for name in names:
|
||||
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
|
||||
full = os.path.join(folder, name)
|
||||
found.append(os.path.relpath(full, root))
|
||||
else:
|
||||
for name in os.listdir(root):
|
||||
if os.path.splitext(name)[1].lower() in IMAGE_EXTS:
|
||||
found.append(name)
|
||||
return sorted(found)[:MAX_LISTED]
|
||||
|
||||
|
||||
def read_manifest(root, manifest):
|
||||
"""The asset descriptions file, or None when there isn't one."""
|
||||
name = (manifest or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
path = os.path.join(root, name)
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def build_request(system_prompt, instruction, files, manifest_text, max_images, may_look):
|
||||
"""The single prompt sent to Claude."""
|
||||
parts = []
|
||||
if system_prompt.strip():
|
||||
parts.append(system_prompt.strip())
|
||||
|
||||
if manifest_text:
|
||||
parts.append(
|
||||
"Here is the asset library manifest describing what each file contains:\n\n"
|
||||
f"{manifest_text.strip()}"
|
||||
)
|
||||
|
||||
listing = "\n".join(f" {name}" for name in files)
|
||||
parts.append(f"These are the image files available ({len(files)} total):\n{listing}")
|
||||
|
||||
if may_look:
|
||||
parts.append(
|
||||
"You may use the Read tool on any of these files to look at them before "
|
||||
"deciding. They are in your current working directory."
|
||||
)
|
||||
|
||||
parts.append(f"Request:\n{instruction.strip()}")
|
||||
parts.append(
|
||||
f"Choose at most {max_images} file(s). Reply with ONLY a JSON object and nothing "
|
||||
f"else — no explanation, no code fence:\n"
|
||||
f'{{"files": ["exact/path/as/listed.png"], "prompt": "the image generation prompt"}}\n'
|
||||
f"Use file paths exactly as they appear in the listing. The prompt should refer to "
|
||||
f"the chosen images in the order you list them."
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def parse_reply(text):
|
||||
"""The JSON object out of Claude's reply, tolerating a code fence around it."""
|
||||
stripped = text.strip()
|
||||
start, end = stripped.find("{"), stripped.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
raise RuntimeError(f"Claude did not return JSON. It said: {stripped[:300]}")
|
||||
try:
|
||||
payload = json.loads(stripped[start : end + 1])
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Claude's JSON could not be parsed ({e}): {stripped[:300]}")
|
||||
|
||||
files = payload.get("files") or []
|
||||
prompt = str(payload.get("prompt") or "").strip()
|
||||
if not isinstance(files, list):
|
||||
raise RuntimeError("Claude's 'files' field was not a list.")
|
||||
return [str(f).strip() for f in files if str(f).strip()], prompt
|
||||
|
||||
|
||||
def resolve_picks(picked, available, root):
|
||||
"""Map Claude's filenames onto real paths, matching on basename as a fallback."""
|
||||
by_rel = {name: name for name in available}
|
||||
by_base = {}
|
||||
for name in available:
|
||||
by_base.setdefault(os.path.basename(name).lower(), name)
|
||||
|
||||
resolved, missing = [], []
|
||||
for want in picked:
|
||||
cleaned = want.strip().lstrip("./")
|
||||
match = by_rel.get(cleaned) or by_base.get(os.path.basename(cleaned).lower())
|
||||
if match:
|
||||
resolved.append(os.path.join(root, match))
|
||||
else:
|
||||
missing.append(want)
|
||||
return resolved, missing
|
||||
|
||||
|
||||
def to_image_batch(paths, pad_to_fit):
|
||||
"""Load the images into one ComfyUI IMAGE tensor.
|
||||
|
||||
A batch is a single tensor, so every frame must share dimensions. With
|
||||
`pad_to_fit` each image is scaled to fit and centred on a common canvas,
|
||||
which keeps its aspect ratio; without it they are stretched to match.
|
||||
"""
|
||||
images = [Image.open(p).convert("RGB") for p in paths]
|
||||
width = max(im.width for im in images)
|
||||
height = max(im.height for im in images)
|
||||
|
||||
frames = []
|
||||
for im in images:
|
||||
if pad_to_fit:
|
||||
scale = min(width / im.width, height / im.height)
|
||||
size = (max(1, round(im.width * scale)), max(1, round(im.height * scale)))
|
||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
canvas.paste(im.resize(size, Image.LANCZOS),
|
||||
((width - size[0]) // 2, (height - size[1]) // 2))
|
||||
im = canvas
|
||||
else:
|
||||
im = im.resize((width, height), Image.LANCZOS)
|
||||
frames.append(np.asarray(im, dtype=np.float32) / 255.0)
|
||||
|
||||
return torch.from_numpy(np.stack(frames))
|
||||
|
||||
|
||||
class SFClaudeAssetDirector:
|
||||
"""
|
||||
Claude picks reference images out of an asset library and writes the prompt
|
||||
that goes with them.
|
||||
|
||||
Decisions are made from a manifest describing the assets, so the library can
|
||||
be large without every run costing a pile of image reads. Outputs an IMAGE
|
||||
batch and a STRING, which wire straight into an image generation node.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"library_path": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Folder holding the assets. For Google Drive, point this at "
|
||||
"your Drive for Desktop sync folder."
|
||||
),
|
||||
},
|
||||
),
|
||||
"manifest": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "assets.json",
|
||||
"tooltip": (
|
||||
"File inside the library describing each asset, used to choose "
|
||||
"between them. Any text or JSON format. Ignored if absent."
|
||||
),
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": "Standing instructions — how to choose, how to write the prompt.",
|
||||
},
|
||||
),
|
||||
"instruction": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"What you want this run. Type it here or wire a string in, "
|
||||
"e.g. 'Make a poster. Pick the characters you think work best.'"
|
||||
),
|
||||
},
|
||||
),
|
||||
"max_images": (
|
||||
"INT",
|
||||
{
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"tooltip": "Most reference images Claude may choose.",
|
||||
},
|
||||
),
|
||||
"pad_to_fit": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"On: scale each image to fit a shared canvas and centre it, "
|
||||
"keeping aspect ratios. Off: stretch them all to one size."
|
||||
),
|
||||
},
|
||||
),
|
||||
"look_at_images": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Let Claude open the image files as well as the manifest. "
|
||||
"Better choices on an unlabelled library, but roughly 5 "
|
||||
"seconds per image it opens."
|
||||
),
|
||||
},
|
||||
),
|
||||
"recursive": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "Include images in subfolders."},
|
||||
),
|
||||
"model": (
|
||||
MODEL_CHOICES,
|
||||
{"default": "default", "tooltip": "Which Claude model decides."},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{"default": 300, "min": 30, "max": 3600, "step": 30,
|
||||
"tooltip": "Give up if Claude has not replied within this long."},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF,
|
||||
"control_after_generate": True,
|
||||
"tooltip": (
|
||||
"Not sent to Claude. Change it to force a fresh choice instead "
|
||||
"of reusing ComfyUI's cached result."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
|
||||
RETURN_NAMES = ("images", "prompt", "picked")
|
||||
FUNCTION = "direct"
|
||||
CATEGORY = "Stillfront/LLM"
|
||||
|
||||
DESCRIPTION = """
|
||||
Claude reads your asset library's **manifest**, chooses which references suit the
|
||||
request, and writes the image prompt to go with them.
|
||||
|
||||
Outputs an IMAGE batch and the prompt, ready to wire into Nano Banana or any
|
||||
other generation node. Point **library_path** at a Google Drive for Desktop sync
|
||||
folder to work straight off Drive.
|
||||
|
||||
**look_at_images** is off by default: choices come from the manifest, so a large
|
||||
library costs nothing extra. Turn it on to have Claude open the files too.
|
||||
"""
|
||||
|
||||
def direct(self, library_path, manifest, system_prompt, instruction, max_images,
|
||||
pad_to_fit, look_at_images, recursive, model, timeout_seconds, seed):
|
||||
root = os.path.expanduser(library_path.strip())
|
||||
if not root or not os.path.isdir(root):
|
||||
raise ValueError(f"library_path is not a folder: {library_path!r}")
|
||||
if not instruction.strip():
|
||||
raise ValueError("instruction is empty — tell Claude what this run is for.")
|
||||
|
||||
binary = find_claude_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it from https://claude.com/claude-code "
|
||||
"and sign in, then restart ComfyUI."
|
||||
)
|
||||
|
||||
files = scan_library(root, recursive)
|
||||
if not files:
|
||||
raise ValueError(f"No image files found in {root}")
|
||||
|
||||
manifest_text = read_manifest(root, manifest)
|
||||
if manifest_text is None and manifest.strip():
|
||||
print(
|
||||
f"[SF Claude Asset Director] No manifest '{manifest.strip()}' in the library; "
|
||||
f"choosing from filenames alone."
|
||||
)
|
||||
|
||||
request = build_request(
|
||||
system_prompt, instruction, files, manifest_text, max_images, look_at_images
|
||||
)
|
||||
|
||||
command = [binary, "-p", request, "--output-format", "json"]
|
||||
if model != "default":
|
||||
command += ["--model", model]
|
||||
if look_at_images:
|
||||
command += ["--allowedTools", "Read"]
|
||||
|
||||
print(
|
||||
f"[SF Claude Asset Director] {len(files)} asset(s), "
|
||||
f"manifest={'yes' if manifest_text else 'no'}, vision={'on' if look_at_images else 'off'}."
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
f"Claude did not reply within {timeout_seconds}s. Raise timeout_seconds, "
|
||||
f"or turn look_at_images off."
|
||||
)
|
||||
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()[:500]
|
||||
raise RuntimeError(f"Claude Code exited with code {completed.returncode}. {detail}")
|
||||
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"Unreadable response: {completed.stdout.strip()[:400]}")
|
||||
|
||||
if payload.get("is_error"):
|
||||
raise RuntimeError(f"Claude reported an error: {payload.get('result', 'unknown')}")
|
||||
|
||||
picked, prompt = parse_reply(payload.get("result", ""))
|
||||
if not picked:
|
||||
raise RuntimeError("Claude chose no images. Check the manifest describes the assets.")
|
||||
if not prompt:
|
||||
raise RuntimeError("Claude returned no prompt.")
|
||||
|
||||
paths, missing = resolve_picks(picked[:max_images], files, root)
|
||||
if missing:
|
||||
print(f"[SF Claude Asset Director] Ignoring names not in the library: {missing}")
|
||||
if not paths:
|
||||
raise RuntimeError(
|
||||
f"None of Claude's choices matched a real file. It asked for: {picked}"
|
||||
)
|
||||
|
||||
batch = to_image_batch(paths, pad_to_fit)
|
||||
names = [os.path.relpath(p, root) for p in paths]
|
||||
print(f"[SF Claude Asset Director] Chose {len(names)}: {', '.join(names)}")
|
||||
|
||||
return (batch, prompt, "\n".join(names))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFClaudeAssetDirector": SFClaudeAssetDirector}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeAssetDirector": "SF Claude Asset Director"}
|
||||
@@ -0,0 +1,241 @@
|
||||
# ABOUTME: Runs a prompt through the locally installed Claude Code CLI, using its own login
|
||||
# ABOUTME: Accepts text plus any number of images and returns Claude's reply as a string
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from .vertexai_utils import tensor_to_pil
|
||||
|
||||
MAX_IMAGES = 16
|
||||
|
||||
# Aliases rather than pinned model IDs, so this keeps working as models are updated.
|
||||
MODEL_CHOICES = ["default", "opus", "sonnet", "haiku"]
|
||||
|
||||
BINARY_CANDIDATES = [
|
||||
"~/.local/bin/claude",
|
||||
"~/.claude/local/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
]
|
||||
|
||||
|
||||
def find_claude_binary():
|
||||
"""Locates the Claude Code CLI, which may not be on ComfyUI's PATH."""
|
||||
found = shutil.which("claude")
|
||||
if found:
|
||||
return found
|
||||
for candidate in BINARY_CANDIDATES:
|
||||
path = os.path.expanduser(candidate)
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def save_images(image_inputs, directory):
|
||||
"""Writes every frame of every connected IMAGE input to disk. Returns filenames."""
|
||||
names = []
|
||||
for slot, tensor in image_inputs:
|
||||
if tensor is None:
|
||||
continue
|
||||
batch = tensor if tensor.dim() == 4 else tensor.unsqueeze(0)
|
||||
for frame in range(batch.shape[0]):
|
||||
pil = tensor_to_pil(batch[frame])
|
||||
if pil is None:
|
||||
continue
|
||||
name = f"image_{slot}.png" if batch.shape[0] == 1 else f"image_{slot}_{frame + 1}.png"
|
||||
pil.convert("RGB").save(os.path.join(directory, name))
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def build_prompt(prompt, image_names):
|
||||
"""Prefixes the request with instructions to read the images off disk."""
|
||||
if not image_names:
|
||||
return prompt
|
||||
|
||||
listing = ", ".join(image_names)
|
||||
plural = "image" if len(image_names) == 1 else "images"
|
||||
return (
|
||||
f"The following {plural} are in your current working directory: {listing}\n"
|
||||
f"Use the Read tool on each of them first, then respond to this request:\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
|
||||
|
||||
class SFClaudeCode:
|
||||
"""
|
||||
Sends a prompt (and any attached images) to Claude through the Claude Code
|
||||
CLI installed on this machine, so it runs on your existing Claude login
|
||||
rather than a separate API key.
|
||||
|
||||
Connect an image and another image slot appears automatically.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
optional = {
|
||||
f"image_{i}": ("IMAGE",) for i in range(1, MAX_IMAGES + 1)
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": "What you want Claude to do.",
|
||||
},
|
||||
),
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Extra instructions shaping how Claude responds, e.g. "
|
||||
"'Reply with only the prompt text, no preamble.' Leave blank to skip."
|
||||
),
|
||||
},
|
||||
),
|
||||
"model": (
|
||||
MODEL_CHOICES,
|
||||
{
|
||||
"default": "default",
|
||||
"tooltip": "Which Claude model to use. 'default' uses whatever Claude Code is set to.",
|
||||
},
|
||||
),
|
||||
"timeout_seconds": (
|
||||
"INT",
|
||||
{
|
||||
"default": 300,
|
||||
"min": 30,
|
||||
"max": 3600,
|
||||
"step": 30,
|
||||
"tooltip": "Give up if Claude has not replied within this long.",
|
||||
},
|
||||
),
|
||||
"seed": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFF,
|
||||
"control_after_generate": True,
|
||||
"tooltip": (
|
||||
"Not sent to Claude. Change it to force a fresh reply instead "
|
||||
"of reusing ComfyUI's cached result."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": optional,
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("text",)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "Stillfront/LLM"
|
||||
|
||||
DESCRIPTION = """
|
||||
Sends text and images to Claude via the **Claude Code CLI** on this machine,
|
||||
using your existing Claude login — no API key needed.
|
||||
|
||||
Requires Claude Code to be installed and signed in on whichever computer runs
|
||||
ComfyUI. Connect an image and a further image slot appears automatically.
|
||||
|
||||
Change **seed** to force a fresh reply; otherwise ComfyUI reuses the cached one.
|
||||
"""
|
||||
|
||||
def run(self, prompt, system_prompt, model, timeout_seconds, seed, **kwargs):
|
||||
if not prompt.strip():
|
||||
raise ValueError("prompt is empty — give Claude something to do.")
|
||||
|
||||
binary = find_claude_binary()
|
||||
if not binary:
|
||||
raise RuntimeError(
|
||||
"Claude Code CLI not found. Install it from "
|
||||
"https://claude.com/claude-code and sign in, then restart ComfyUI."
|
||||
)
|
||||
|
||||
image_inputs = [
|
||||
(i, kwargs.get(f"image_{i}"))
|
||||
for i in range(1, MAX_IMAGES + 1)
|
||||
if kwargs.get(f"image_{i}") is not None
|
||||
]
|
||||
|
||||
workdir = tempfile.mkdtemp(prefix="sf_claude_")
|
||||
try:
|
||||
image_names = save_images(image_inputs, workdir)
|
||||
|
||||
command = [
|
||||
binary,
|
||||
"-p",
|
||||
build_prompt(prompt, image_names),
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
if system_prompt.strip():
|
||||
command += ["--append-system-prompt", system_prompt.strip()]
|
||||
if model != "default":
|
||||
command += ["--model", model]
|
||||
if image_names:
|
||||
command += ["--allowedTools", "Read"]
|
||||
|
||||
print(
|
||||
f"[SF Claude Code] Running {model} with {len(image_names)} image(s), "
|
||||
f"timeout {timeout_seconds}s."
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=workdir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
# Without this the CLI spends 3s waiting on stdin it will never get.
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
f"Claude did not reply within {timeout_seconds}s. Raise timeout_seconds "
|
||||
f"or simplify the request."
|
||||
)
|
||||
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()[:500]
|
||||
raise RuntimeError(
|
||||
f"Claude Code exited with code {completed.returncode}. {detail}"
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(completed.stdout)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(
|
||||
f"Could not parse Claude's response: {completed.stdout.strip()[:500]}"
|
||||
)
|
||||
|
||||
if payload.get("is_error"):
|
||||
raise RuntimeError(
|
||||
f"Claude reported an error: {payload.get('result', 'unknown')}"
|
||||
)
|
||||
|
||||
text = payload.get("result", "")
|
||||
if not text:
|
||||
raise RuntimeError("Claude returned an empty response.")
|
||||
|
||||
print(
|
||||
f"[SF Claude Code] Got {len(text)} characters in "
|
||||
f"{payload.get('duration_ms', '?')}ms."
|
||||
)
|
||||
return (text,)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFClaudeCode": SFClaudeCode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFClaudeCode": "SF Claude Code"}
|
||||
@@ -156,8 +156,9 @@ A row is treated as finished when its **status_col** cell contains any text at
|
||||
all. Write `done` next to a prompt in the sheet and the next run skips it; clear
|
||||
the cell and it gets queued again. Scanning stops at the first empty prompt cell.
|
||||
|
||||
Press **Refresh sheet** after editing the sheet in your browser to see the
|
||||
updated counts.
|
||||
The sheet is re-read fresh on every run, so edits you make in the browser are
|
||||
always picked up. **Refresh sheet** is optional — it reports how many prompts
|
||||
are pending without having to run the graph.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-sf-nodes"
|
||||
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
|
||||
version = "1.0.8"
|
||||
version = "1.2.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* SF Claude Code — grows the image input list on demand.
|
||||
*
|
||||
* The Python side declares image_1..image_16 as optional inputs. This keeps
|
||||
* only as many slots visible as are in use, plus one spare to connect into.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const NODE_NAME = "SFClaudeCode";
|
||||
const MAX_IMAGES = 16;
|
||||
|
||||
function imageSlotIndex(node, n) {
|
||||
return (node.inputs ?? []).findIndex((input) => input.name === `image_${n}`);
|
||||
}
|
||||
|
||||
function syncImageSlots(node) {
|
||||
if (!node.inputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Highest numbered image slot that currently has something plugged in.
|
||||
let lastUsed = 0;
|
||||
for (const input of node.inputs) {
|
||||
const match = input.name?.match(/^image_(\d+)$/);
|
||||
if (match && input.link != null) {
|
||||
lastUsed = Math.max(lastUsed, parseInt(match[1], 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Show every used slot, plus one empty spare to drag into.
|
||||
const wanted = Math.min(lastUsed + 1, MAX_IMAGES);
|
||||
|
||||
for (let n = 1; n <= wanted; n++) {
|
||||
if (imageSlotIndex(node, n) === -1) {
|
||||
node.addInput(`image_${n}`, "IMAGE");
|
||||
}
|
||||
}
|
||||
|
||||
// Drop trailing spares, but never one that is still connected.
|
||||
for (let n = MAX_IMAGES; n > wanted; n--) {
|
||||
const index = imageSlotIndex(node, n);
|
||||
if (index !== -1 && node.inputs[index].link == null) {
|
||||
node.removeInput(index);
|
||||
}
|
||||
}
|
||||
|
||||
node.setSize(node.computeSize());
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.ClaudeCode",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
syncImageSlots(this);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Re-sync after a saved workflow restores its connections.
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure ? onConfigure.apply(this, arguments) : undefined;
|
||||
requestAnimationFrame(() => syncImageSlots(this));
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
||||
nodeType.prototype.onConnectionsChange = function (type, index, connected, linkInfo, ioSlot) {
|
||||
const result = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
: undefined;
|
||||
// Let litegraph finish updating link state before recounting.
|
||||
requestAnimationFrame(() => syncImageSlots(this));
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user