Updated LLM Request node with improved UI, support for A Thousand Words VLM server, and other tweaks.

This commit is contained in:
MNeMoNiCuZ
2026-09-27 20:52:56 +02:00
parent 315d06728a
commit c8abe83cac
18 changed files with 495 additions and 195 deletions
+6
View File
@@ -50,6 +50,12 @@ OPENAI_COMPATIBLE_API_KEY=
CLAUDE_CLI=
CODEX_CLI=
# --- A Thousand Words ---------------------------------------------------------
# Local captioning server (server.bat). Only needed if it is not on the
# default port.
# default http://127.0.0.1:8585
ATHOUSANDWORDS_URL=
# --- Sanctum -------------------------------------------------------------------
# Server address (http://host:port)
SANCTUM_URL=
+1 -1
View File
@@ -141,7 +141,7 @@ Visual bounding-box for Ideogram 4 with added automatic string input for each re
Automatically generates random Ideogram 4 json-structured compositions with dictionary-sourced descriptions.
<img width="2162" height="580" alt="image" src="https://github.com/user-attachments/assets/72b2eeeb-8d12-4525-991b-b284fd13d60d" />
## ✨🧠 [LLM Request](./README/llm_api.md)
## ✨ [LLM Request](./README/llm_request.md)
One node for every LLM: ChatGPT, Claude, Gemini, Grok, Groq, OpenRouter, Mistral, DeepSeek, Ollama / LM Studio / any OpenAI-compatible server on your PC or your network, and your Claude Code or Codex subscription. Model browser, live streaming preview, vision, reasoning control, and live config support.
+1 -1
View File
@@ -1,4 +1,4 @@
# ✨🧠 LLM Request
# ✨ LLM Request
One node for every language model: ChatGPT, Claude, Gemini, Grok, Groq,
OpenRouter, Mistral, DeepSeek, local servers like Ollama and LM Studio (on
+1 -1
View File
@@ -10,7 +10,7 @@ from .nodes.groq_api_llm import GroqAPILLM
from .nodes.groq_api_vlm import GroqAPIVLM
from .nodes.groq_api_alm_transcribe import GroqAPIALMTranscribe
#from .nodes.groq_api_alm_translate import GroqAPIALMTranslate
from .nodes.llm_api import LLMAPI
from .nodes.llm_request import LLMAPI
from .nodes.tiktoken_tokenizer import TiktokenTokenizer
from .nodes.string_cleaning import StringCleaning
from .nodes.generate_negative_prompt import GenerateNegativePrompt
+1 -1
View File
@@ -5,7 +5,7 @@ from .get_file_path import GetFilePath
from .groq_api_llm import GroqAPILLM
from .groq_api_vlm import GroqAPIVLM
from .groq_api_alm_transcribe import GroqAPIALMTranscribe
from .llm_api import LLMAPI
from .llm_request import LLMAPI
from .tiktoken_tokenizer import TiktokenTokenizer
from .string_cleaning import StringCleaning
from .lora_tag_loader import LoraTagLoader
+8 -2
View File
@@ -30,7 +30,7 @@
"description": "Any OpenAI-compatible server: llama.cpp, vLLM, KoboldCpp, text-generation-webui, LocalAI, Jan… Set OPENAI_COMPATIBLE_URL in .env (usually ending in /v1)."
},
{
"name": "Local Claude Code Subscription",
"name": "Claude Code Subscription",
"provider": "claude_cli",
"command": "${CLAUDE_CLI:-claude}",
"options": {
@@ -39,7 +39,7 @@
"description": "Runs the Claude Code CLI on this PC (claude -p) with your Claude subscription. Install Claude Code and sign in once by running `claude` in a terminal. Empty model uses Claude Code's default."
},
{
"name": "Local Codex Subscription",
"name": "Codex Subscription",
"provider": "codex_cli",
"command": "${CODEX_CLI:-codex}",
"options": {
@@ -117,6 +117,12 @@
"default_model": "deepseek-chat",
"description": "DeepSeek's API. Key from platform.deepseek.com."
},
{
"name": "A Thousand Words",
"provider": "athousandwords",
"base_url": "${ATHOUSANDWORDS_URL:-http://127.0.0.1:8585}",
"description": "A Thousand Words running on this PC (server.bat). A dedicated image/video captioning server: requires images or a video. No key needed."
},
{
"name": "Sanctum",
"provider": "openai",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"_readme": "Your own endpoints for the ✨🧠 LLM Request node. Copied to UserEndpoints.json on first run; that copy is git-ignored and survives updates. Entries here are added to the built-in list; one with the same name as a built-in replaces it, and \"enabled\": false hides it. Never paste keys or private addresses here: put them in the .env file in the pack root and reference them as ${VAR}. Press R in ComfyUI (refresh node definitions) after editing.",
"_readme": "Your own endpoints for the ✨ LLM Request node. Copied to UserEndpoints.json on first run; that copy is git-ignored and survives updates. Entries here are added to the built-in list; one with the same name as a built-in replaces it, and \"enabled\": false hides it. Never paste keys or private addresses here: put them in the .env file in the pack root and reference them as ${VAR}. Press R in ComfyUI (refresh node definitions) after editing.",
"endpoints": [
{
"name": "My llama.cpp box",
+14 -6
View File
@@ -101,12 +101,12 @@ class LLMAPI(io.ComfyNode):
return io.Schema(
node_id="MNeMiC_LLMAPI",
display_name="✨🧠 LLM Request",
display_name="✨ LLM Request",
category="⚡ MNeMiC Nodes",
description="Sends a prompt, and optionally images, to any LLM: ChatGPT, Claude, Gemini, Grok, Groq, OpenRouter, or Ollama and LM Studio on this PC or your network.",
description="Sends a prompt, and optionally images or a video, to any LLM: ChatGPT, Claude, Gemini, Grok, Groq, OpenRouter, A Thousand Words, or Ollama and LM Studio on this PC or your network.",
search_aliases=["llm", "chat", "ollama", "openai", "chatgpt", "gpt", "claude", "anthropic", "gemini",
"grok", "xai", "groq", "openrouter", "lm studio", "llama.cpp", "vllm", "vlm", "prompt generator",
"universal llm api"],
"universal llm api", "a thousand words", "caption", "video caption"],
inputs=[
io.Combo.Input("endpoint", options=names, default=names[0],
tooltip="Which server to talk to. Endpoints are defined in nodes/llm/*.json; keys and private addresses come from .env and are never saved in the workflow."),
@@ -120,6 +120,8 @@ class LLMAPI(io.ComfyNode):
tooltip="The request itself: what you want the model to write, rewrite or describe. May be empty: the system message or preset is then sent on its own."),
io.Image.Input("images", optional=True,
tooltip="Images to send along with the prompt, for vision models. Every image in the batch is sent."),
io.Video.Input("video", optional=True,
tooltip="A Thousand Words only: a video to caption instead of, or alongside, images. Which models accept video depends on the server's own model list."),
io.Float.Input("temperature", default=0.8, min=0.0, max=2.0, step=0.05,
tooltip="Randomness. Low is focused and repeatable, high is varied and creative. Dropped automatically for models that only allow their default."),
io.Combo.Input("reasoning", options=REASONING_LEVELS, default="default", advanced=True,
@@ -167,7 +169,7 @@ class LLMAPI(io.ComfyNode):
async def execute(cls, endpoint, model, preset, system_message, user_input, temperature,
reasoning="default", max_tokens=0, top_p=1.0, seed=42, stop="", json_mode=False,
unload_model_after=False, context_length=0, free_comfy_vram=False, max_retries=2,
raise_on_error=True, custom_endpoint="", images=None) -> io.NodeOutput:
raise_on_error=True, custom_endpoint="", images=None, video=None) -> io.NodeOutput:
node_id = cls.hidden.unique_id if cls.hidden else None
client_id = _current_client_id()
console_log = is_llm_console_log_enabled()
@@ -180,7 +182,7 @@ class LLMAPI(io.ComfyNode):
if raise_on_error:
# from None: the chained original error would otherwise be
# printed in ComfyUI's traceback.
raise RuntimeError(f"✨🧠 LLM Request — {message}") from None
raise RuntimeError(f"✨ LLM Request — {message}") from None
return io.NodeOutput("", "", False, message,
ui={"mnemic_llm": [{"ok": False, "error": message, "status": status}]})
@@ -196,6 +198,8 @@ class LLMAPI(io.ComfyNode):
ep = ep_config.resolve()
if not ep.ok:
return fail(f"{endpoint}: {' '.join(ep.problems)}", "not configured")
if video is not None and ep_config.provider != "athousandwords":
return fail(f"{endpoint} can't take a video; only A Thousand Words can.")
model = (model or "").strip() or ep_config.default_model
# Local and network servers (Ollama, LM Studio, llama.cpp…) usually
@@ -231,7 +235,10 @@ class LLMAPI(io.ComfyNode):
caption_block = "\n\n".join(f"[Image {i + 1} description]: {d}" for i, d in enumerate(descriptions) if d)
user_input = f"{caption_block}\n\n{user_input}".strip() if user_input else caption_block
pil_images = []
if not user_input and not pil_images:
has_media = bool(pil_images) or video is not None
if ep_config.provider == "athousandwords" and not has_media:
return fail("A Thousand Words requires at least one image or a video.")
if not user_input and not has_media:
if not system_message:
return fail("There is nothing to send: system_message, user_input and images are all empty.")
# Instructions alone are a valid request; most APIs need a user
@@ -243,6 +250,7 @@ class LLMAPI(io.ComfyNode):
system=system_message,
user=user_input,
images=pil_images,
videos=[video] if video is not None else [],
temperature=temperature,
top_p=top_p,
max_tokens=max_tokens,
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-mnemic-nodes"
description = "Added LLM Request node and made the wildcard processor node show a preview, and colorize special inputs"
version = "3.0.3"
description = "Updated LLM Request node with improved UI, support for A Thousand Words VLM server, and other tweaks."
version = "3.0.4"
license = { file = "LICENSE" }
dependencies = ["configparser", "groq", "transformers", "torch", "tiktoken", "imageio", "tqdm", "piexif", "requests", "colorama", "opencv-python", "python-dotenv"]
+89 -17
View File
@@ -397,27 +397,99 @@ def run_cli_chat(provider, command, req, result, **kwargs):
raise CLIError(f"could not start the CLI ({type(e).__name__})", status="cli error") from None
# Every model here is current-generation and accepts image input, so all are
# marked vision: True. static: True marks a fixed list, not fetched live, for
# the picker's "*" indicator. Only the rolling alias is listed for each model
# (not also its current pinned full name, e.g. claude-sonnet-5): both resolve
# to the same model today, and the alias is the one that keeps working as
# Anthropic ships new versions.
CLAUDE_MODELS = [
{"id": "sonnet", "detail": "alias for claude-sonnet-5"},
{"id": "claude-sonnet-5", "detail": "Sonnet 5"},
{"id": "opus", "detail": "alias for claude-opus-5-5"},
{"id": "claude-opus-5-5", "detail": "Opus 5.5"},
{"id": "haiku", "detail": "alias for claude-haiku-4-5-20251001"},
{"id": "claude-haiku-4-5-20251001", "detail": "Haiku 4.5"},
{"id": "fable", "detail": "alias for claude-fable-5-1"},
{"id": "claude-fable-5-1", "detail": "Fable 5.1"},
{"id": "sonnet", "detail": "", "vision": True, "static": True},
{"id": "opus", "detail": "", "vision": True, "static": True},
{"id": "haiku", "detail": "", "vision": True, "static": True},
{"id": "fable", "detail": "", "vision": True, "static": True},
]
# Codex has no equivalent of `claude` picking up new releases under a fixed
# alias, and no command to list what a given install supports (openai/codex#8871
# asks for exactly this); these are current model names, not a live list.
# alias. Its app-server does expose a live model/list RPC (see
# _codex_model_list_rpc below), so this is only the fallback when that can't
# be reached (CLI not installed, too old to speak the protocol, timed out…).
# Matched against codex-cli 0.157.0's own live catalog, not a guess.
CODEX_MODELS = [
{"id": "gpt-5.2-codex", "detail": "current Codex model"},
{"id": "gpt-5.1-codex-max", "detail": "previous Codex model"},
{"id": "gpt-5.1-codex-mini", "detail": "smaller, faster"},
{"id": "gpt-6-astra", "detail": "", "vision": True, "static": True},
{"id": "gpt-6-sol", "detail": "", "vision": True, "static": True},
{"id": "gpt-6-luna", "detail": "", "vision": True, "static": True},
{"id": "gpt-5.6-sol", "detail": "", "vision": True, "static": True},
{"id": "gpt-5.6-terra", "detail": "", "vision": True, "static": True},
{"id": "gpt-5.6-luna", "detail": "", "vision": True, "static": True},
{"id": "gpt-5.5", "detail": "", "vision": True, "static": True},
]
def list_cli_models(provider):
"""Neither CLI can list what a given install actually supports; these are
known model names/aliases, not fetched live."""
return list(CLAUDE_MODELS) if provider == CLAUDE else list(CODEX_MODELS)
def _codex_model_list_rpc(command, timeout=6):
"""The live model catalog from Codex's own `app-server` JSON-RPC daemon
(method "model/list"), so the picker matches what this install actually
offers. None on any failure (not installed, too old to speak this
protocol, no reply in time…).
"""
try:
with _work_dir() as cwd:
proc = _popen([command, "app-server"], CODEX, cwd)
lines = queue.Queue()
threading.Thread(target=_read_lines, args=(proc.stdout, lines), daemon=True).start()
try:
proc.stdin.write((json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"clientInfo": {"name": "ComfyUI-mnemic-nodes", "version": "1.0"}},
}) + "\n").encode())
proc.stdin.write((json.dumps({
"jsonrpc": "2.0", "id": 2, "method": "model/list", "params": {},
}) + "\n").encode())
proc.stdin.flush()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
raw = lines.get(timeout=0.25)
except queue.Empty:
continue
if raw is None:
return None
try:
msg = json.loads(raw.decode("utf-8", errors="replace"))
except ValueError:
continue
if msg.get("id") == 2:
return (msg.get("result") or {}).get("data")
return None
finally:
_terminate(proc)
except OSError:
return None
def _codex_models_from_catalog(data):
models = []
for m in data or []:
if m.get("hidden"):
continue
model_id = m.get("id") or m.get("model")
if not model_id:
continue
vision = "image" in (m.get("inputModalities") or ["text", "image"])
models.append({"id": model_id, "detail": "", "vision": vision})
return models
def list_cli_models(provider, command=""):
"""Claude Code has no documented way to enumerate installed models, so
CLAUDE_MODELS is a fixed list of current aliases/names. Codex exposes a
live catalog through its own app-server; that is queried directly, and
CODEX_MODELS is only the fallback when it can't be reached.
"""
if provider == CLAUDE:
return list(CLAUDE_MODELS)
if command:
models = _codex_models_from_catalog(_codex_model_list_rpc(command))
if models:
return models
return list(CODEX_MODELS)
+1 -1
View File
@@ -25,7 +25,7 @@ DEFAULT_ENDPOINTS_FILE = os.path.join(LLM_DIR, "DefaultEndpoints.json")
USER_ENDPOINTS_FILE = os.path.join(LLM_DIR, "UserEndpoints.json")
USER_ENDPOINTS_EXAMPLE = os.path.join(LLM_DIR, "UserEndpoints.example.json")
PROVIDERS = ("openai", "anthropic", "ollama", "claude_cli", "codex_cli")
PROVIDERS = ("openai", "anthropic", "ollama", "claude_cli", "codex_cli", "athousandwords")
CLI_PROVIDERS = ("claude_cli", "codex_cli")
CUSTOM_ENDPOINT_NAME = "Custom Endpoint - WARNING"
+99 -7
View File
@@ -12,6 +12,10 @@ Each adapter turns a ChatRequest into (url, headers, body), and turns the
reply — whole or streamed — back into text. `run_chat` does the HTTP around it:
retries, backoff, live streaming, interrupts, and dropping parameters an
endpoint rejects.
`athousandwords` is not a chat API (one multipart POST /caption per request,
no streaming, images/video only) and bypasses the adapter's build/parse: see
_run_athousandwords. Its adapter only serves list_models.
"""
import base64
@@ -61,6 +65,7 @@ class ChatRequest:
system: str = ""
user: str = ""
images: list = field(default_factory=list) # PIL images
videos: list = field(default_factory=list) # VIDEO inputs; A Thousand Words only
temperature: float | None = None
top_p: float | None = None
max_tokens: int = 0
@@ -90,8 +95,8 @@ class ChatResult:
# Helpers
# --------------------------------------------------------------------------
def encode_images(images):
"""PIL images → list of (mime, base64). Large images are scaled down."""
def _encode_image_bytes(images):
"""PIL images → list of (mime, raw bytes). Large images are scaled down."""
encoded = []
for image in images:
image = image.convert("RGB")
@@ -99,10 +104,28 @@ def encode_images(images):
image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=92)
encoded.append(("image/jpeg", base64.b64encode(buffer.getvalue()).decode("ascii")))
encoded.append(("image/jpeg", buffer.getvalue()))
return encoded
def encode_images(images):
"""PIL images → list of (mime, base64). Large images are scaled down."""
return [(mime, base64.b64encode(data).decode("ascii")) for mime, data in _encode_image_bytes(images)]
_VIDEO_MIME = {"mp4": "video/mp4", "avi": "video/x-msvideo", "mov": "video/quicktime",
"matroska": "video/x-matroska", "webm": "video/webm"}
def encode_video(video):
"""A ComfyUI VIDEO input → (filename, raw bytes, mime), without re-encoding."""
fmt = (video.get_container_format() or "mp4").lower()
ext, mime = next(((e, m) for e, m in _VIDEO_MIME.items() if e in fmt), ("mp4", "video/mp4"))
source = video.get_stream_source()
data = open(source, "rb").read() if isinstance(source, str) else source.getvalue()
return f"video.{ext}", data, mime
_THINK_BLOCK = re.compile(r"<(think|thinking|reasoning)>(.*?)</\1>", re.DOTALL | re.IGNORECASE)
_THINK_OPEN_TAG = re.compile(r"<(think|thinking|reasoning)>", re.IGNORECASE)
_THINK_OPEN_ONLY = re.compile(r"^\s*<(think|thinking|reasoning)>(.*)$", re.DOTALL | re.IGNORECASE)
@@ -470,7 +493,10 @@ class AnthropicAdapter:
def list_models(self, ep, timeout):
response = requests.get(f"{ep.base_url}/models", params={"limit": 1000}, headers=self.headers(ep), timeout=timeout, allow_redirects=False)
_raise_for_status(response)
return [{"id": m["id"], "detail": m.get("display_name", "")} for m in response.json().get("data", [])]
# Every model the Anthropic API lists is a current Claude model, and
# all of them accept image input.
return [{"id": m["id"], "detail": m.get("display_name", ""), "vision": True}
for m in response.json().get("data", [])]
class OllamaAdapter:
@@ -565,11 +591,38 @@ class OllamaAdapter:
detail = [d for d in (details.get("parameter_size"), details.get("quantization_level")) if d]
if item.get("size"):
detail.append(f"{item['size'] / 1e9:.1f} GB")
models.append({"id": item["name"], "detail": " · ".join(detail), "loaded": item["name"] in loaded})
vision = "vision" in (item.get("capabilities") or [])
models.append({"id": item["name"], "detail": " · ".join(detail),
"loaded": item["name"] in loaded, "vision": vision})
return sorted(models, key=lambda m: (not m["loaded"], m["id"].lower()))
ADAPTERS = {a.name: a for a in (OpenAIAdapter(), AnthropicAdapter(), OllamaAdapter())}
class AThousandWordsAdapter:
"""A Thousand Words is a captioning server, not a chat API: one POST
/caption per request, multipart/form-data, no streaming. run_chat sends
it through _run_athousandwords instead of this adapter's build/parse; it
only serves list_models here."""
name = "athousandwords"
def headers(self, ep):
return dict(ep.headers)
def list_models(self, ep, timeout):
response = requests.get(f"{ep.base_url}/models", headers=self.headers(ep), timeout=timeout, allow_redirects=False)
_raise_for_status(response)
data = response.json()
batch_sizes = data.get("batch_sizes") or {}
models = []
for model_id in data.get("models", []):
recommended = (batch_sizes.get(model_id) or {}).get("recommended")
# The server doesn't say which models take video; every model here
# accepts the same /caption call, images or video, so both are marked.
models.append({"id": model_id, "detail": f"batch {recommended}" if recommended else "",
"vision": True, "video": True})
return sorted(models, key=lambda m: m["id"].lower())
ADAPTERS = {a.name: a for a in (OpenAIAdapter(), AnthropicAdapter(), OllamaAdapter(), AThousandWordsAdapter())}
# CLI providers (claude_cli, codex_cli) have no HTTP adapter: see llm_cli.
@@ -692,6 +745,8 @@ def run_chat(ep, req, *, timeout=300, max_retries=2, on_delta=None, check_interr
"""
if ep.endpoint.is_cli:
return _run_cli(ep, req, timeout, on_delta, check_interrupt, log)
if ep.endpoint.provider == "athousandwords":
return _run_athousandwords(ep, req, timeout, check_interrupt, log)
adapter = get_adapter(ep.endpoint.provider)
url = adapter.chat_url(ep)
headers = adapter.headers(ep)
@@ -858,11 +913,48 @@ def _run_cli(ep, req, timeout, on_delta, check_interrupt, log):
return result
def _run_athousandwords(ep, req, timeout, check_interrupt, log):
"""POST /caption: multipart/form-data, one reply per call, no streaming."""
result = ChatResult()
started = time.monotonic()
files = [("files", (f"image{i}.jpg", data, mime))
for i, (mime, data) in enumerate(_encode_image_bytes(req.images))]
files += [("files", encode_video(video)) for video in req.videos]
form = {"model": req.model}
task_prompt = "\n\n".join(t for t in (req.system, req.user) if t)
if task_prompt:
form["task_prompt"] = task_prompt
if req.max_tokens:
form["max_tokens"] = str(req.max_tokens)
if req.temperature is not None:
form["temperature"] = str(req.temperature)
url = f"{ep.base_url}/caption"
if check_interrupt:
check_interrupt()
if log:
log(f"POST {url}\n{json.dumps({**form, 'files': f'{len(files)} file(s)'}, indent=2)}")
try:
response = requests.post(url, headers=ep.headers, data=form, files=files,
allow_redirects=False, timeout=(15, timeout))
except (requests.RequestException, LocationValueError) as e:
raise LLMError(f"Could not reach {ep.endpoint.name}: {_short_exception(e)}", status="connection error")
_raise_for_status(response)
try:
data = response.json()
result.text = "\n\n".join(r.get("caption", "") for r in data.get("results", []))
except (ValueError, AttributeError) as e:
raise LLMError(f"Bad response from {ep.endpoint.name}: {_short_exception(e)}", status="bad response")
result.seconds = time.monotonic() - started
return result
def list_models(ep, timeout=15):
"""Models the endpoint reports, falling back to the configured list."""
if ep.endpoint.is_cli:
from .llm_cli import list_cli_models
return list_cli_models(ep.endpoint.provider)
return list_cli_models(ep.endpoint.provider, ep.base_url)
adapter = get_adapter(ep.endpoint.provider)
try:
return adapter.list_models(ep, timeout)
+1 -1
View File
@@ -1,4 +1,4 @@
"""HTTP routes behind the Universal LLM node's UI (web/js/llm_api.js).
"""HTTP routes behind the Universal LLM node's UI (web/js/llm_request.js).
Endpoints are addressed by name only; the browser never receives a key or a
header value. Model listing happens here, server-side, because the key must
+3 -2
View File
@@ -149,8 +149,8 @@ LLM_ENDPOINT_VISIBILITY_IDS = {
"Ollama (network)": "MNeMiC.LLM.ShowEndpoint.OllamaNetwork",
"LM Studio (this PC)": "MNeMiC.LLM.ShowEndpoint.LMStudio",
"OpenAI-compatible server": "MNeMiC.LLM.ShowEndpoint.OpenAICompatible",
"Local Claude Code Subscription": "MNeMiC.LLM.ShowEndpoint.ClaudeCodeSubscription",
"Local Codex Subscription": "MNeMiC.LLM.ShowEndpoint.CodexSubscription",
"Claude Code Subscription": "MNeMiC.LLM.ShowEndpoint.ClaudeCodeSubscription",
"Codex Subscription": "MNeMiC.LLM.ShowEndpoint.CodexSubscription",
"OpenAI (ChatGPT)": "MNeMiC.LLM.ShowEndpoint.OpenAI",
"Anthropic (Claude)": "MNeMiC.LLM.ShowEndpoint.Anthropic",
"Google (Gemini)": "MNeMiC.LLM.ShowEndpoint.Gemini",
@@ -159,6 +159,7 @@ LLM_ENDPOINT_VISIBILITY_IDS = {
"OpenRouter": "MNeMiC.LLM.ShowEndpoint.OpenRouter",
"Mistral": "MNeMiC.LLM.ShowEndpoint.Mistral",
"DeepSeek": "MNeMiC.LLM.ShowEndpoint.DeepSeek",
"A Thousand Words": "MNeMiC.LLM.ShowEndpoint.AThousandWords",
"Sanctum": "MNeMiC.LLM.ShowEndpoint.Sanctum",
"Custom Endpoint - WARNING": "MNeMiC.LLM.ShowEndpoint.CustomEndpoint",
}
+2 -2
View File
@@ -15,7 +15,7 @@ GROQ_API_KEY=your_key_here
```
Changes apply on the next run; no restart needed. The same key is used by the
✨🧠 LLM Request node's Groq endpoint.
✨ LLM Request node's Groq endpoint.
## Inputs
@@ -49,7 +49,7 @@ Advanced:
Presets come from `nodes/groq/DefaultPrompts.json` (shipped) and
`nodes/groq/UserPrompts.json` (yours). Each entry is `{"name": ..., "content":
...}`; the name shows in the dropdown. They are shared with the ✨🧠 Universal
...}`; the name shows in the dropdown. They are shared with the ✨ Universal
LLM API node. Press R in ComfyUI (refresh node definitions) after editing.
## Notes
+25 -19
View File
@@ -1,10 +1,10 @@
# ✨🧠 LLM Request
# ✨ LLM Request
Sends a prompt, and optionally images, to any language model and returns the
reply. One node covers cloud APIs (ChatGPT, Claude, Gemini, Grok, Groq,
OpenRouter, Mistral, DeepSeek) and local servers (Ollama and LM Studio on this
PC, Ollama or any OpenAI-compatible server on your network). Presets are
shared with the Groq nodes.
Sends a prompt, and optionally images or a video, to any language model and
returns the reply. One node covers cloud APIs (ChatGPT, Claude, Gemini, Grok,
Groq, OpenRouter, Mistral, DeepSeek) and local servers (Ollama and LM Studio on
this PC, Ollama or any OpenAI-compatible server on your network, A Thousand
Words for image/video captioning). Presets are shared with the Groq nodes.
## Setup
@@ -27,22 +27,27 @@ and what is missing if not.
## Inputs
- **endpoint** — Which server to call. The list comes from
`nodes/llm/DefaultEndpoints.json` and your `nodes/llm/UserEndpoints.json`.
- **model** — Model name. Empty uses the endpoint's default model. Endpoints
on this PC or your network without one use the first chat model the
server lists, skipping embedding models (for Ollama, a model already in
memory if there is one, else the first installed one alphabetically);
cloud endpoints without one need a model chosen. Click **🔍 Models** to browse and search what the
endpoint offers; Ollama models already in memory are marked.
- **preset** — A saved system prompt, or the first entry to use
`system_message`. Click **📜 Preset** to read the selected one.
- **system_message** — The model's instructions. Ignored while a preset is
active; in the classic node view it is also greyed out and shows the
preset's text as a hint (📜 Preset shows it in either view).
- **endpoint** — Which server to call. Click it (or the ▾) to browse and
search the list, in the order `nodes/llm/DefaultEndpoints.json` and your
`nodes/llm/UserEndpoints.json` define them.
- **model** — Model name. Type one directly, or click the ▾ to browse and
search what the endpoint offers (Ollama models already in memory are
marked). Empty uses the endpoint's default model. Endpoints on this PC or
your network without one use the first chat model the server lists,
skipping embedding models (for Ollama, a model already in memory if there
is one, else the first installed one alphabetically); cloud endpoints
without one need a model chosen.
- **preset** — A saved system prompt. Picking one copies its text into
`system_message` (asking first if that would overwrite something different)
and resets itself back to the first entry, so `system_message` stays a
plain, freely editable field afterward.
- **user_input** — The request. May be empty: the system message (or preset)
is then sent on its own as the request.
- **images** — Optional. Every image in the batch is sent, for vision models.
- **video** — Optional, A Thousand Words only. A single video to caption
instead of, or alongside, images. Sending it to any other endpoint fails:
no other protocol here accepts video. Which of the server's models actually
support video input is up to the server; it isn't in its model list.
- **temperature** — Randomness. Dropped automatically for models that refuse
anything but their default.
@@ -96,6 +101,7 @@ back in an error message is masked before it is shown or returned.
| `ollama` | Ollama's native API (for `keep_alive`, `num_ctx`, `think`) |
| `claude_cli` | The Claude Code CLI on this PC, with your Claude subscription |
| `codex_cli` | The Codex CLI on this PC, with your ChatGPT subscription |
| `athousandwords` | A Thousand Words, a local image/video captioning server (not a chat API: one `POST /caption` per call, `system_message` and `user_input` are combined into its `task_prompt`, no streaming) |
**Parameters that don't fit.** Models differ in what they accept: OpenAI's
reasoning models refuse `temperature`, some servers don't know `seed`. For
+227 -127
View File
@@ -1,7 +1,7 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// ✨🧠 LLM Request — node UI.
// ✨ LLM Request — node UI.
//
// Adds a panel to the node with the endpoint's status (where it runs, whether
// its key/address is set), a searchable model browser, a connection test, a
@@ -28,10 +28,10 @@ function defaultOutHeight() {
}
const LOCATION_LABEL = {
local: ["🖥", "This PC"],
network: ["🏠", "Network"],
cloud: ["☁", "Cloud"],
unknown: ["❔", "Not set"],
local: "This PC",
network: "Network",
cloud: "Cloud",
unknown: "Not configured",
};
const panels = new Set();
@@ -97,32 +97,6 @@ function splitInlineThinking(text) {
return [text.slice(m[0].length).trimStart(), m[2].trim()];
}
// navigator.clipboard only exists in secure contexts; ComfyUI opened over
// http://<LAN IP> is not one, so fall back to the old selection copy.
async function copyText(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to the fallback
}
const area = document.createElement("textarea");
area.value = text;
area.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0";
document.body.appendChild(area);
area.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
area.remove();
return ok;
}
// --------------------------------------------------------------------------
// Styles
// --------------------------------------------------------------------------
@@ -142,7 +116,20 @@ function addStylesheet() {
.mnemic-llm-chip { padding:1px 7px; border-radius:10px; background:var(--comfy-input-bg); border:1px solid var(--border-color);
white-space:nowrap; font-size:11px; }
.mnemic-llm-chip.bad { border-color:#d29922; color:#d29922; }
.mnemic-llm-host { opacity:.6; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; flex:1; }
.mnemic-llm-host { opacity:.6; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.mnemic-llm-test { flex:0 0 auto; min-width:0; margin-left:auto; padding:2px 8px; }
.mnemic-llm-pickrow { display:flex; gap:6px; }
.mnemic-llm-pick { flex:1; min-width:0; display:flex; align-items:center; gap:4px; padding:3px 6px; border-radius:5px;
background:var(--comfy-input-bg); color:var(--fg-color); border:1px solid var(--border-color); cursor:pointer; }
.mnemic-llm-pick:hover { border-color:#58a6ff; }
.mnemic-llm-pick.mnemic-llm-pick-combo { padding:0; cursor:default; }
.mnemic-llm-pick-value { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-size:11.5px; }
.mnemic-llm-pick-input { flex:1; min-width:0; padding:3px 0 3px 6px; border:none; background:none; color:var(--fg-color); font-size:11.5px; }
.mnemic-llm-pick-input:focus { outline:none; }
.mnemic-llm-arrow { opacity:.6; font-size:10px; flex:0 0 auto; }
.mnemic-llm-arrow-btn { flex:0 0 auto; padding:3px 6px; border:none; border-left:1px solid var(--border-color);
background:none; color:var(--fg-color); cursor:pointer; }
.mnemic-llm-arrow-btn:hover .mnemic-llm-arrow { opacity:1; }
.mnemic-llm-buttons { display:flex; gap:4px; flex-wrap:wrap; }
.mnemic-llm-btn { flex:1; min-width:60px; padding:3px 6px; border-radius:5px; cursor:pointer; font-size:11.5px;
background:var(--comfy-input-bg); color:var(--fg-color); border:1px solid var(--border-color); white-space:nowrap; }
@@ -182,9 +169,11 @@ function addStylesheet() {
.mnemic-llm-item { display:flex; align-items:baseline; gap:8px; padding:4px 12px; cursor:pointer; }
.mnemic-llm-item.active { background:#58a6ff33; }
.mnemic-llm-item.current .mnemic-llm-item-id::before { content:"✓ "; color:#3fb950; }
.mnemic-llm-item-id { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mnemic-llm-item-detail { opacity:.55; font-size:11px; white-space:nowrap; }
.mnemic-llm-loaded { color:#3fb950; font-size:10px; }
.mnemic-llm-item-id { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#9198a1; }
.mnemic-llm-item-tags { flex:0 0 auto; display:flex; gap:8px; font-size:11px; }
.mnemic-llm-loaded { color:#3fb950; }
.mnemic-llm-vision { color:#d29922; }
.mnemic-llm-video { color:#58a6ff; }
.mnemic-llm-note { padding:6px 12px; opacity:.75; font-size:11.5px; }
.mnemic-llm-note.err { color:#f85149; opacity:1; }
.mnemic-llm-pop pre { margin:0; padding:10px 12px; overflow:auto; white-space:pre-wrap; word-break:break-word; font:12px/1.45 ui-monospace, monospace; }
@@ -234,17 +223,18 @@ function createPopup(anchor, title) {
function showModelPicker(panel, anchor) {
const endpoint = panel.widget("endpoint")?.value;
const pop = createPopup(anchor, `Models · ${endpoint}`);
const headTitle = pop.querySelector(".mnemic-llm-pop-head span");
const refresh = document.createElement("b");
refresh.className = "mnemic-llm-pop-refresh";
refresh.title = "Ask the endpoint again";
refresh.title = "Refresh: ask the endpoint for its model list again, instead of using the last one fetched (cached for 1 minute)";
refresh.textContent = "⟳";
pop.querySelector(".mnemic-llm-pop-head").insertBefore(refresh, pop.querySelector(".mnemic-llm-pop-close"));
const search = document.createElement("input");
search.placeholder = "Search, or type any model name and press Enter…";
search.placeholder = "Search…";
const note = document.createElement("div");
note.className = "mnemic-llm-note";
note.textContent = "Asking the endpoint…";
note.hidden = true;
const list = document.createElement("div");
list.className = "mnemic-llm-list";
pop.append(search, note, list);
@@ -254,7 +244,6 @@ function showModelPicker(panel, anchor) {
let shown = [];
let active = 0;
const current = panel.widget("model")?.value ?? "";
const info = endpointCache.byName.get(endpoint);
const choose = (id) => {
panel.setModel(id);
@@ -265,9 +254,9 @@ function showModelPicker(panel, anchor) {
const q = search.value.trim().toLowerCase();
const terms = q.split(/\s+/).filter(Boolean);
shown = models.filter((m) => terms.every((t) => `${m.id} ${m.detail ?? ""}`.toLowerCase().includes(t)));
const items = [{ id: "", label: `Endpoint default${info?.default_model ? ` (${info.default_model})` : ""}`, detail: "" }, ...shown];
const items = [...shown];
if (q && !models.some((m) => m.id.toLowerCase() === q)) {
items.push({ id: search.value.trim(), label: `Use “${search.value.trim()}”`, detail: "custom" });
items.push({ id: search.value.trim(), label: `Use “${search.value.trim()}”` });
}
shown = items;
active = Math.min(active, items.length - 1);
@@ -276,24 +265,33 @@ function showModelPicker(panel, anchor) {
const row = document.createElement("div");
row.className = "mnemic-llm-item";
if (i === active) row.classList.add("active");
if (m.id === current && (m.id !== "" || current === "")) row.classList.add("current");
if (m.id === current) row.classList.add("current");
const id = document.createElement("span");
id.className = "mnemic-llm-item-id";
id.textContent = m.label ?? m.id;
id.title = m.id;
row.append(id);
const tags = document.createElement("span");
tags.className = "mnemic-llm-item-tags";
if (m.loaded) {
const loaded = document.createElement("span");
loaded.className = "mnemic-llm-loaded";
loaded.textContent = "● in memory";
row.append(loaded);
loaded.textContent = "in memory";
tags.append(loaded);
}
if (m.detail) {
const detail = document.createElement("span");
detail.className = "mnemic-llm-item-detail";
detail.textContent = m.detail;
row.append(detail);
if (m.vision) {
const vision = document.createElement("span");
vision.className = "mnemic-llm-vision";
vision.textContent = "vision";
tags.append(vision);
}
if (m.video) {
const video = document.createElement("span");
video.className = "mnemic-llm-video";
video.textContent = "video";
tags.append(video);
}
if (tags.childNodes.length) row.append(tags);
row.addEventListener("mousemove", () => {
if (active === i) return;
list.querySelector(".active")?.classList.remove("active");
@@ -307,14 +305,16 @@ function showModelPicker(panel, anchor) {
};
const load = async (force) => {
note.className = "mnemic-llm-note";
note.textContent = "Asking the endpoint…";
const data = await getModels(endpoint, force, panel.customId());
if (openPopup !== pop) return;
models = data.models ?? [];
const isStatic = models.length > 0 && models.every((m) => m.static);
headTitle.textContent = `Models · ${endpoint}${isStatic ? " *" : ""} (${models.length})`;
headTitle.title = isStatic ? "* a fixed list built into this pack, not fetched live from the endpoint" : "";
if (data.ok) {
note.textContent = `${models.length} model${models.length === 1 ? "" : "s"} · ${data.ms} ms`;
note.hidden = true;
} else {
note.hidden = false;
note.className = "mnemic-llm-note err";
note.textContent = `${data.error}${models.length ? " Showing models from the config instead." : ""}`;
}
@@ -322,7 +322,7 @@ function showModelPicker(panel, anchor) {
};
search.addEventListener("input", () => {
active = search.value ? 1 : 0;
active = 0;
render();
});
search.addEventListener("keydown", (e) => {
@@ -344,16 +344,73 @@ function showModelPicker(panel, anchor) {
load(false);
}
async function showPreset(panel, anchor) {
const name = panel.widget("preset")?.value;
const pop = createPopup(anchor, name === DEFAULT_PRESET ? "No preset selected" : name);
const pre = document.createElement("pre");
if (name === DEFAULT_PRESET) {
pre.textContent = "The node is using its own system_message field.\n\nPick a preset to replace it with a saved system prompt. Presets are shared with the Groq nodes: nodes/groq/UserPrompts.json and UserPrompts_VLM.json.";
} else {
pre.textContent = (await getPresets(name))[name] ?? "(preset not found: it may have been removed from the preset files)";
}
pop.append(pre);
function showEndpointPicker(panel, anchor) {
const widget = panel.widget("endpoint");
const names = widget?.options?.values ?? [];
const current = widget?.value ?? "";
const pop = createPopup(anchor, "Endpoints");
const search = document.createElement("input");
search.placeholder = "Search…";
const list = document.createElement("div");
list.className = "mnemic-llm-list";
pop.append(search, list);
search.focus();
const choose = (name) => {
panel.setEndpoint(name);
closePopup();
};
let shown = [];
let active = 0;
const render = () => {
const q = search.value.trim().toLowerCase();
shown = names.filter((name) => !q || name.toLowerCase().includes(q));
active = Math.min(active, shown.length - 1);
list.replaceChildren(
...shown.map((name, i) => {
const row = document.createElement("div");
row.className = "mnemic-llm-item";
if (i === active) row.classList.add("active");
if (name === current) row.classList.add("current");
const id = document.createElement("span");
id.className = "mnemic-llm-item-id";
id.textContent = name;
row.append(id);
row.addEventListener("mousemove", () => {
if (active === i) return;
list.querySelector(".active")?.classList.remove("active");
row.classList.add("active");
active = i;
});
row.addEventListener("click", () => choose(name));
return row;
})
);
};
search.addEventListener("input", () => {
active = 0;
render();
});
search.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
active = (active + (e.key === "ArrowDown" ? 1 : -1) + shown.length) % shown.length;
render();
list.querySelector(".active")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter") {
e.preventDefault();
const pick = shown[active];
if (pick) choose(pick);
} else if (e.key === "Escape") {
closePopup();
}
});
render();
}
// --------------------------------------------------------------------------
@@ -372,16 +429,21 @@ class LLMPanel {
<span class="mnemic-llm-dot"></span>
<span class="mnemic-llm-chip" data-role="where"></span>
<span class="mnemic-llm-chip" data-role="key"></span>
<span class="mnemic-llm-host"></span>
<button class="mnemic-llm-btn mnemic-llm-test" data-act="test" title="Check that the endpoint answers">Test</button>
</div>
<div class="mnemic-llm-buttons">
<button class="mnemic-llm-btn" data-act="models" title="Browse the models this endpoint offers">🔍 Models</button>
<button class="mnemic-llm-btn" data-act="test" title="Check that the endpoint answers">⚡ Test</button>
<button class="mnemic-llm-btn" data-act="preset" title="Show the selected preset's system prompt">📜 Preset</button>
<button class="mnemic-llm-btn" data-act="copy" title="Copy the last reply">📋 Copy</button>
<div class="mnemic-llm-host"></div>
<div class="mnemic-llm-pickrow">
<button class="mnemic-llm-pick" data-act="endpoints" title="Endpoint - click to choose">
<span class="mnemic-llm-pick-value" data-role="endpoint-value"></span>
<span class="mnemic-llm-arrow">▾</span>
</button>
<span class="mnemic-llm-pick mnemic-llm-pick-combo">
<input class="mnemic-llm-pick-input" data-role="model-input" placeholder="Model" title="Model - type a name, or click ▾ to browse" spellcheck="false" autocomplete="off">
<button class="mnemic-llm-arrow-btn" data-act="models" title="Browse the models this endpoint offers"><span class="mnemic-llm-arrow">▾</span></button>
</span>
</div>
<div class="mnemic-llm-custom" hidden>
<div class="mnemic-llm-warn"><b>⚠ Custom endpoint.</b> The address and key you enter are stored on this
<div class="mnemic-llm-warn"><b>Custom endpoint.</b> The address and key you enter are stored on this
ComfyUI machine only (nodes/llm/CustomEndpoints.local.json, plain text) and never in the workflow:
the workflow keeps just a random id, so shared workflows and images don't carry them.
Changing the address or protocol clears the saved key.
@@ -398,21 +460,21 @@ class LLMPanel {
</div>
<div class="mnemic-llm-row">
<input data-f="key" type="password" placeholder="API key (optional)" autocomplete="new-password">
<button class="mnemic-llm-btn" data-act="custom-save" style="flex:0 0 auto">💾 Save</button>
<button class="mnemic-llm-btn" data-act="custom-save" style="flex:0 0 auto">Save</button>
</div>
<div class="mnemic-llm-custom-status"></div>
</div>
<div class="mnemic-llm-out empty">The reply will appear here.</div>
<div class="mnemic-llm-out empty">No reply yet.</div>
<div class="mnemic-llm-stats"></div>
`;
this.dot = this.el.querySelector(".mnemic-llm-dot");
this.where = this.el.querySelector('[data-role="where"]');
this.key = this.el.querySelector('[data-role="key"]');
this.host = this.el.querySelector(".mnemic-llm-host");
this.endpointValue = this.el.querySelector('[data-role="endpoint-value"]');
this.modelInput = this.el.querySelector('[data-role="model-input"]');
this.out = this.el.querySelector(".mnemic-llm-out");
this.stats = this.el.querySelector(".mnemic-llm-stats");
this.copyButton = this.el.querySelector('[data-act="copy"]');
this.copyButton.disabled = true;
this.custom = this.el.querySelector(".mnemic-llm-custom");
this.customStatus = this.el.querySelector(".mnemic-llm-custom-status");
this.customField = (f) => this.custom.querySelector(`[data-f="${f}"]`);
@@ -425,10 +487,17 @@ class LLMPanel {
// Typing in these fields must not trigger ComfyUI shortcuts.
for (const type of ["keydown", "keyup", "keypress", "pointerdown", "wheel"]) {
this.custom.addEventListener(type, (e) => e.stopPropagation());
this.modelInput.addEventListener(type, (e) => e.stopPropagation());
}
this.modelInput.addEventListener("input", () => {
const w = this.widget("model");
if (!w) return;
w.value = this.modelInput.value;
w.callback?.(w.value);
});
this.el.addEventListener("pointerdown", (e) => {
if (e.target.closest("button, .mnemic-llm-out")) e.stopPropagation();
if (e.target.closest("button, input, .mnemic-llm-out")) e.stopPropagation();
});
this.out.addEventListener("wheel", (e) => {
if (this.out.scrollHeight > this.out.clientHeight) e.stopPropagation();
@@ -454,6 +523,10 @@ class LLMPanel {
this.domWidget.computeLayoutSize = undefined;
this.applyOutHeight();
this.hideRawWidgets();
this.syncEndpointValue();
this.syncModelInput();
// Dragging the reply preview's resize handle persists the chosen
// height on this node and resizes the node to match.
new ResizeObserver(() => {
@@ -466,6 +539,31 @@ class LLMPanel {
}).observe(this.out);
}
// The endpoint and model widgets keep their value (workflows still store
// just the name/model string) but are never drawn: the pick button and
// the model input above are the only way to change them now.
hideRawWidgets() {
for (const name of ["endpoint", "model"]) {
const w = this.widget(name);
if (!w) continue;
w.hidden = true;
w.computeSize = () => [0, -4];
if (w.type !== "hidden") {
w.origType = w.origType || w.type;
w.type = "hidden";
}
}
}
syncEndpointValue() {
if (this.endpointValue) this.endpointValue.textContent = this.widget("endpoint")?.value ?? "";
}
syncModelInput() {
const w = this.widget("model");
if (w && this.modelInput && this.modelInput.value !== (w.value ?? "")) this.modelInput.value = w.value ?? "";
}
outHeight() {
return this.node.properties?.[OUT_HEIGHT_PROPERTY] ?? defaultOutHeight();
}
@@ -581,20 +679,20 @@ class LLMPanel {
}
this.info = info;
if (!info) {
this.setChip(this.where, "❔ Unknown endpoint", true);
this.setChip(this.where, "Unknown endpoint", true);
this.setChip(this.key, "", false);
this.host.textContent = "";
if (this.state === "idle") this.setDot("warn");
return;
}
const [icon, label] = LOCATION_LABEL[info.location] ?? LOCATION_LABEL.unknown;
this.setChip(this.where, `${icon} ${label} · ${info.provider}`, info.location === "unknown");
const label = LOCATION_LABEL[info.location] ?? LOCATION_LABEL.unknown;
this.setChip(this.where, `${label} · ${info.provider}`, info.location === "unknown");
if (info.is_custom) {
this.setChip(this.key, info.key_set ? "🔑 key saved" : "🔓 no key", false);
this.setChip(this.key, info.key_set ? "key saved" : "no key", false);
} else if (info.key_env) {
this.setChip(this.key, info.key_set ? "🔑 key set" : info.key_optional ? "🔓 no key" : `🔑 ${info.key_env} missing`, !info.key_set && !info.key_optional);
this.setChip(this.key, info.key_set ? "key set" : info.key_optional ? "no key" : `${info.key_env} missing`, !info.key_set && !info.key_optional);
} else {
this.setChip(this.key, "🔓 no key needed", false);
this.setChip(this.key, "no key needed", false);
}
this.host.title = [info.description, ...info.problems].filter(Boolean).join("\n\n");
this.updateModelHint();
@@ -602,7 +700,7 @@ class LLMPanel {
// Green once configured; ⚡ Test checks that it actually answers.
this.setDot(info.ok ? "ok" : "warn");
if (!info.ok && !this.result) this.showNote(info.problems.join(" "), false);
else if (info.ok && !this.result) this.showNote("The reply will appear here.", false);
else if (info.ok && !this.result) this.showNote("No reply yet.", false);
}
}
@@ -631,30 +729,47 @@ class LLMPanel {
: ["local", "network"].includes(this.info.location) ? "model: first the server lists"
: "no model chosen";
}
this.host.textContent = [this.info.host, hint].filter(Boolean).join(" · ");
// The where-chip already names the CLI (e.g. "This PC · claude_cli");
// info.host would just repeat that in different words.
const hostPart = this.info.is_cli ? "" : this.info.host;
this.host.textContent = [hostPart, hint].filter(Boolean).join(" · ");
}
// ---- presets --------------------------------------------------------
async updatePresetState() {
const preset = this.widget("preset")?.value;
const presetButton = this.el.querySelector('[data-act="preset"]');
if (presetButton) presetButton.disabled = !preset || preset === DEFAULT_PRESET;
const system = this.widget("system_message");
const input = system?.inputEl;
if (!input) return;
if (preset && preset !== DEFAULT_PRESET) {
const text = (await getPresets(preset))[preset] ?? "";
if (this.widget("preset")?.value !== preset) return;
input.style.opacity = "0.45";
input.title = "Ignored while a preset is selected.";
input.dataset.mnemicPlaceholder ??= input.placeholder ?? "";
input.placeholder = `Preset “${preset}” is active:\n\n${text.slice(0, 600)}${text.length > 600 ? "…" : ""}`;
} else {
input.style.opacity = "";
input.title = "";
if (input.dataset.mnemicPlaceholder !== undefined) input.placeholder = input.dataset.mnemicPlaceholder;
// Picking a preset copies its text into system_message once, then resets
// the preset dropdown to default: a one-shot insert, not a persistent
// mode, so the field stays freely editable afterward.
async applyPreset() {
const w = this.widget("preset");
const name = w?.value;
if (!name || name === DEFAULT_PRESET) return;
const text = (await getPresets(name))[name] ?? "";
if (w.value !== name) return; // changed again while this was loading
const sys = this.widget("system_message");
const current = (sys?.value ?? "").trim();
if (current && current !== text.trim() && !window.confirm(`Replace the current system message with the "${name}" preset?`)) {
w.value = DEFAULT_PRESET;
w.callback?.(DEFAULT_PRESET);
return;
}
if (sys) {
sys.value = text;
sys.callback?.(text);
}
w.value = DEFAULT_PRESET;
w.callback?.(DEFAULT_PRESET);
this.node.setDirtyCanvas(true, true);
}
// ---- endpoints --------------------------------------------------------
setEndpoint(name) {
const w = this.widget("endpoint");
if (!w || w.value === name) return;
w.value = name;
w.callback?.(name);
this.node.setDirtyCanvas(true, true);
}
// ---- models ---------------------------------------------------------
@@ -665,6 +780,7 @@ class LLMPanel {
w.value = id;
w.callback?.(id);
this.rememberModel();
this.syncModelInput();
this.node.setDirtyCanvas(true, true);
}
@@ -688,9 +804,10 @@ class LLMPanel {
w.value = remembered;
this.node.setDirtyCanvas(true, true);
}
this.syncEndpointValue();
this.syncModelInput();
this.result = null;
this.stats.textContent = "";
this.updateCopyState();
this.refreshStatus();
}
@@ -698,17 +815,8 @@ class LLMPanel {
async onButton(act, button) {
if (act === "custom-save") return this.saveCustom();
if (act === "endpoints") return showEndpointPicker(this, button);
if (act === "models") return showModelPicker(this, button);
if (act === "preset") return showPreset(this, button);
if (act === "copy") {
// The reply on screen: the finished one, or what streamed in so far.
const text = this.result?.text ?? this.partial?.text ?? "";
if (!text) return;
const ok = await copyText(text);
button.textContent = ok ? "✓ Copied" : "Copy failed";
setTimeout(() => (button.textContent = "📋 Copy"), 1200);
return;
}
if (act === "test") {
button.disabled = true;
const runId = this.runId;
@@ -736,10 +844,6 @@ class LLMPanel {
this.out.textContent = text;
}
updateCopyState() {
this.copyButton.disabled = !(this.result?.text ?? this.partial?.text ?? "");
}
renderReply(text, thinking, streaming) {
this.out.className = "mnemic-llm-out";
const pinned = this.out.scrollHeight - this.out.scrollTop - this.out.clientHeight < 24;
@@ -788,7 +892,6 @@ class LLMPanel {
this.showNote(msg.error, true);
this.stats.textContent = "";
}
this.updateCopyState();
}
onResult(summary) {
@@ -799,7 +902,6 @@ class LLMPanel {
this.setDot("err");
this.showNote(summary.error ?? summary.status, true);
this.stats.textContent = "";
this.updateCopyState();
return;
}
this.result = summary;
@@ -813,7 +915,6 @@ class LLMPanel {
if (summary.output_tokens && summary.seconds > 0) parts.push(`${Math.round(summary.output_tokens / summary.seconds)} tok/s`);
if (summary.status && summary.status !== "200 OK") parts.push(summary.status.replace(/^200 OK /, ""));
this.stats.textContent = parts.join(" · ");
this.updateCopyState();
// .env may have changed since the panel last looked (e.g. a key added).
this.refreshStatus(true);
}
@@ -825,7 +926,6 @@ class LLMPanel {
// Keep what arrived, without the streaming caret.
if (this.partial?.text || this.partial?.thinking) this.renderReply(this.partial.text, this.partial.thinking, false);
this.stats.textContent = "interrupted";
this.updateCopyState();
}
}
@@ -854,7 +954,7 @@ function chainCallback(widget, fn) {
}
app.registerExtension({
name: "MNeMiC.LLMAPI",
name: "MNeMiC.LLMRequest",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_ID) return;
@@ -866,18 +966,16 @@ app.registerExtension({
this.mnemicLLM = panel;
chainCallback(panel.widget("endpoint"), (_value, previous) => panel.onEndpointChanged(previous));
chainCallback(panel.widget("preset"), () => panel.updatePresetState());
chainCallback(panel.widget("preset"), () => panel.applyPreset());
chainCallback(panel.widget("model"), () => {
panel.rememberModel();
panel.syncModelInput();
panel.updateModelHint();
});
const [w, h] = this.size;
this.setSize([Math.max(w, 400), Math.max(h, this.computeSize()[1])]);
requestAnimationFrame(() => {
panel.refreshStatus();
panel.updatePresetState();
});
requestAnimationFrame(() => panel.refreshStatus());
return r;
};
@@ -890,6 +988,9 @@ app.registerExtension({
const w = panel.widget(name);
if (w) w._mnemicLast = w.value;
}
panel.hideRawWidgets();
panel.syncEndpointValue();
panel.syncModelInput();
requestAnimationFrame(() => {
// Undo/redo and tab switches rebuild the node, and
// onExecuted is not replayed: restore the last reply.
@@ -897,7 +998,6 @@ app.registerExtension({
const key = Object.keys(outputs).find((k) => panel.matches(k) && outputs[k]?.mnemic_llm?.[0]);
if (key && !panel.result) panel.onResult(outputs[key].mnemic_llm[0]);
panel.refreshStatus();
panel.updatePresetState();
// A node saved before the reply box got a fixed height
// may carry a stale, oversized node height: correct it.
panel.fitNode();
+13 -4
View File
@@ -244,8 +244,8 @@ app.registerExtension({
},
{
id: "MNeMiC.LLM.ShowEndpoint.ClaudeCodeSubscription",
name: "Local Claude Code Subscription",
category: ["⚡MNeMiC Nodes", "LLM Request", "Show Endpoints", "Local Claude Code Subscription"],
name: "Claude Code Subscription",
category: ["⚡MNeMiC Nodes", "LLM Request", "Show Endpoints", "Claude Code Subscription"],
tooltip: "Show this endpoint in the LLM Request node's endpoint dropdown.",
type: "boolean",
defaultValue: true,
@@ -253,8 +253,8 @@ app.registerExtension({
},
{
id: "MNeMiC.LLM.ShowEndpoint.CodexSubscription",
name: "Local Codex Subscription",
category: ["⚡MNeMiC Nodes", "LLM Request", "Show Endpoints", "Local Codex Subscription"],
name: "Codex Subscription",
category: ["⚡MNeMiC Nodes", "LLM Request", "Show Endpoints", "Codex Subscription"],
tooltip: "Show this endpoint in the LLM Request node's endpoint dropdown.",
type: "boolean",
defaultValue: true,
@@ -332,6 +332,15 @@ app.registerExtension({
defaultValue: true,
sortOrder: -140,
},
{
id: "MNeMiC.LLM.ShowEndpoint.AThousandWords",
name: "A Thousand Words",
category: ["⚡MNeMiC Nodes", "LLM Request", "Show Endpoints", "A Thousand Words"],
tooltip: "Show this endpoint in the LLM Request node's endpoint dropdown.",
type: "boolean",
defaultValue: true,
sortOrder: -145,
},
{
id: "MNeMiC.LLM.ShowEndpoint.Sanctum",
name: "Sanctum",