commit 3c7fa99d7815d79ae20b1fc59321b7699f4accce Author: Pablo Montero Date: Mon Apr 13 10:29:55 2026 +0200 Initial commit: Session Memory Node with Feedback Editor and Thumbnail system diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..314c732 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +sessions/ +.DS_Store +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..68a7c90 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# 🧠 Session Memory Node + +Nodo de ComfyUI que acumula un historial estructurado de cada ejecuciΓ³n creativa y lo inyecta en el sistema prompt de la siguiente ejecuciΓ³n. DiseΓ±ado para eliminar la repeticiΓ³n de localizaciones, conceptos y configuraciones entre Runs consecutivos del motor MoodBoard Transfer. + +## Problema que resuelve + +El motor MoodBoard Transfer no tiene memoria entre ejecuciones (Runs). Esto causa: + +- **Reciclaje de localizaciones**: Las mismas ubicaciones aparecen en 4-5 Runs consecutivos +- **Colisiones de conceptos**: Nombres de concepto semΓ‘nticamente idΓ©nticos se repiten +- **MonotonΓ­a de configuraciones**: La configuraciΓ³n "Worn" se acumula al 70-80% +- **Violaciones repetidas**: Los mismos patrones de fallo reaparecen en cada Run + +## CΓ³mo funciona + +``` +Run N comienza + β”œβ”€β”€ 1. Session Memory EMITE el log acumulado (Runs 1..N-1) + β”œβ”€β”€ 2. El LLM genera con conciencia del historial + β”œβ”€β”€ 3. El output pasa al Gemini Summarizer + β”œβ”€β”€ 4. El Summarizer extrae un digest estructurado + └── 5. Session Memory AΓ‘ADE el digest al log (para Run N+1) +``` + +### Orden de ejecuciΓ³n clave + +El nodo **primero emite** el estado actual y **despuΓ©s aΓ±ade** el nuevo resumen. Esto resuelve la dependencia circular: + +- **Run 1**: output = `""` (vacΓ­o), luego aΓ±ade resumen del Run 1 +- **Run 2**: output = resumen del Run 1, luego aΓ±ade resumen del Run 2 +- **Run N**: output = Runs 1 a N-1, luego aΓ±ade resumen del Run N + +## Inputs + +| Input | Tipo | Requerido | DescripciΓ³n | +|---|---|---|---| +| `run_summary` | `STRING` | SΓ­ | Texto de resumen estructurado del nodo Gemini Summarizer | +| `reset_session` | `BOOLEAN` | No (default: `False`) | Limpia toda la memoria acumulada | +| `max_runs` | `INT` | No (default: `0`) | Ventana deslizante: mantiene solo los ΓΊltimos N entries. 0 = ilimitado | + +## Outputs + +| Output | Tipo | DescripciΓ³n | +|---|---|---| +| `session_memory` | `STRING` | Log acumulado completo para inyectar en `SESSION_MEMORY` del system prompt | +| `run_count` | `INT` | NΓΊmero de Run actual en la sesiΓ³n (1-indexed) | + +## Persistencia + +- **En memoria** (variable de clase): Sobrevive entre Runs dentro de la misma sesiΓ³n de ComfyUI +- **Se pierde** al reiniciar ComfyUI +- `max_runs` controla una ventana deslizante para gestionar el budget de tokens + +## InstalaciΓ³n + +Copiar la carpeta `ComfyUI-Session-Memory` en `ComfyUI/custom_nodes/` y reiniciar ComfyUI. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e061d84 --- /dev/null +++ b/__init__.py @@ -0,0 +1,5 @@ +from .session_memory import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/session_memory.py b/session_memory.py new file mode 100644 index 0000000..50debc5 --- /dev/null +++ b/session_memory.py @@ -0,0 +1,669 @@ +""" +🧠 Session Memory β€” Reader + Writer + Feedback Editor nodes for ComfyUI. + +Architecture (no graph cycle): + + 🧠 Reader ──session_memory──► system prompt ──► LLM ──► Summarizer + ↑ β”‚ + β”‚ πŸ’Ύ Writer + β”‚ β”‚ + β”‚ ⭐ Feedback Editor + β”‚ β”‚ + └───────────────── sessions/{session_id}.json β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +Writer saves the run, then Feedback Editor shows it for rating/notes. +Reader loads everything (entries + feedback) at the start of the next Run. +""" + +import json +import logging +import os +import re +import shutil +import threading +import time + +from aiohttp import web +from server import PromptServer + +logger = logging.getLogger(__name__) + +# ── Session file helpers ─────────────────────────────────────────────── +SESSIONS_DIR = os.path.join(os.path.dirname(__file__), "sessions") + +# Threading events for blocking mode +_pending_feedback: dict[str, threading.Event] = {} + + +def _safe_id(session_id: str) -> str: + """Sanitize session_id to a safe filename component.""" + return "".join(c for c in session_id if c.isalnum() or c in "-_") or "default" + + +def _session_path(session_id: str) -> str: + os.makedirs(SESSIONS_DIR, exist_ok=True) + return os.path.join(SESSIONS_DIR, f"{_safe_id(session_id)}.json") + + +def _load(session_id: str) -> dict: + path = _session_path(session_id) + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"[Session Memory] Could not read {path}: {e}") + return {"run_count": 0, "entries": [], "feedback": {}} + + +def _save(session_id: str, data: dict) -> None: + path = _session_path(session_id) + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except OSError as e: + logger.error(f"[Session Memory] Could not write {path}: {e}") + + +def _parse_prompts_from_entry(entry_text: str) -> list[dict]: + """Extract prompt headers and concepts from a run entry string.""" + prompts = [] + prompt_blocks = re.split(r'(?=PROMPT \d+:)', entry_text) + for block in prompt_blocks: + m = re.match(r'PROMPT (\d+):', block) + if m: + prompt_num = int(m.group(1)) + concept_match = re.search(r'concept:\s*(.+)', block) + concept = (concept_match.group(1).strip() + if concept_match else f"Prompt {prompt_num}") + prompts.append({"number": prompt_num, "concept": concept}) + return prompts + + +# ── Thumbnail helpers ────────────────────────────────────────────────── + +def _session_images_dir(session_id: str) -> str: + """Get the image storage directory for a session.""" + return os.path.join(SESSIONS_DIR, _safe_id(session_id)) + + +def _save_thumbnails(session_id, run_number, num_prompts, batch_count, + images_tensor): + """Save batch images as downscaled JPEG thumbnails. + + Layout: sessions/{sid}/run_{N}/prompt_{P}/img_{K}.jpg + where K is the batch/pass index (0-based). + + Image order in tensor: [pass0_p1, pass0_p2, ..., pass1_p1, pass1_p2, ...] + Total images = num_prompts Γ— batch_count. + """ + import numpy as np + from PIL import Image as PILImage + + base_dir = _session_images_dir(session_id) + run_dir = os.path.join(base_dir, f"run_{run_number}") + + # NOTE: Do NOT rmtree run_dir here. ComfyUI with batch_count>1 + # re-executes the whole graph for each batch. Batch 1 saves img_0, + # then batch 2 arrives and must APPEND img_1, not destroy img_0. + + B = images_tensor.shape[0] + saved = 0 + + logger.info( + f"[Session Feedback] Saving thumbnails: B={B}, " + f"num_prompts={num_prompts}, batch_count_widget={batch_count}" + ) + + # Distribute images round-robin: image[i] β†’ prompt (i % num_prompts) + for i in range(B): + p = i % num_prompts # 0-based prompt index + + prompt_dir = os.path.join(run_dir, f"prompt_{p + 1}") + os.makedirs(prompt_dir, exist_ok=True) + + # Find next available index for this prompt + existing = [ + f for f in os.listdir(prompt_dir) + if f.startswith("img_") and f.endswith(".jpg") + ] + next_idx = len(existing) + + # Get single image [H, W, C] + img_t = images_tensor[i] + h, w = img_t.shape[0], img_t.shape[1] + + # Downscale to max 1 megapixel + current_mp = (h * w) / 1_000_000 + if current_mp > 1.0: + scale = (1.0 / current_mp) ** 0.5 + new_h = max(1, int(h * scale)) + new_w = max(1, int(w * scale)) + else: + new_h, new_w = h, w + + img_np = (img_t.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) + pil_img = PILImage.fromarray(img_np) + + if (new_h, new_w) != (h, w): + pil_img = pil_img.resize((new_w, new_h), PILImage.LANCZOS) + + out_path = os.path.join(prompt_dir, f"img_{next_idx}.jpg") + pil_img.save(out_path, "JPEG", quality=85) + saved += 1 + logger.debug( + f"[Session Feedback] image[{i}] β†’ prompt_{p+1}/img_{next_idx}.jpg" + ) + + logger.info( + f"[Session Feedback] Saved {saved} thumbnails for Run {run_number} " + f"in '{session_id}'." + ) + + +def _delete_session_images(session_id: str) -> int: + """Delete all thumbnail images for a session. Returns count removed.""" + img_dir = _session_images_dir(session_id) + if os.path.isdir(img_dir): + # Count files before deletion + count = sum(len(files) for _, _, files in os.walk(img_dir)) + shutil.rmtree(img_dir, ignore_errors=True) + logger.info( + f"[Session Memory] Deleted {count} thumbnail images " + f"for session '{session_id}'." + ) + return count + return 0 + + +# ── Feedback formatting ─────────────────────────────────────────────── + +def _format_feedback_block(feedback: dict) -> str: + """Format feedback into a SESSION_FEEDBACK block for the system prompt. + + Only includes actionable feedback: + β˜…1-2 -> AVOID directive (negative constraint) + β˜…4-5 -> REPLICATE directive (positive reference) + β˜…3 -> omitted (neutral) + β˜…0 -> omitted (unrated) + """ + if not feedback: + return "" + + lines = [] + for run_key in sorted(feedback.keys(), key=lambda x: int(x)): + run_data = feedback[run_key] + prompts_fb = run_data.get("prompts", {}) + run_lines = [] + + for p_key in sorted(prompts_fb.keys(), key=lambda x: int(x)): + p_data = prompts_fb[p_key] + rating = p_data.get("rating", 0) + notes = p_data.get("notes", "").strip() + + if rating == 0 or rating == 3: + continue + + stars = "\u2605" * rating + "\u2606" * (5 - rating) + directive = "AVOID" if rating <= 2 else "REPLICATE" + + line = f" PROMPT {p_key} ({stars} \u2014 {directive})" + if notes: + line += f': "{notes}"' + run_lines.append(line) + + if run_lines: + lines.append(f"RUN {run_key}:") + lines.extend(run_lines) + + if not lines: + return "" + + return "SESSION_FEEDBACK:\n" + "\n".join(lines) + "\n" + + +# ── API Routes ───────────────────────────────────────────────────────── + +@PromptServer.instance.routes.get("/session_feedback/load") +async def _api_load_feedback(request): + """Load run data + existing feedback for the frontend editor.""" + session_id = request.query.get("session_id", "default") + run_number = request.query.get("run_number", "") + + session = _load(session_id) + entries = session.get("entries", []) + run_count = session.get("run_count", 0) + feedback = session.get("feedback", {}) + + if run_count == 0: + return web.json_response({ + "run_count": 0, + "selected_run": 0, + "prompts": [], + "feedback": {}, + }) + + # Determine which run to show + if run_number and run_number.isdigit(): + sel_run = int(run_number) + else: + sel_run = run_count + + sel_run = max(1, min(sel_run, run_count)) + + idx = sel_run - 1 + entry_text = entries[idx] if idx < len(entries) else "" + prompts = _parse_prompts_from_entry(entry_text) + run_feedback = feedback.get(str(sel_run), {}).get("prompts", {}) + + return web.json_response({ + "run_count": run_count, + "selected_run": sel_run, + "prompts": prompts, + "feedback": run_feedback, + }) + + +@PromptServer.instance.routes.post("/session_feedback/save") +async def _api_save_feedback(request): + """Save feedback from the frontend editor to the session file.""" + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + session_id = body.get("session_id", "default") + run_number = str(body.get("run_number", "")) + prompts_fb = body.get("prompts", {}) + + if not run_number: + return web.json_response({"error": "run_number required"}, status=400) + + session = _load(session_id) + feedback = session.get("feedback", {}) + + if run_number not in feedback: + feedback[run_number] = {"prompts": {}} + + for p_key, p_data in prompts_fb.items(): + feedback[run_number]["prompts"][str(p_key)] = { + "rating": int(p_data.get("rating", 0)), + "notes": str(p_data.get("notes", "")), + } + + session["feedback"] = feedback + _save(session_id, session) + + logger.info( + f"[Session Feedback] Saved feedback for Run {run_number} " + f"in session '{session_id}' ({len(prompts_fb)} prompts)." + ) + return web.json_response({"status": "ok"}) + + +@PromptServer.instance.routes.post("/session_feedback/resume") +async def _api_resume_feedback(request): + """Resume blocked execution after user saves feedback.""" + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + node_id = str(body.get("node_id", "")) + event = _pending_feedback.get(node_id) + if event: + event.set() + logger.info(f"[Session Feedback] Resumed execution for node {node_id}.") + return web.json_response({"status": "resumed"}) + + return web.json_response({"status": "not_found"}) + + +@PromptServer.instance.routes.get("/session_feedback/thumbnails") +async def _api_list_thumbnails(request): + """List available thumbnail images for a specific run.""" + session_id = request.query.get("session_id", "default") + run_number = request.query.get("run_number", "1") + + safe_sid = _safe_id(session_id) + run_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{run_number}") + result = {} + + if os.path.isdir(run_dir): + for entry in sorted(os.listdir(run_dir)): + if not entry.startswith("prompt_"): + continue + prompt_path = os.path.join(run_dir, entry) + if not os.path.isdir(prompt_path): + continue + p_num = entry.replace("prompt_", "") + imgs = sorted( + f for f in os.listdir(prompt_path) + if f.lower().endswith((".jpg", ".jpeg", ".png")) + ) + result[p_num] = [ + f"/session_feedback/thumb?path=" + f"{safe_sid}/run_{run_number}/{entry}/{img}" + for img in imgs + ] + + return web.json_response(result) + + +@PromptServer.instance.routes.get("/session_feedback/thumb") +async def _api_serve_thumbnail(request): + """Serve a single thumbnail image file.""" + rel_path = request.query.get("path", "") + if not rel_path: + return web.Response(status=400, text="Missing path") + + full_path = os.path.realpath(os.path.join(SESSIONS_DIR, rel_path)) + sessions_real = os.path.realpath(SESSIONS_DIR) + + # Security: prevent directory traversal + if not full_path.startswith(sessions_real): + return web.Response(status=403, text="Forbidden") + + if not os.path.isfile(full_path): + return web.Response(status=404, text="Not found") + + return web.FileResponse(full_path) + + +# ── Node: Feedback Editor ───────────────────────────────────────────── +class SessionFeedbackEditorNode: + """ + ⭐ Session Feedback Editor + + Placed AFTER the Writer. Shows the latest Run for the director to + rate (1-5 stars) and annotate each prompt. Optionally receives a + batch of images to save as thumbnails for visual preview. + + Two modes: + - non_blocking: execution completes, user annotates at leisure + - blocking: execution pauses until user clicks Save + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "session_id": ("STRING", { + "default": "default", + "tooltip": "Session identifier. Must match Reader/Writer.", + }), + "mode": (["non_blocking", "blocking"], { + "default": "non_blocking", + "tooltip": "blocking = pauses workflow until you save feedback. " + "non_blocking = workflow finishes, annotate later.", + }), + "num_prompts": ("INT", { + "default": 4, + "min": 1, + "max": 20, + "step": 1, + "tooltip": "Number of prompts per run (e.g. 4).", + }), + "batch_count": ("INT", { + "default": 1, + "min": 1, + "max": 50, + "step": 1, + "tooltip": "Times each prompt was sampled. " + "Total images = num_prompts Γ— batch_count.", + }), + }, + "optional": { + "run_summary": ("STRING", { + "forceInput": True, + "tooltip": "Connect from Writer to ensure execution order.", + }), + "thumbnail_images": ("IMAGE", { + "tooltip": "Batch of images from sampler. " + "Total = num_prompts Γ— batch_count.", + }), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = () + OUTPUT_NODE = True + FUNCTION = "process" + CATEGORY = "\U0001f9e0 Memory" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("NaN") + + def process(self, session_id: str = "default", mode: str = "non_blocking", + num_prompts: int = 4, batch_count: int = 1, + run_summary: str = "", thumbnail_images=None, + unique_id=None): + session = _load(session_id) + run_count = session.get("run_count", 0) + + if run_count == 0: + logger.info("[Session Feedback Editor] No runs yet.") + return {"ui": {"status": ["no_runs"]}} + + # Save thumbnails if images provided + if thumbnail_images is not None: + _save_thumbnails( + session_id, run_count, num_prompts, batch_count, + thumbnail_images, + ) + + # Get latest run prompts + existing feedback + entries = session.get("entries", []) + latest_entry = entries[-1] if entries else "" + prompts = _parse_prompts_from_entry(latest_entry) + feedback = session.get("feedback", {}) + run_feedback = feedback.get(str(run_count), {}).get("prompts", {}) + + # Notify frontend via websocket + PromptServer.instance.send_sync("session_feedback_update", { + "node_id": str(unique_id), + "session_id": session_id, + "run_count": run_count, + "selected_run": run_count, + "prompts": prompts, + "feedback": run_feedback, + "mode": mode, + }) + + if mode == "blocking": + logger.info( + f"[Session Feedback Editor] Blocking β€” waiting for feedback " + f"on Run {run_count} (node {unique_id})..." + ) + event = threading.Event() + _pending_feedback[str(unique_id)] = event + try: + # Wait up to 10 minutes + timeout = 600 + start = time.time() + while not event.is_set(): + if time.time() - start > timeout: + logger.warning( + "[Session Feedback Editor] Timeout β€” " + "continuing without feedback." + ) + break + event.wait(timeout=1.0) + finally: + _pending_feedback.pop(str(unique_id), None) + + logger.info("[Session Feedback Editor] Resumed after feedback.") + else: + logger.info( + f"[Session Feedback Editor] Non-blocking β€” Run {run_count} " + f"ready for feedback in UI." + ) + + return {"ui": {"status": ["done"]}} + + +# ── Node: Reader ─────────────────────────────────────────────────────── +class SessionMemoryReaderNode: + """ + 🧠 Session Memory Reader + + Placed at the start of the workflow. Reads the accumulated session log + AND feedback from disk, outputs it for injection into the system prompt. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "session_id": ("STRING", { + "default": "default", + "tooltip": "Session identifier. Must match Writer/Feedback nodes.", + }), + }, + "optional": { + "reset_session": ("BOOLEAN", { + "default": False, + "tooltip": "Clear all memory, feedback, AND thumbnail images.", + }), + }, + } + + RETURN_TYPES = ("STRING", "INT", "STRING") + RETURN_NAMES = ("session_memory", "run_count", "session_id") + FUNCTION = "read" + CATEGORY = "\U0001f9e0 Memory" + + @classmethod + def IS_CHANGED(cls, **kwargs): + return float("NaN") + + def read(self, session_id: str = "default", reset_session: bool = False): + if reset_session: + _save(session_id, {"run_count": 0, "entries": [], "feedback": {}}) + deleted = _delete_session_images(session_id) + logger.info( + f"[Session Memory Reader] Session '{session_id}' reset " + f"({deleted} thumbnail images removed)." + ) + return ("", 0, session_id) + + session = _load(session_id) + entries = session.get("entries", []) + run_count = session.get("run_count", 0) + feedback = session.get("feedback", {}) + + session_memory = "\n".join(entries) if entries else "" + + # Append feedback block if any + feedback_block = _format_feedback_block(feedback) + if feedback_block: + session_memory = session_memory + "\n" + feedback_block + + logger.info( + f"[Session Memory Reader] Session '{session_id}' β€” " + f"{run_count} runs, {len(session_memory)} chars" + f"{', with feedback' if feedback_block else ''}." + ) + return (session_memory, run_count, session_id) + + +# ── Node: Writer ─────────────────────────────────────────────────────── +class SessionMemoryWriterNode: + """ + πŸ’Ύ Session Memory Writer + + Placed at the end of the workflow, after the Gemini Summarizer. + Appends the structured run summary to the session file on disk. + Outputs session_id so it can chain to the Feedback Editor. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "session_id": ("STRING", { + "default": "default", + "tooltip": "Session identifier. Must match Reader/Feedback.", + }), + "run_summary": ("STRING", { + "multiline": True, + "default": "", + "tooltip": "Structured summary from the Gemini Summarizer.", + }), + }, + "optional": { + "max_runs": ("INT", { + "default": 0, + "min": 0, + "max": 100, + "step": 1, + "tooltip": "Sliding window: keep only the last N entries. " + "0 = unlimited.", + }), + }, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("run_summary", "session_id") + OUTPUT_NODE = True + FUNCTION = "write" + CATEGORY = "\U0001f9e0 Memory" + + def write( + self, + session_id: str = "default", + run_summary: str = "", + max_runs: int = 0, + ): + stripped = run_summary.strip() + if not stripped: + logger.info( + f"[Session Memory Writer] Empty summary β€” nothing written " + f"to session '{session_id}'." + ) + return (run_summary, session_id) + + session = _load(session_id) + run_count = session.get("run_count", 0) + 1 + entries: list[str] = session.get("entries", []) + feedback = session.get("feedback", {}) + + entry = f"--- RUN {run_count} ---\n{stripped}\n" + entries.append(entry) + + # Sliding window + if max_runs > 0 and len(entries) > max_runs: + trimmed = len(entries) - max_runs + entries = entries[-max_runs:] + logger.info( + f"[Session Memory Writer] Trimmed {trimmed} oldest entries " + f"(window={max_runs})." + ) + + _save(session_id, { + "run_count": run_count, + "entries": entries, + "feedback": feedback, + }) + logger.info( + f"[Session Memory Writer] Written Run {run_count} to session " + f"'{session_id}' ({len(stripped)} chars, {len(entries)} entries)." + ) + return (run_summary, session_id) + + +# ── Registration ─────────────────────────────────────────────────────── +NODE_CLASS_MAPPINGS = { + "SessionFeedbackEditor": SessionFeedbackEditorNode, + "SessionMemoryReader": SessionMemoryReaderNode, + "SessionMemoryWriter": SessionMemoryWriterNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SessionFeedbackEditor": "\u2b50 Session Feedback Editor", + "SessionMemoryReader": "\U0001f9e0 Session Memory Reader", + "SessionMemoryWriter": "\U0001f4be Session Memory Writer", +} diff --git a/test_node.py b/test_node.py new file mode 100644 index 0000000..2af25cb --- /dev/null +++ b/test_node.py @@ -0,0 +1,118 @@ +"""Smoke test for Session Memory Reader + Writer nodes.""" +import sys, os, shutil +sys.path.insert(0, ".") +from session_memory import ( + SessionMemoryReaderNode as Reader, + SessionMemoryWriterNode as Writer, + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, + SESSIONS_DIR, +) + +TEST_SESSION = "test_smoke" +TEST_FILE = os.path.join(SESSIONS_DIR, f"{TEST_SESSION}.json") + + +def cleanup(): + if os.path.exists(TEST_FILE): + os.remove(TEST_FILE) + + +def test(): + cleanup() + + reader = Reader() + writer = Writer() + + # ── Test 1: First read on empty session ─────────────────────────── + mem, count, sid = reader.read(TEST_SESSION) + assert mem == "", f"Empty session should return empty string, got: {repr(mem)}" + assert count == 0, f"Empty session run_count should be 0, got: {count}" + assert sid == TEST_SESSION + print("PASS: Empty session read") + + # ── Test 2: Write Run 1, then read ──────────────────────────────── + writer.write(TEST_SESSION, "concept: Alpha\nlocation: Vespa corner") + mem, count, _ = reader.read(TEST_SESSION) + assert "Alpha" in mem, "After writing Run 1, Reader should output it" + assert count == 1 + print("PASS: Write Run 1, read back correctly") + + # ── Test 3: Write Run 2 β€” Run 1 still visible ───────────────────── + writer.write(TEST_SESSION, "concept: Beta\nlocation: Metro platform") + mem, count, _ = reader.read(TEST_SESSION) + assert "Alpha" in mem and "Beta" in mem + assert count == 2 + assert "--- RUN 1 ---" in mem and "--- RUN 2 ---" in mem + print("PASS: Both runs accumulate correctly") + + # ── Test 4: Empty summary is ignored ────────────────────────────── + writer.write(TEST_SESSION, " ") + _, count2, _ = reader.read(TEST_SESSION) + assert count2 == 2, "Empty summary should not increment run_count" + print("PASS: Empty summary ignored") + + # ── Test 5: Sliding window ──────────────────────────────────────── + cleanup() + writer.write(TEST_SESSION, "Entry A") + writer.write(TEST_SESSION, "Entry B") + writer.write(TEST_SESSION, "Entry C") + writer.write(TEST_SESSION, "Entry D", max_runs=2) + mem, count, _ = reader.read(TEST_SESSION) + assert "Entry A" not in mem, "Entry A should be trimmed" + assert "Entry B" not in mem, "Entry B should be trimmed" + assert "Entry C" in mem and "Entry D" in mem + assert count == 4 # run_count is cumulative, not windowed + print("PASS: Sliding window (max_runs=2)") + + # ── Test 6: Reset ───────────────────────────────────────────────── + mem, count, _ = reader.read(TEST_SESSION, reset_session=True) + assert mem == "" and count == 0 + mem2, count2, _ = reader.read(TEST_SESSION) + assert mem2 == "" and count2 == 0 + print("PASS: Reset clears session") + + # ── Test 7: session_id passthrough from Writer ───────────────────── + result = writer.write(TEST_SESSION, "Passthrough check") + assert result == ("Passthrough check",), f"Writer should passthrough summary: {result}" + print("PASS: Writer passthrough") + + # ── Test 8: Node metadata ────────────────────────────────────────── + assert "SessionMemoryReader" in NODE_CLASS_MAPPINGS + assert "SessionMemoryWriter" in NODE_CLASS_MAPPINGS + assert Reader.CATEGORY == "🧠 Memory" + assert Writer.CATEGORY == "🧠 Memory" + assert Writer.OUTPUT_NODE is True + import math + assert math.isnan(Reader.IS_CHANGED()) + print("PASS: Node metadata correct") + + # ── Test 9: session_id sanitization ─────────────────────────────── + from session_memory import _safe_id + cleanup() + bad_id = "../../../evil; rm -rf" + writer.write(bad_id, "Sanitization test") + safe_name = _safe_id(bad_id) + safe_path = os.path.join(SESSIONS_DIR, f"{safe_name}.json") + assert os.path.exists(safe_path), f"Sanitized file not found: {safe_path}" + # Confirm the path is strictly inside SESSIONS_DIR (no path traversal) + assert os.path.abspath(safe_path).startswith(os.path.abspath(SESSIONS_DIR)), \ + "Path traversal not prevented!" + os.remove(safe_path) + print("PASS: session_id sanitization") + + # ── Test 10: Corrupt file recovery ──────────────────────────────── + cleanup() + os.makedirs(SESSIONS_DIR, exist_ok=True) + with open(TEST_FILE, "w") as f: + f.write("NOT VALID JSON {{{{") + mem, count, _ = reader.read(TEST_SESSION) + assert mem == "" and count == 0, "Corrupt file should gracefully return empty" + print("PASS: Corrupt file graceful recovery") + + print("\nβœ… ALL 10 TESTS PASSED") + cleanup() + + +if __name__ == "__main__": + test() diff --git a/web/session_feedback_editor.js b/web/session_feedback_editor.js new file mode 100644 index 0000000..e021f19 --- /dev/null +++ b/web/session_feedback_editor.js @@ -0,0 +1,676 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +/** + * ⭐ Session Feedback Editor β€” Frontend Extension + * + * Features: + * - Star ratings (1-5) per prompt + * - Director notes per prompt + * - Thumbnail previews per prompt (multiple passes supported) + * - Reset confirmation popup on Reader node + * + * Data flow: + * 1. On node creation β†’ GET /session_feedback/load (initial state) + * 2. On execution β†’ websocket "session_feedback_update" (latest run) + * 3. On Save click β†’ POST /session_feedback/save + optional /resume + * 4. On run change β†’ GET /session_feedback/thumbnails (load images) + */ + +// ── Visual constants ────────────────────────────────────────────────── +const STAR_FILLED = "β˜…"; +const STAR_EMPTY = "β˜†"; +const STAR_COUNT = 5; +const GOLD = "#FFD700"; +const GREY = "#555"; +const CARD_BG = "#16213e"; +const CARD_BORDER = "#0f3460"; +const TEXT_COLOR = "#e0e0e0"; +const LABEL_COLOR = "#94a3b8"; +const CONCEPT_CLR = "#7dd3fc"; +const NOTES_BG = "#1e293b"; +const NOTES_BORDER = "#334155"; +const BTN_SAVE_BG = "#2563eb"; +const BTN_SAVE_HV = "#3b82f6"; +const BTN_WAIT_BG = "#d97706"; +const BTN_WAIT_HV = "#f59e0b"; +const BTN_DONE_BG = "#16a34a"; +const AVOID_CLR = "#f87171"; +const REPLICATE_CLR = "#4ade80"; +const NODE_MIN_W = 380; + +// Thumbnail constants +const THUMB_SIZE = 70; // square thumbnail dimension +const THUMB_GAP = 5; // gap between thumbnails +const THUMB_RADIUS = 5; // border radius + +const RATING_LABELS = { + 0: "", + 1: "Poor β€” AVOID", + 2: "Weak β€” AVOID", + 3: "Neutral", + 4: "Good β€” REPLICATE", + 5: "Excellent β€” REPLICATE", +}; + +// ── Helper: draw center-cropped image ───────────────────────────────── +function drawCoverImage(ctx, img, x, y, w, h, radius) { + ctx.save(); + ctx.beginPath(); + ctx.roundRect(x, y, w, h, radius); + ctx.clip(); + + if (img.complete && img.naturalWidth > 0) { + const imgW = img.naturalWidth; + const imgH = img.naturalHeight; + const imgRatio = imgW / imgH; + const boxRatio = w / h; + let sx, sy, sw, sh; + if (imgRatio > boxRatio) { + sh = imgH; + sw = imgH * boxRatio; + sx = (imgW - sw) / 2; + sy = 0; + } else { + sw = imgW; + sh = imgW / boxRatio; + sx = 0; + sy = (imgH - sh) / 2; + } + ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); + } else { + // Loading placeholder + ctx.fillStyle = "#0d1117"; + ctx.fillRect(x, y, w, h); + // Spinner dots + const dotCount = 3; + const phase = (Date.now() / 400) % dotCount; + for (let d = 0; d < dotCount; d++) { + ctx.fillStyle = d === Math.floor(phase) ? "#475569" : "#1e293b"; + ctx.beginPath(); + ctx.arc(x + w/2 - 10 + d * 10, y + h/2, 2.5, 0, Math.PI * 2); + ctx.fill(); + } + } + + ctx.restore(); + + // Border + ctx.strokeStyle = "#334155"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, radius); + ctx.stroke(); +} + +// ── Extension ───────────────────────────────────────────────────────── +app.registerExtension({ + name: "session.FeedbackEditor", + + async setup() { + // Listen for websocket messages from the backend (during execution) + api.addEventListener("session_feedback_update", (event) => { + const data = event.detail; + const nodeId = data.node_id; + const nodes = app.graph._nodes; + for (const node of nodes) { + if (String(node.id) === String(nodeId) && node._fb_onUpdate) { + node._fb_onUpdate(data); + break; + } + } + }); + }, + + async beforeRegisterNodeDef(nodeType, nodeData, _app) { + // ── Session Feedback Editor ───────────────────────────────────── + if (nodeData.name === "SessionFeedbackEditor") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + + nodeType.prototype.onNodeCreated = function () { + if (onNodeCreated) onNodeCreated.apply(this, arguments); + + // ── Internal state ── + this._fb = { + sessionId: "default", + runCount: 0, + selectedRun: 0, + prompts: [], // [{number, concept}] + ratings: {}, // {promptNum: 0-5} + notes: {}, // {promptNum: string} + waiting: false, + saveFlash: 0, + hitAreas: null, + hoverBtn: false, + thumbnails: {}, // {promptNum: [Image(), ...]} + thumbsLoading: 0, + }; + + // Force minimum width + if (this.size[0] < NODE_MIN_W) this.size[0] = NODE_MIN_W; + + // ── Custom feedback panel widget ── + const node = this; + this.addCustomWidget({ + name: "feedback_panel", + type: "custom", + value: "", + computeSize: () => { + const n = node._fb.prompts.length; + if (n === 0) return [NODE_MIN_W, 80]; + + let totalH = 48; // run nav row + for (let i = 0; i < n; i++) { + const pNum = String(node._fb.prompts[i].number); + const thumbs = (node._fb.thumbnails && node._fb.thumbnails[pNum]) || []; + const cardW = NODE_MIN_W - 28; + const availW = cardW - 20; + const perRow = Math.max(1, Math.floor(availW / (THUMB_SIZE + THUMB_GAP))); + const thumbRows = thumbs.length > 0 ? Math.ceil(thumbs.length / perRow) : 0; + const thumbH = thumbRows > 0 ? thumbRows * (THUMB_SIZE + THUMB_GAP) + 6 : 0; + totalH += 90 + thumbH + 8; // card base + thumbs + gap + } + totalH += 56; // save button + padding + return [NODE_MIN_W, totalH]; + }, + draw: (ctx, _node, width, posY, height) => { + node._fb_drawPanel(ctx, width, posY, height); + }, + mouse: (event, pos, _node) => { + return node._fb_handleMouse(event, pos); + }, + serialize: false, + }); + + // Initial load after widgets settle + setTimeout(() => this._fb_loadRun(), 400); + }; + + // ── React to session_id widget changes ── + const onWidgetChanged = nodeType.prototype.onWidgetChanged; + nodeType.prototype.onWidgetChanged = function (widget) { + if (onWidgetChanged) onWidgetChanged.apply(this, arguments); + if (widget.name === "session_id") { + this._fb.sessionId = widget.value; + this._fb_loadRun(); + } + }; + + // ── Websocket update handler ── + nodeType.prototype._fb_onUpdate = function (data) { + this._fb.sessionId = data.session_id; + this._fb.runCount = data.run_count; + this._fb.selectedRun = data.selected_run; + this._fb.prompts = data.prompts || []; + this._fb.waiting = data.mode === "blocking"; + + this._fb.ratings = {}; + this._fb.notes = {}; + const fb = data.feedback || {}; + for (const [k, v] of Object.entries(fb)) { + this._fb.ratings[k] = v.rating || 0; + this._fb.notes[k] = v.notes || ""; + } + + // Load thumbnails for the new run + this._fb_loadThumbnails(); + + this.setDirtyCanvas(true); + app.graph.setDirtyCanvas(true, true); + }; + + // ── Load run data from REST API ── + nodeType.prototype._fb_loadRun = function (runNumber) { + const w = this.widgets?.find(w => w.name === "session_id"); + const sid = w?.value || "default"; + this._fb.sessionId = sid; + + const rp = runNumber ? `&run_number=${runNumber}` : ""; + fetch(`/session_feedback/load?session_id=${encodeURIComponent(sid)}${rp}`) + .then(r => r.json()) + .then(data => { + this._fb.runCount = data.run_count || 0; + this._fb.selectedRun = data.selected_run || 0; + this._fb.prompts = data.prompts || []; + + this._fb.ratings = {}; + this._fb.notes = {}; + const fb = data.feedback || {}; + for (const [k, v] of Object.entries(fb)) { + this._fb.ratings[k] = v.rating || 0; + this._fb.notes[k] = v.notes || ""; + } + + // Load thumbnails for this run + this._fb_loadThumbnails(); + + this.setDirtyCanvas(true); + app.graph.setDirtyCanvas(true, true); + }) + .catch(err => console.error("[Feedback] Load error:", err)); + }; + + // ── Load thumbnail images ── + nodeType.prototype._fb_loadThumbnails = function () { + const sid = this._fb.sessionId; + const run = this._fb.selectedRun; + if (!run || run === 0) { + this._fb.thumbnails = {}; + return; + } + + fetch(`/session_feedback/thumbnails?session_id=${encodeURIComponent(sid)}&run_number=${run}`) + .then(r => r.json()) + .then(data => { + // data = { "1": ["/session_feedback/thumb?path=..."], ... } + this._fb.thumbnails = {}; + this._fb.thumbsLoading = 0; + + for (const [pNum, urls] of Object.entries(data)) { + this._fb.thumbnails[pNum] = urls.map(url => { + const img = new Image(); + this._fb.thumbsLoading++; + img.onload = () => { + this._fb.thumbsLoading--; + this.setDirtyCanvas(true); + if (this._fb.thumbsLoading <= 0) { + // All loaded β€” recalculate node size + requestAnimationFrame(() => { + this.setSize(this.computeSize()); + app.graph.setDirtyCanvas(true, true); + }); + } + }; + img.onerror = () => { + this._fb.thumbsLoading--; + }; + img.src = url; + return img; + }); + } + + // Recalculate size for known thumb count + this.setSize(this.computeSize()); + this.setDirtyCanvas(true); + app.graph.setDirtyCanvas(true, true); + }) + .catch(err => console.error("[Feedback] Thumbnail load error:", err)); + }; + + // ── Save feedback via REST API ── + nodeType.prototype._fb_saveFeedback = function () { + if (this._fb.selectedRun === 0) return; + + const prompts = {}; + for (const p of this._fb.prompts) { + const k = String(p.number); + prompts[k] = { + rating: this._fb.ratings[k] || 0, + notes: this._fb.notes[k] || "", + }; + } + + fetch("/session_feedback/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: this._fb.sessionId, + run_number: this._fb.selectedRun, + prompts, + }), + }) + .then(() => { + this._fb.saveFlash = Date.now(); + this.setDirtyCanvas(true); + + if (this._fb.waiting) { + fetch("/session_feedback/resume", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(this.id) }), + }) + .then(() => { + this._fb.waiting = false; + this.setDirtyCanvas(true); + }) + .catch(err => console.error("[Feedback] Resume error:", err)); + } + }) + .catch(err => console.error("[Feedback] Save error:", err)); + }; + + // ── Draw the entire feedback panel ── + nodeType.prototype._fb_drawPanel = function (ctx, width, posY, height) { + const pad = 14; + const inW = width - pad * 2; + let curY = posY + 6; + const fb = this._fb; + + ctx.save(); + + // ── Empty state ── + if (fb.runCount === 0) { + ctx.fillStyle = GREY; + ctx.font = "italic 12px Inter, Arial, sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText("No runs in this session.", pad, curY + 30); + ctx.restore(); + return; + } + + // ── Blocking status bar ── + if (fb.waiting) { + const barH = 26; + ctx.fillStyle = "#451a03"; + ctx.beginPath(); + ctx.roundRect(pad - 4, curY, inW + 8, barH, 5); + ctx.fill(); + const pulse = 0.6 + 0.4 * Math.sin(Date.now() / 300); + ctx.globalAlpha = pulse; + ctx.fillStyle = BTN_WAIT_BG; + ctx.font = "bold 11px Inter, Arial, sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText("⏸ Workflow paused β€” add feedback and click Save", pad + 6, curY + 13); + ctx.globalAlpha = 1.0; + curY += barH + 4; + requestAnimationFrame(() => this.setDirtyCanvas(true)); + } + + // ── Run selector row ── + fb.hitAreas = { leftArrow: null, rightArrow: null, stars: [], notes: [], saveBtn: null }; + + ctx.fillStyle = LABEL_COLOR; + ctx.font = "bold 12px Inter, Arial, sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText("Run:", pad, curY + 14); + + const aY = curY + 4; + const aH = 22; + const leftX = pad + 35; + const labelX = leftX + 26; + const rightX = labelX + 80; + + // Left arrow + ctx.fillStyle = fb.selectedRun > 1 ? TEXT_COLOR : "#333"; + ctx.font = "bold 16px Inter, Arial, sans-serif"; + ctx.fillText("β—€", leftX + 2, curY + 14); + fb.hitAreas.leftArrow = { x: leftX, y: aY, w: 24, h: aH }; + + // Run label + ctx.fillStyle = "#fff"; + ctx.font = "bold 13px Inter, Arial, sans-serif"; + ctx.fillText(`Run ${fb.selectedRun} / ${fb.runCount}`, labelX + 2, curY + 14); + + // Right arrow + ctx.fillStyle = fb.selectedRun < fb.runCount ? TEXT_COLOR : "#333"; + ctx.font = "bold 16px Inter, Arial, sans-serif"; + ctx.fillText("β–Ά", rightX + 2, curY + 14); + fb.hitAreas.rightArrow = { x: rightX, y: aY, w: 24, h: aH }; + + curY += 38; + + // ── Prompt cards ── + for (let i = 0; i < fb.prompts.length; i++) { + const prompt = fb.prompts[i]; + const pNum = String(prompt.number); + const rating = fb.ratings[pNum] || 0; + const notes = fb.notes[pNum] || ""; + const thumbs = (fb.thumbnails && fb.thumbnails[pNum]) || []; + + const cX = pad - 4; + const cW = inW + 8; + + // Compute thumbnail area height + const availW = cW - 20; + const perRow = Math.max(1, Math.floor(availW / (THUMB_SIZE + THUMB_GAP))); + const thumbRows = thumbs.length > 0 ? Math.ceil(thumbs.length / perRow) : 0; + const thumbAreaH = thumbRows > 0 ? thumbRows * (THUMB_SIZE + THUMB_GAP) + 6 : 0; + + const cH = 90 + thumbAreaH; + + // Card bg + ctx.fillStyle = CARD_BG; + ctx.strokeStyle = CARD_BORDER; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(cX, curY, cW, cH, 6); + ctx.fill(); + ctx.stroke(); + + // Prompt label + ctx.fillStyle = CONCEPT_CLR; + ctx.font = "bold 11px Inter, Arial, sans-serif"; + let label = `\ud83d\udccb Prompt ${pNum}: ${prompt.concept}`; + const maxW = cW - 16; + while (ctx.measureText(label).width > maxW && label.length > 20) { + label = label.slice(0, -4) + "\u2026"; + } + ctx.fillText(label, cX + 10, curY + 16); + + // ── Thumbnails ── + if (thumbs.length > 0) { + const tStartY = curY + 26; + let tX = cX + 10; + let tY = tStartY; + let col = 0; + + for (let t = 0; t < thumbs.length; t++) { + drawCoverImage(ctx, thumbs[t], tX, tY, THUMB_SIZE, THUMB_SIZE, THUMB_RADIUS); + col++; + if (col >= perRow) { + col = 0; + tX = cX + 10; + tY += THUMB_SIZE + THUMB_GAP; + } else { + tX += THUMB_SIZE + THUMB_GAP; + } + } + + // Request redraws while images are loading (for spinners) + if (fb.thumbsLoading > 0) { + requestAnimationFrame(() => this.setDirtyCanvas(true)); + } + } + + // Stars (offset by thumbnail area) + const sY = curY + 28 + thumbAreaH; + const sSize = 18; + const sSpc = 23; + const sX = cX + 10; + + fb.hitAreas.stars[i] = []; + for (let s = 0; s < STAR_COUNT; s++) { + const filled = s < rating; + ctx.fillStyle = filled ? GOLD : GREY; + ctx.font = `${sSize}px Inter, Arial, sans-serif`; + ctx.fillText(filled ? STAR_FILLED : STAR_EMPTY, sX + s * sSpc, sY + 14); + fb.hitAreas.stars[i][s] = { + x: sX + s * sSpc - 2, y: sY, w: sSpc, h: 22, + promptNum: pNum, starIdx: s, + }; + } + + // Rating label + if (rating > 0) { + ctx.fillStyle = rating <= 2 ? AVOID_CLR : rating >= 4 ? REPLICATE_CLR : LABEL_COLOR; + ctx.font = "10px Inter, Arial, sans-serif"; + ctx.fillText(RATING_LABELS[rating], sX + STAR_COUNT * sSpc + 6, sY + 12); + } + + // Notes area + const nY = sY + 26; + const nH = 24; + const nW = cW - 20; + + ctx.fillStyle = NOTES_BG; + ctx.strokeStyle = NOTES_BORDER; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.roundRect(cX + 10, nY, nW, nH, 4); + ctx.fill(); + ctx.stroke(); + + ctx.font = "11px Inter, Arial, sans-serif"; + if (notes) { + ctx.fillStyle = TEXT_COLOR; + let display = notes; + while (ctx.measureText(display).width > nW - 16 && display.length > 5) { + display = display.slice(0, -4) + "\u2026"; + } + ctx.fillText(display, cX + 16, nY + 15); + } else { + ctx.fillStyle = "#475569"; + ctx.fillText("Click to add notes\u2026", cX + 16, nY + 15); + } + + fb.hitAreas.notes[i] = { x: cX + 10, y: nY, w: nW, h: nH, promptNum: pNum }; + curY += cH + 8; + } + + // ── Save button ── + const btnW = inW + 8; + const btnH = 34; + const btnX = pad - 4; + const btnY = curY + 2; + + const flashActive = (Date.now() - fb.saveFlash) < 1500; + let btnBg, btnText; + + if (flashActive) { + btnBg = BTN_DONE_BG; + btnText = "\u2705 Saved"; + } else if (fb.waiting) { + btnBg = fb.hoverBtn ? BTN_WAIT_HV : BTN_WAIT_BG; + btnText = "\ud83d\udcbe Save & Continue \u25b6"; + } else { + btnBg = fb.hoverBtn ? BTN_SAVE_HV : BTN_SAVE_BG; + btnText = "\ud83d\udcbe Save Feedback"; + } + + ctx.shadowColor = "rgba(0,0,0,0.3)"; + ctx.shadowBlur = 6; + ctx.shadowOffsetY = 2; + + ctx.fillStyle = btnBg; + ctx.beginPath(); + ctx.roundRect(btnX, btnY, btnW, btnH, 8); + ctx.fill(); + + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetY = 0; + + ctx.fillStyle = "#fff"; + ctx.font = "bold 13px Inter, Arial, sans-serif"; + ctx.textBaseline = "middle"; + const tw = ctx.measureText(btnText).width; + ctx.fillText(btnText, btnX + (btnW - tw) / 2, btnY + btnH / 2); + + fb.hitAreas.saveBtn = { x: btnX, y: btnY, w: btnW, h: btnH }; + + if (flashActive) { + requestAnimationFrame(() => this.setDirtyCanvas(true)); + } + + ctx.restore(); + }; + + // ── Handle mouse events ── + nodeType.prototype._fb_handleMouse = function (event, pos) { + const fb = this._fb; + if (!fb.hitAreas) return false; + + const [mx, my] = pos; + const hit = (r) => r && mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h; + + // Hover tracking for save button + if (event.type === "pointermove" || event.type === "mousemove") { + const wasHover = fb.hoverBtn; + fb.hoverBtn = hit(fb.hitAreas.saveBtn); + if (wasHover !== fb.hoverBtn) this.setDirtyCanvas(true); + return false; + } + + // Only process clicks + const isClick = event.type === "pointerdown" || + event.type === (LiteGraph.pointerevents_method + "down"); + if (!isClick) return false; + + // ── Arrow clicks ── + if (hit(fb.hitAreas.leftArrow) && fb.selectedRun > 1) { + this._fb_loadRun(fb.selectedRun - 1); + return true; + } + if (hit(fb.hitAreas.rightArrow) && fb.selectedRun < fb.runCount) { + this._fb_loadRun(fb.selectedRun + 1); + return true; + } + + // ── Star clicks ── + for (const promptStars of fb.hitAreas.stars) { + if (!promptStars) continue; + for (const s of promptStars) { + if (!s || !hit(s)) continue; + const newR = s.starIdx + 1; + fb.ratings[s.promptNum] = fb.ratings[s.promptNum] === newR ? 0 : newR; + this.setDirtyCanvas(true); + return true; + } + } + + // ── Notes clicks ── + for (const n of fb.hitAreas.notes) { + if (!n || !hit(n)) continue; + const pNum = n.promptNum; + const current = fb.notes[pNum] || ""; + const info = fb.prompts.find(p => String(p.number) === pNum); + const concept = info?.concept || `Prompt ${pNum}`; + + const result = prompt( + `\ud83d\udcdd Notes for Prompt ${pNum}: ${concept}`, + current + ); + if (result !== null) { + fb.notes[pNum] = result; + this.setDirtyCanvas(true); + } + return true; + } + + // ── Save button click ── + if (hit(fb.hitAreas.saveBtn)) { + this._fb_saveFeedback(); + return true; + } + + return false; + }; + } + + // ── Session Memory Reader β€” reset confirmation popup ────────────── + if (nodeData.name === "SessionMemoryReader") { + const origOnWidgetChanged = nodeType.prototype.onWidgetChanged; + + nodeType.prototype.onWidgetChanged = function (widget) { + // Intercept reset_session toggle + if (widget?.name === "reset_session" && widget.value === true) { + const confirmed = window.confirm( + "⚠️ Reset session?\n\n" + + "ALL data will be permanently deleted:\n" + + "β€’ Run history\n" + + "β€’ Feedback & ratings\n" + + "β€’ All thumbnail images\n\n" + + "This action cannot be undone." + ); + if (!confirmed) { + widget.value = false; + this.setDirtyCanvas(true); + return; + } + } + + if (origOnWidgetChanged) origOnWidgetChanged.apply(this, arguments); + }; + } + }, +});