Files
goodguy1963-ComfyUI-Thinkin…/thinkingllm_core/prompt_state.py
T
Marcel Hofer e0985b4f7b feat: improve video prompting and refactor QwenVL
Refactor the monolithic QwenVL implementation into focused core modules for model access, Hugging Face runtime and configuration, prompt state, prompt contracts, and media helpers while preserving the public compatibility facade.

Add duration-aware LTX 2.3 and MiniMax H3 prompting, including effective-duration cache identity, MiniMax frame-grid normalization, dedicated T2VA/I2VA/FL2VA/L2VA/reference contracts, and best-effort output normalization.

Improve streamed output handling by separating reasoning from final content, rejecting unfinished thinking traces, sanitizing terminal-only punctuation for legacy Windows code pages, and retaining original Unicode in responses and traces.

Expand preset recommendations, ComfyUI tooltips, system prompts, and documentation for video token budgets, thinking settings, reference roles, dialogue fidelity, and duration behavior.

Keep HF and GGUF consumers on canonical shared imports, preserve mutable export identities across reloads, and extend regression coverage for compatibility, downloads, caching, prompting, streaming, and node registration.
2026-08-10 22:04:05 +02:00

257 lines
11 KiB
Python

"""Persistent prompt cache and per-node workflow state shared by all backends."""
import hashlib
import json
import sys
from pathlib import Path
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
PROMPT_CACHE: dict = {}
CACHE_FILE = PLUGIN_ROOT / "prompt_cache.json"
NODE_PROMPT_STATE: dict = {}
NODE_STATE_FILE = PLUGIN_ROOT / "node_prompt_state.json"
def _build_workflow_fingerprint(extra_pnginfo):
"""Return a short stable fingerprint for a workflow dict so node state
can be scoped to a particular workflow window, avoiding cross-workflow
leakage while remaining stable across save/reload cycles."""
if not isinstance(extra_pnginfo, dict):
return None
workflow = extra_pnginfo.get("workflow")
if not isinstance(workflow, dict):
return None
try:
last_id = workflow.get("last_node_id", 0)
node_ids = sorted(
str(n.get("id", "")) for n in workflow.get("nodes", []) if isinstance(n, dict)
)
seed = f"{last_id}|{','.join(node_ids[:50])}"
return hashlib.md5(seed.encode()).hexdigest()[:12]
except Exception:
return None
def _make_node_state_key(node_class, unique_id, extra_pnginfo):
"""Produce a stable per-node key from the node class name, its numeric
identity within the workflow, and the workflow fingerprint when available."""
wf_fp = _build_workflow_fingerprint(extra_pnginfo)
if wf_fp:
return f"{node_class}|{wf_fp}|{unique_id}"
return f"{node_class}|{unique_id}"
def _normalize_state_signature_value(value):
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple)):
return [_normalize_state_signature_value(item) for item in value]
if isinstance(value, dict):
return {
str(key): _normalize_state_signature_value(val)
for key, val in sorted(value.items(), key=lambda item: str(item[0]))
}
return str(value)
def build_node_input_signature(**kwargs):
"""Return a stable hash for the node inputs that should invalidate saved prompt reuse."""
normalized = {
str(key): _normalize_state_signature_value(value)
for key, value in sorted(kwargs.items(), key=lambda item: str(item[0]))
}
payload = json.dumps(normalized, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
return hashlib.md5(payload.encode("utf-8")).hexdigest()
def load_node_prompt_state():
"""Load per-node prompt state from the sidecar JSON file."""
try:
if NODE_STATE_FILE.exists():
with open(NODE_STATE_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("node prompt state must be a JSON object")
NODE_PROMPT_STATE.clear()
NODE_PROMPT_STATE.update(loaded)
print(f"[QwenVL] Loaded {len(NODE_PROMPT_STATE)} node prompt states")
except Exception as e:
print(f"[QwenVL] Failed to load node prompt state: {e}")
NODE_PROMPT_STATE.clear()
def save_node_prompt_state():
"""Persist per-node prompt state to the sidecar JSON file."""
try:
with open(NODE_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(NODE_PROMPT_STATE, f, indent=2)
except Exception as e:
print(f"[QwenVL] Failed to save node prompt state: {e}")
def get_node_saved_prompt(node_class, unique_id, extra_pnginfo):
"""Return the saved prompt text for a specific node, or None."""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
entry = NODE_PROMPT_STATE.get(key)
if isinstance(entry, dict) and entry.get("text"):
return entry["text"]
return None
def get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, input_signature=None):
"""Return saved prompt text only if the generation parameters haven't changed."""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
entry = NODE_PROMPT_STATE.get(key)
if not isinstance(entry, dict) or not entry.get("text"):
return None
# If seed or params changed, invalidate and return None
if seed is not None and entry.get("seed") != seed:
return None
if max_tokens is not None and entry.get("max_tokens") != max_tokens:
return None
if temperature is not None and entry.get("temperature") != temperature:
return None
if top_p is not None and entry.get("top_p") != top_p:
return None
if repetition_penalty is not None and entry.get("repetition_penalty") != repetition_penalty:
return None
if input_signature is not None and entry.get("input_signature") != input_signature:
return None
return entry["text"]
def set_node_saved_prompt(node_class, unique_id, extra_pnginfo, text, raw_trace=None, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, input_signature=None):
"""Persist the generated prompt (and optional raw trace) for a specific node."""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
NODE_PROMPT_STATE[key] = {
"text": text,
"raw_trace": raw_trace or "",
"timestamp": None,
"seed": seed,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"repetition_penalty": repetition_penalty,
"input_signature": input_signature,
}
save_node_prompt_state()
def load_prompt_cache():
"""Load prompt cache from file"""
try:
if CACHE_FILE.exists():
with open(CACHE_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("prompt cache must be a JSON object")
PROMPT_CACHE.clear()
PROMPT_CACHE.update(loaded)
print(f"[QwenVL] Loaded {len(PROMPT_CACHE)} cached prompts")
except Exception as e:
print(f"[QwenVL] Failed to load prompt cache: {e}")
PROMPT_CACHE.clear()
def save_prompt_cache():
"""Save prompt cache to file"""
try:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(PROMPT_CACHE, f, indent=2)
except Exception as e:
print(f"[QwenVL] Failed to save prompt cache: {e}")
def log_llm_input(
backend: str,
stage: str,
preset_name: str,
user_text: str,
*,
system_text: str = "",
formatted_text: str | None = None,
media: dict[str, int] | None = None,
) -> None:
"""Print the complete text input sent to an LLM without dumping media payloads."""
lines = [
f"\n[{backend}] LLM INPUT",
f"Stage: {stage}",
f"Preset: {preset_name or '(none)'}",
"Media: " + (", ".join(f"{kind}={count}" for kind, count in (media or {}).items()) or "none"),
"--- SYSTEM ---",
system_text or "(none)",
"--- USER ---",
user_text or "(empty)",
]
if formatted_text is not None:
lines.extend(("--- FORMATTED CHAT TEMPLATE ---", formatted_text))
lines.append("--- END LLM INPUT ---")
message = "\n".join(lines)
try:
print(message)
except UnicodeEncodeError:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(message.encode(encoding, errors="replace").decode(encoding))
def get_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, video_hash=None, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, enable_thinking=None, effective_duration_seconds=None, video_prompt_contract_version=None):
"""Generate cache key from inputs including all generation parameters.
All generation-parameter fields are optional so existing callers that omit them
continue to work; when provided, changing any parameter produces a different key.
"""
key_data = {
"model": model_name,
"preset": preset_prompt,
"custom": custom_prompt.strip() if custom_prompt else "",
"image": image_hash,
"video": video_hash,
"seed": seed, # Always include seed to ensure proper caching behavior
"max_tokens": max_tokens,
"temperature": temperature if temperature is not None else None,
"top_p": top_p if top_p is not None else None,
"repetition_penalty": repetition_penalty if repetition_penalty is not None else None,
"enable_thinking": bool(enable_thinking) if enable_thinking is not None else None,
}
if effective_duration_seconds is not None:
key_data["effective_duration_seconds"] = effective_duration_seconds
if video_prompt_contract_version is not None:
key_data["video_prompt_contract_version"] = video_prompt_contract_version
# Create deterministic hash
key_str = json.dumps(key_data, sort_keys=True)
return hashlib.md5(key_str.encode()).hexdigest()
def get_alternative_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, video_hash=None, seed=None, module_name="QwenVL"):
"""Generate alternative cache key for fixed seed mode to find random prompts"""
# Only for fixed seed mode (when user wants consistent prompts)
# We consider any seed that the user keeps fixed as "fixed seed mode"
print(f"[{module_name} DEBUG] Searching through cache for model={model_name}, preset={preset_prompt}")
# Try to find any cached prompt with same model/preset/custom/image but different seed
for cached_key, cached_data in PROMPT_CACHE.items():
cached_model = cached_data.get("model")
cached_preset = cached_data.get("preset")
cached_seed = cached_data.get("seed")
print(f"[{module_name} DEBUG] Checking entry: model={cached_model}, preset={cached_preset}, seed={cached_seed}")
if (cached_model == model_name and
cached_preset == preset_prompt and
cached_seed != seed): # Different seed
# Generate the cache key that would have been created for this cached data
# to check if image/video hashes match
cached_image_hash = cached_data.get("image_hash")
cached_video_hash = cached_data.get("video_hash")
print(f"[{module_name} DEBUG] Found potential match with hashes: image={cached_image_hash}, video={cached_video_hash}")
# If the cached data doesn't have hash info, try to match by other criteria
if cached_image_hash is None and cached_video_hash is None:
# Fallback: if both current and cached have no image/video, consider it a match
if image_hash is None and video_hash is None:
print(f"[{module_name} DEBUG] Match found (no images/videos)!")
return cached_key
else:
# Match if hashes are the same (including None)
if cached_image_hash == image_hash and cached_video_hash == video_hash:
print(f"[{module_name} DEBUG] Match found (hashes match)!")
return cached_key
print(f"[{module_name} DEBUG] No alternative cache found")
return None