项目文件夹

文件

677 行
28 KiB
Python

from __future__ import annotations
import copy
import random
import re
from ollama import Client
import numpy as np
import base64
from io import BytesIO
from server import PromptServer
from aiohttp import web
from pprint import pprint
from PIL import Image
from PIL.PngImagePlugin import PngInfo
import os
from typing import TYPE_CHECKING, Any, Literal
from dataclasses import dataclass, field
from pydantic.json_schema import JsonSchemaValue
# For type checking only. Torch is not installed at runtime
if TYPE_CHECKING:
import torch
@dataclass
class ChatSession:
messages: list[dict] = field(default_factory=list)
model: str = ""
# Dictionary global per session_id
CHAT_SESSIONS: dict[str, ChatSession] = {}
# Function to filter enabled options
def _filter_enabled_options(options: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return only the ollama options whose 'enable_*' flag is True."""
if not options:
return None
enablers = [
"enable_mirostat",
"enable_mirostat_eta",
"enable_mirostat_tau",
"enable_num_ctx",
"enable_repeat_last_n",
"enable_repeat_penalty",
"enable_temperature",
"enable_seed",
"enable_stop",
"enable_tfs_z",
"enable_num_predict",
"enable_top_k",
"enable_top_p",
"enable_min_p",
]
out: dict[str, Any] = {}
for enabler in enablers:
if options.get(enabler, False):
key = enabler.replace("enable_", "")
out[key] = options[key]
return out or None
@PromptServer.instance.routes.post("/ollama/get_models")
async def get_models_endpoint(request):
data = await request.json()
url = data.get("url")
client = Client(host=url)
models = client.list().get('models', [])
try:
models = [model['model'] for model in models]
return web.json_response(models)
except Exception as e:
models = [model['name'] for model in models]
return web.json_response(models)
class OllamaSaveContext:
def __init__(self):
self._base_dir = os.path.dirname(os.path.realpath(__file__)) + os.path.sep + "saved_context"
@classmethod
def INPUT_TYPES(s):
return {"required":
{"context": ("STRING", {"forceInput": True},),
"filename": ("STRING", {"default": "context"})},
}
RETURN_TYPES = ()
FUNCTION = "ollama_save_context"
OUTPUT_NODE = True
CATEGORY = "Ollama"
def ollama_save_context(self, filename, context=None):
path = self._base_dir + os.path.sep + filename
metadata = PngInfo()
metadata.add_text("context", ','.join(map(str, context)))
image = Image.new('RGB', (100, 100), (255, 255, 255)) # Creates a 100x100 white image
image.save(path + ".png", pnginfo=metadata)
return {"ui": {"context": context}}
class OllamaLoadContext:
def __init__(self):
self._base_dir = os.path.dirname(os.path.realpath(__file__)) + os.path.sep + "saved_context"
@classmethod
def INPUT_TYPES(s):
input_dir = os.path.dirname(os.path.realpath(__file__)) + os.path.sep + "saved_context"
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f)) and f != ".keep"]
return {"required":
{"context_file": (files, {})},
}
CATEGORY = "Ollama"
RETURN_NAMES = ("context",)
RETURN_TYPES = ("STRING",)
FUNCTION = "ollama_load_context"
def ollama_load_context(self, context_file):
with Image.open(self._base_dir + os.path.sep + context_file) as img:
info = img.info
res = info.get('context', '')
return (res,)
class OllamaOptionsV2:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
seed = random.randint(1, 2 ** 31)
return {
"required": {
"enable_mirostat": ("BOOLEAN", {"default": False}),
"mirostat": ("INT", {"default": 0, "min": 0, "max":2, "step": 1, "tooltip": "Whether to use Mirostat sampling. Mirostat is an algorithm that actively maintains the quality of generated text within a desired range during text generation. (0 = disabled, 1 = Mirostat 1, 2 = Mirostat 2.0)"}),
"enable_mirostat_eta": ("BOOLEAN", {"default": False}),
"mirostat_eta": ("FLOAT", {"default": 0.1, "min": 0, "step": 0.1, "tooltip": "Mirostat's learning rate parameter influences how quickly the algorithm responds to feedback from the generated text."}),
"enable_mirostat_tau": ("BOOLEAN", {"default": False}),
"mirostat_tau": ("FLOAT", {"default": 5.0, "min": 0, "step": 0.1, "tooltip": "Mirostat's target entropy parameter controls the balance between coherence and diversity in the generated text."}),
"enable_num_ctx": ("BOOLEAN", {"default": False}),
"num_ctx": ("INT", {"default": 2048, "min": 0, "max": 2 ** 31, "step": 1, "tooltip": "Sets the size of the context window used to generate the next token."}),
"enable_repeat_last_n": ("BOOLEAN", {"default": False}),
"repeat_last_n": ("INT", {"default": 64, "min": -1, "max": 64, "step": 1, "tooltip": "Sets how far back for the model to look back to prevent repetition. (0 = disabled, -1 = num_ctx)"}),
"enable_repeat_penalty": ("BOOLEAN", {"default": False}),
"repeat_penalty": ("FLOAT", {"default": 1.1, "min": 0, "max": 2, "step": 0.05, "tooltip": "Sets how strongly to penalize repetitions. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient."}),
"enable_temperature": ("BOOLEAN", {"default": False}),
"temperature": ("FLOAT", {"default": 0.8, "min": -10, "max": 10, "step": 0.05, "tooltip": "Increasing the temperature will make the model answer more creatively."}),
"enable_seed": ("BOOLEAN", {"default": False}),
"seed": ("INT", {"default": seed, "min": 0, "max": 2 ** 31, "step": 1, "tooltip": "Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt."}),
"enable_stop": ("BOOLEAN", {"default": False}),
"stop": ("STRING", {"default": "", "multiline": False, "tooltip": "When this pattern is encountered the LLM will stop generating text and return."}),
"enable_tfs_z": ("BOOLEAN", {"default": False}),
"tfs_z": ("FLOAT", {"default": 1, "min": 1, "max": 1000, "step": 0.05}),
"enable_num_predict": ("BOOLEAN", {"default": False}),
"num_predict": ("INT", {"default": -1, "min": -2, "max": 2048, "step": 1, "tooltip": "Maximum number of tokens to predict when generating text. The default -1 means infinite generation."}),
"enable_top_k": ("BOOLEAN", {"default": False}),
"top_k": ("INT", {"default": 40, "min": 0, "max": 100, "step": 1, "tooltip": "Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative."}),
"enable_top_p": ("BOOLEAN", {"default": False}),
"top_p": ("FLOAT", {"default": 0.9, "min": 0, "max": 1, "step": 0.05, "tooltip": "Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text."}),
"enable_min_p": ("BOOLEAN", {"default": False}),
"min_p": ("FLOAT", {"default": 0.0, "min": 0, "max": 1, "step": 0.05, "tooltip": "Alternative to the top_p, and aims to ensure a balance of quality and variety. The parameter p represents the minimum probability for a token to be considered, relative to the probability of the most likely token. For example, with p=0.05 and the most likely token having a probability of 0.9, logits with a value less than 0.045 are filtered out."}),
"debug": ("BOOLEAN", {"default": False, "tooltip": "For debugging purposes of the custom nodes, no effect on ollama api."}),
},
}
RETURN_TYPES = ("OLLAMA_OPTIONS",)
RETURN_NAMES = ("options",)
FUNCTION = "ollama_options"
CATEGORY = "Ollama"
DESCRIPTION = "Various settings for advanced configuration of Ollama inference. See Ollama documentation for more details."
def ollama_options(self, **kargs):
if kargs['debug']:
print("--- ollama options v2 dump\n")
pprint(kargs)
print("---------------------------------------------------------")
return (kargs,)
class OllamaConnectivityV2:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"url": ("STRING", {
"multiline": False,
"default": "http://127.0.0.1:11434",
"tooltip": "The URL of the Ollama server. Default value points to a local instance with ollama's default port configuration."
}),
"model": ((), {"tooltip": "Select a model for inference. This is a list of available models on the Ollama server. If you don't see any, make sure the Ollama server is running on the url and there are models installed."}),
"keep_alive": ("INT", {"default": 5, "min": -1, "max": 120, "step": 1, "tooltip": "Configures how long ollama keeps the model loaded in memory after inference. -1 = keep alive indefinitely, 0 = unload model immediately after inference"}),
"keep_alive_unit": (["minutes", "hours"],),
},
}
RETURN_TYPES = ("OLLAMA_CONNECTIVITY",)
RETURN_NAMES = ("connection",)
FUNCTION = "ollama_connectivity"
CATEGORY = "Ollama"
DESCRIPTION = "Provides connection to an Ollama server. Use the refresh button to load the model list in case of connection error or after installing a new model."
def ollama_connectivity(self, url, model, keep_alive, keep_alive_unit):
data = {
"url": url,
"model": model,
"keep_alive": keep_alive,
"keep_alive_unit": keep_alive_unit,
}
return (data,)
class OllamaGenerateV2:
def __init__(self):
self.saved_context = None
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"system": ("STRING", {
"multiline": True,
"default": "You are an AI artist.",
"tooltip": "System prompt - use this to set the role and general behavior of the model."
}),
"prompt": ("STRING", {
"multiline": True,
"default": "What is art?",
"tooltip": "User prompt - a question or task you want the model to answer or perform. For vision tasks, you can refer to the input image as 'this image', 'photo' etc. like 'Describe this image in detail'"
}),
"think": ("BOOLEAN", {"default": False, "tooltip": "If enabled, the model will do a thinking process before answering. This can result in more accurate results. The thinking is then available as a separate output for debugging or understanding how the model arrived at its answer. Some models don't support this feature and the generation will fail."}),
"keep_context": ("BOOLEAN", {"default": False, "tooltip": "If enabled, the model will keep the context of the conversation and use it for the next generation. This is useful for multi-turn conversations or tasks that require context."}),
"format": (["text", "json"], {"tooltip": "Output format of the response. 'text' will return a plain text response, while 'json' will return a structured response in JSON format. This is useful when the model is part of a larger pipeline and you need additional processing on the response. In this case I recommend showing the model example outputs in the system prompt. Some models are not trained to perform well in structured output."}),
},
"optional": {
"connectivity": ("OLLAMA_CONNECTIVITY", {"forceInput": False, "tooltip": "Set an ollama provider for the generation. If this input is empty, the 'meta' input must be set."},),
"options": ("OLLAMA_OPTIONS", {"forceInput": False, "tooltip": "Connect an Ollama Options node for advanced inference configuration."},),
"images": ("IMAGE", {"forceInput": False, "tooltip": "Provide an image or a batch of images for vision tasks. Make sure that the selected model supports vision, otherwise it may hallucinate the response."},),
"context": ("OLLAMA_CONTEXT", {"forceInput": False, "tooltip": "Optionally set an existing model context, useful for multi-turn conversations, follow-up questions."},),
"meta": ("OLLAMA_META", {"forceInput": False, "tooltip": "Use this input to chain multiple 'Ollama Generate' nodes. In this case the connectivity and options inputs are passed along."},),
}
}
RETURN_TYPES = ("STRING", "STRING", "OLLAMA_CONTEXT", "OLLAMA_META",)
RETURN_NAMES = ("result", "thinking", "context", "meta",)
FUNCTION = "ollama_generate_v2"
CATEGORY = "Ollama"
DESCRIPTION = "Text generation with Ollama. Supports vision tasks, multi-turn conversations, and advanced inference options. Connect an Ollama Connectivity node to set the server URL and model."
def get_request_options(self, options):
response = None
if options is None:
return response
enablers = ['enable_mirostat', 'enable_mirostat_eta',
'enable_mirostat_tau', 'enable_mirostat_eta',
'enable_num_ctx', 'enable_repeat_last_n', 'enable_repeat_penalty',
'enable_temperature', 'enable_seed', 'enable_stop', 'enable_tfs_z', 'enable_num_predict',
'enable_top_k', 'enable_top_p', 'enable_min_p']
for enabler in enablers:
if options[enabler]:
if response is None:
response = {}
key = enabler.replace("enable_", "")
response[key] = options[key]
return response
def ollama_generate_v2(self, system, prompt, think, keep_context, format, context = None, options=None, connectivity=None, images=None, meta=None):
if connectivity is None and meta is None:
raise Exception("Required input connectivity or meta.")
if connectivity is None and meta['connectivity'] is None:
raise Exception("Required input connectivity or connectivity in meta.")
if meta is not None:
if connectivity is not None: # bypass the current meta connectivity
meta["connectivity"] = connectivity
if options is not None: # bypass the current meta options
meta["options"] = options
else:
meta = {"options": options, "connectivity": connectivity}
url = meta['connectivity']['url']
model = meta['connectivity']['model']
client = Client(host=url)
debug_print = True if meta['options'] is not None and meta['options']['debug'] else False
if format == "text":
format = ''
if context is not None and isinstance(context, str):
string_list = context.split(',')
context = [int(item.strip()) for item in string_list]
if keep_context and context is None:
context = self.saved_context
keep_alive_unit = 'm' if meta['connectivity']['keep_alive_unit'] == "minutes" else 'h'
request_keep_alive = str(meta['connectivity']['keep_alive']) + keep_alive_unit
request_options = self.get_request_options(options)
images_b64 = None
if images is not None:
images_b64 = []
for (batch_number, image) in enumerate(images):
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
buffered = BytesIO()
img.save(buffered, format="PNG")
img_bytes = base64.b64encode(buffered.getvalue())
images_b64.append(str(img_bytes, 'utf-8'))
if debug_print:
print(f"""
--- ollama generate v2 request:
url: {url}
model: {model}
system: {system}
prompt: {prompt}
images: {0 if images_b64 is None else len(images_b64)}
context: {context}
think: {think}
options: {request_options}
keep alive: {request_keep_alive}
format: {format}
---------------------------------------------------------
""")
response = client.generate(
model=model,
system=system,
prompt=prompt,
images=images_b64,
context=context,
think=think,
options=request_options,
keep_alive= request_keep_alive,
format=format,
)
if debug_print:
print("\n--- ollama generate v2 response:")
pprint(response)
print("---------------------------------------------------------")
ollama_response_text = response['response']
ollama_response_thinking = response['thinking'] if think else None
if keep_context:
self.saved_context = response["context"]
if debug_print:
print("saving context to node memory.")
return ollama_response_text, ollama_response_thinking, response['context'], meta,
class OllamaChat:
"""
Text generation with Ollama Chat.
Returns: (result: str, thinking: str|None, meta: dict, history: str)
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"system": (
"STRING",
{
"multiline": True,
"default": "You are an AI artist.",
"tooltip": "System prompt - use this to set the role and general behavior of the model.",
},
),
"prompt": (
"STRING",
{
"multiline": True,
"default": "What is art?",
"tooltip": "User prompt - a question or task you want the model to answer or perform. For vision tasks, you can refer to the input image as 'this image', 'photo' etc. like 'Describe this image in detail'",
},
),
"think": (
"BOOLEAN",
{
"default": False,
"tooltip": "If enabled, the model will do a thinking process before answering. This can result in more accurate results. The thinking is then available as a separate output for debugging or understanding how the model arrived at its answer. Some models don't support this feature and the generation will fail.",
},
),
"format": (
["text", "json"],
{
"tooltip": "Output format of the response. 'text' will return a plain text response, while 'json' will return a structured response in JSON format. This is useful when the model is part of a larger pipeline and you need additional processing on the response. In this case I recommend showing the model example outputs in the system prompt. Some models are not trained to perform well in structured output."
},
),
},
"optional": {
"connectivity": (
"OLLAMA_CONNECTIVITY",
{
"forceInput": False,
"tooltip": "Set an ollama provider for the generation. If this input is empty, the 'meta' input must be set.",
},
),
"options": (
"OLLAMA_OPTIONS",
{
"forceInput": False,
"tooltip": "Connect an Ollama Options node for advanced inference configuration.",
},
),
"images": (
"IMAGE",
{
"forceInput": False,
"tooltip": "Provide an image or a batch of images for vision tasks. Make sure that the selected model supports vision, otherwise it may hallucinate the response.",
},
),
"meta": (
"OLLAMA_META",
{
"forceInput": False,
"tooltip": "Use this input to chain multiple 'Ollama Generate' nodes. In this case the connectivity and options inputs are passed along.",
},
),
"history": (
"OLLAMA_HISTORY",
{
"forceInput": False,
"tooltip": "Optionally set an existing model history, useful for multi-turn conversations, follow-up questions.",
},
),
"reset_session": (
"BOOLEAN",
{
"default": False,
"tooltip": "Clear the conversation history. WARNING: If using shared history, this will affect all nodes using the same history ID.",
},
),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = (
"STRING",
"STRING",
"OLLAMA_META",
"OLLAMA_HISTORY",
)
RETURN_NAMES = (
"result",
"thinking",
"meta",
"history",
)
FUNCTION = "ollama_chat"
CATEGORY = "Ollama"
DESCRIPTION = "Text generation with Ollama Chat. Supports vision tasks, multi-turn conversations, and advanced inference options. Connect an Ollama Connectivity node to set the server URL and model."
def ollama_chat(
self,
system: str,
prompt: str,
think: bool,
unique_id: str,
format: str,
options: dict[str, Any] | None = None,
connectivity: dict[str, Any] | None = None,
images: list[torch.Tensor] | None = None,
meta: dict[str, Any] | None = None,
history: str | None = None,
reset_session: bool = False,
) -> tuple[str | None, str | None, dict[str, Any], str | None]:
if meta is None:
if connectivity is None:
raise ValueError("Either 'connectivity' or 'meta' must be provided.")
meta = {}
# Update with provided values (override)
if connectivity is not None:
meta["connectivity"] = connectivity
if options is not None:
meta["options"] = options
else:
meta["options"] = None
# Final validation
if "connectivity" not in meta or meta["connectivity"] is None:
raise ValueError("'connectivity' must be present in meta.")
url = meta["connectivity"]["url"]
model = meta["connectivity"]["model"]
client = Client(host=url)
debug_print = (
True if meta["options"] is not None and meta["options"]["debug"] else False
)
ollama_format: Literal["", "json"] | JsonSchemaValue | None = None
if format == "json":
ollama_format = "json"
elif format == "text":
ollama_format = ""
keep_alive_unit = (
"m" if meta["connectivity"]["keep_alive_unit"] == "minutes" else "h"
)
request_keep_alive = str(meta["connectivity"]["keep_alive"]) + keep_alive_unit
# 4. use the shared helper instead of self.get_request_options
request_options = _filter_enabled_options(options)
images_b64: list[str] | None = None
if images is not None:
images_b64 = []
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
buffered = BytesIO()
img.save(buffered, format="PNG")
img_bytes = base64.b64encode(buffered.getvalue()).decode("utf-8")
images_b64.append(img_bytes)
if debug_print:
print(
f"""
--- ollama chat request:
url: {url}
model: {model}
system: {system}
prompt: {prompt}
images: {0 if images_b64 is None else len(images_b64)}
think: {think}
options: {request_options}
keep alive: {request_keep_alive}
format: {format}
---------------------------------------------------------
"""
)
# Determinate which session to use
session_key = history if history is not None else unique_id
# If reset_session is True, reset the session
if reset_session:
CHAT_SESSIONS[session_key] = ChatSession()
if debug_print:
print(f"Session {session_key} has been reset")
# If the session doesn't exist, create it
if session_key not in CHAT_SESSIONS:
CHAT_SESSIONS[session_key] = ChatSession()
session = CHAT_SESSIONS[session_key]
# Update history for return
history = session_key
# If there is a system prompt, replace it or add it to the beginning
if system:
if session.messages and session.messages[0].get("role") == "system":
session.messages[0] = {"role": "system", "content": system}
else:
session.messages.insert(0, {"role": "system", "content": system})
# Construct the user message for history
user_message_for_history: dict[str, Any] = {
"role": "user",
"content": prompt,
}
# Add the user message to the history (without images)
session.messages.append(user_message_for_history)
if debug_print:
print("\n--- ollama chat session:")
for message in session.messages:
pprint(f"{message['role']}> {message['content'][:50]}...")
if "images" in message:
for image in message["images"]:
pprint(f"Image: {image[:50]}...")
print("---------------------------------------------------------")
# Construct the messages for the API call (with images)
messages_for_api = copy.deepcopy(session.messages)
# If there are images, modify the last user message for the API call
if images_b64 is not None:
messages_for_api[-1]["images"] = images_b64
response = client.chat(
model=model,
messages=messages_for_api,
options=request_options,
keep_alive=request_keep_alive,
format=ollama_format,
)
if debug_print:
print("\n--- ollama chat response:")
pprint(response)
print("---------------------------------------------------------")
ollama_response_text = response.message.content
ollama_response_thinking = response.message.thinking if think else None
# Add the assistant message to the history
session.messages.append(
{
"role": "assistant",
"content": ollama_response_text,
}
)
return (
ollama_response_text,
ollama_response_thinking,
meta,
history,
)
NODE_CLASS_MAPPINGS = {
"OllamaOptionsV2": OllamaOptionsV2,
"OllamaConnectivityV2": OllamaConnectivityV2,
"OllamaGenerateV2": OllamaGenerateV2,
"OllamaSaveContext": OllamaSaveContext,
"OllamaLoadContext": OllamaLoadContext,
"OllamaChat": OllamaChat,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"OllamaOptionsV2": "Ollama Options",
"OllamaConnectivityV2": "Ollama Connectivity",
"OllamaGenerateV2": "Ollama Generate",
"OllamaSaveContext": "Ollama Save Context",
"OllamaLoadContext": "Ollama Load Context",
"OllamaChat": "Ollama Chat",
}