277 lines
11 KiB
Python
277 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,
|
|
mask_prompt_contract_version=None,
|
|
thinking_budget_policy_version=None,
|
|
):
|
|
"""Generate cache key from inputs including all generation parameters.
|
|
|
|
Input and contract-version fields are optional so existing callers that omit them
|
|
continue to work; when provided, changing a field 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 thinking_budget_policy_version is not None:
|
|
key_data["thinking_budget_policy_version"] = thinking_budget_policy_version
|
|
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
|
|
if mask_prompt_contract_version is not None:
|
|
key_data["mask_prompt_contract_version"] = mask_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
|