Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3830097b |
@@ -59,6 +59,34 @@ 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. |
|
||||
|
||||
#### 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"}
|
||||
+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.10"
|
||||
version = "1.1.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