Files

438 lines
13 KiB
Python

"""
ComfyUI nodes for the LLM.
Converted to V3 schema
"""
from typing import Literal, Optional
from torch import Tensor
from PIL import Image
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
from .core.chat import chat_completion, grounding, captioning, ExtraParameters
from .core.utils import tensor_to_pil, pil_to_tensor
from .core.model_clients import init_client, ClientInfo
class LLMLoader(io.ComfyNode):
"""
Load the LLM client.
Class methods
-------------
define_schema (io.Schema):
Tell the main program the metadata, input, output parameters of nodes.
execute:
Load the LLM client.
"""
@classmethod
def define_schema(cls) -> io.Schema:
"""
Tell the main program the metadata, input, output parameters of nodes.
"""
return io.Schema(
node_id="APILLMLoader",
display_name="API LLM Loader",
category="LLM",
inputs=[
io.String.Input(
"base_url",
multiline=False,
default="https://api.openai.com/v1",
tooltip="The API URL of the provider.",
),
io.String.Input(
"api_key",
multiline=False,
default="sk-1234567890",
tooltip="The API key of the provider.",
),
io.String.Input(
"model_name",
multiline=False,
default="gpt-5-high",
tooltip="The name of the model.",
),
io.Combo.Input(
"client_type",
options=[
"openai",
"openai-responses",
"mistral",
"ollama",
"anthropic",
],
default="openai",
tooltip="The type of the LLM client (OpenAI chat comptitable? or Mistral?).",
optional=True,
),
],
outputs=[
io.Custom("CLIENT_INFO").Output(display_name="client_info"),
],
)
@classmethod
def execute(
cls,
base_url: str,
api_key: str,
model_name: str,
client_type: Literal["openai", "openai-responses", "mistral", "ollama"],
) -> io.NodeOutput:
"""
Load the LLM client.
"""
client_info: ClientInfo = init_client(
client_type=client_type,
base_url=base_url,
api_key=api_key,
model=model_name,
)
return io.NodeOutput(client_info)
class ExtraParametersComfy(io.ComfyNode):
"""
Extra parameters for the chat completion.
"""
@classmethod
def define_schema(cls) -> io.Schema:
"""
Extra parameters for the chat completion.
"""
return io.Schema(
node_id="ExtraParameters",
display_name="Extra Parameters",
category="LLM",
inputs=[
io.Combo.Input(
"thinking",
options=["disabled", "enabled"],
default="disabled",
tooltip="Whether to enable the thinking mode for the LLM model.",
),
io.Combo.Input(
"reasoning_effort",
options=["minimal", "low", "medium", "high"],
default="medium",
tooltip="The reasoning effort for the LLM model.",
),
],
outputs=[
io.Custom("EXTRA_PARAMETERS").Output(display_name="extra_parameters"),
],
)
@classmethod
def execute(
cls,
thinking: Literal["disabled", "enabled"],
reasoning_effort: Literal["minimal", "low", "medium", "high"],
) -> io.NodeOutput:
"""
Extra parameters for the chat completion.
"""
return io.NodeOutput(
ExtraParameters(thinking=thinking, reasoning_effort=reasoning_effort)
)
class ChatViaAPI(io.ComfyNode):
"""
Chat with the LLM model.
"""
@classmethod
def define_schema(cls) -> io.Schema:
"""
Tell the main program the metadata, input, output parameters of nodes.
"""
return io.Schema(
node_id="ChatViaAPI",
display_name="API Chat",
category="LLM",
inputs=[
io.Image.Input(
"images",
tooltip="The images for the LLM model.",
optional=True,
),
io.Custom("CLIENT_INFO").Input(
"client_info",
tooltip="The LLM client info.",
),
io.String.Input(
"system_prompt",
multiline=True,
default="You are a helpful assistant.",
tooltip="The system prompt for the LLM model.",
),
io.String.Input(
"user_prompt",
multiline=True,
default="Hello world!",
tooltip="The system prompt for the LLM model.",
),
io.Float.Input(
"temperature",
default=1.0,
min=0.0,
max=2.0,
step=0.1,
tooltip="The temperature parameter for the LLM model.",
),
io.Float.Input(
"top_p",
default=0.95,
min=0.0,
max=2.0,
step=0.1,
tooltip="The top_p parameter for the LLM model.",
),
io.Int.Input(
"top_k",
default=40,
min=1,
max=99,
step=1,
tooltip="The top_k parameter for the LLM model. Only Ollama is supported.",
),
io.Int.Input(
"max_tokens",
default=1024,
min=1,
max=1_000_000,
step=1,
tooltip="The max_tokens for the LLM model.",
),
io.Boolean.Input(
"unload_model_after_chat",
default=True,
tooltip=(
"Whether to unload the LLM model after the chat. "
+ "Only Ollama is supported."
),
),
io.Custom("EXTRA_PARAMETERS").Input(
"extra_parameters",
tooltip="The extra parameters for the LLM model.",
optional=True,
),
],
outputs=[
io.String.Output(display_name="response"),
],
)
@classmethod
def execute(
cls,
client_info: ClientInfo,
system_prompt: str,
user_prompt: str,
temperature: float,
top_p: float,
top_k: int,
max_tokens: int,
unload_model_after_chat: bool,
extra_parameters: Optional[ExtraParameters] = None,
images: Optional[Tensor] = None,
) -> io.NodeOutput:
"""
Chat with the LLM model.
"""
if images is not None and images.any():
pil_images: Optional[list[Image.Image]] = []
for image in images:
pil_images.append(tensor_to_pil(image))
response: str = chat_completion(
client_info=client_info,
system_prompt=system_prompt,
user_prompt=user_prompt,
images=pil_images,
temperature=temperature,
top_p=top_p,
top_k=top_k,
max_tokens=max_tokens,
unload_after_chat=unload_model_after_chat,
extra_parameters=extra_parameters,
)
return io.NodeOutput(response)
class GenerateBBOX(io.ComfyNode):
"""
Generate bboxes based on LLM's grounding abitility.
"""
@classmethod
def define_schema(cls) -> io.Schema:
"""
Tell the main program the metadata, input, output parameters of nodes.
"""
return io.Schema(
node_id="GenerateBBOXViaAPI",
display_name="Generate BBOXes",
category="LLM",
inputs=[
io.Custom("CLIENT_INFO").Input(
"client_info",
tooltip="The LLM client info.",
),
io.String.Input(
"items",
default="dog",
tooltip="The system prompt for the LLM model.",
),
io.Boolean.Input(
"unload_model_after_chat",
default=True,
tooltip=(
"Whether to unload the LLM model after the chat. "
+ "Only Ollama is supported."
),
),
io.Image.Input(
"image",
tooltip="The image for the LLM model.",
),
],
outputs=[
io.Custom("BBOX").Output(display_name="bbox"),
io.Image.Output(display_name="BBoxPreviewImage"),
],
)
@classmethod
def execute(
cls,
client_info: ClientInfo,
items: str,
unload_model_after_chat: bool,
image: Tensor,
) -> io.NodeOutput:
"""
Generate bboxes based on LLM's grounding abitility.
"""
pil_image: Image.Image = tensor_to_pil(image)
(response, bbox_image) = grounding(
client_info=client_info,
item=items,
image=pil_image,
unload_after_chat=unload_model_after_chat,
)
# Convert bbox images to Tensor
result_image: Tensor = pil_to_tensor(bbox_image)
return io.NodeOutput(response, result_image)
class Caption(io.ComfyNode):
"""
Generate bboxes based on LLM's grounding abitility.
"""
@classmethod
def define_schema(cls) -> io.Schema:
"""
Tell the main program the metadata, input, output parameters of nodes.
"""
return io.Schema(
node_id="CaptionViaAPI",
display_name="Captioner",
category="LLM",
inputs=[
io.Custom("CLIENT_INFO").Input(
"client_info",
tooltip="The LLM client info.",
),
io.String.Input(
"language",
default="English",
tooltip="The language of the caption.",
),
io.Boolean.Input(
"unload_model_after_chat",
default=True,
tooltip=(
"Whether to unload the LLM model after the chat. "
+ "Only Ollama is supported."
),
),
io.Int.Input(
"num_max_sentences",
default=10,
min=1,
max=100,
step=1,
tooltip="The maximum number of sentences for the caption. Use 2 for CLIP model. Use 4 for FLUX. Use 10 and more for Qwen-Image.",
),
io.Image.Input(
"image",
tooltip="The image for the LLM model.",
),
io.Custom("EXTRA_PARAMETERS").Input(
"extra_parameters",
tooltip="The extra parameters for the LLM model.",
optional=True,
),
],
outputs=[
io.String.Output(display_name="Caption"),
],
)
@classmethod
def execute(
cls,
client_info: ClientInfo,
language: str,
num_max_sentences: int,
unload_model_after_chat: bool,
image: Tensor,
extra_parameters: Optional[ExtraParameters] = None,
) -> io.NodeOutput:
"""
Generate bboxes based on LLM's grounding abitility.
"""
pil_image: Image.Image = tensor_to_pil(image)
payload = {
"client_info": client_info,
"language": language,
"image": pil_image,
"unload_after_chat": unload_model_after_chat,
"num_max_sentences": num_max_sentences,
}
if extra_parameters is not None:
payload["thinking"] = extra_parameters.thinking
payload["mode"] = extra_parameters.reasoning_effort
response = captioning(**payload)
return io.NodeOutput(response)
class PDIDLLMNodes(ComfyExtension):
"""
Define ComfyUI nodes.
"""
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]: # type: ignore[override]
"""
Get the list of nodes.
"""
return [LLMLoader, ExtraParametersComfy, ChatViaAPI, GenerateBBOX, Caption]
async def comfy_entrypoint() -> ComfyExtension:
"""
Register ComfyUI nodes.
"""
return PDIDLLMNodes()