diff --git a/session_memory.py b/session_memory.py index 3a9c413..58cf5c4 100644 --- a/session_memory.py +++ b/session_memory.py @@ -91,6 +91,20 @@ def _parse_prompts_from_entry(entry_text: str) -> list[dict]: "concept": concept, "fullText": full_text, }) + + # Fallback: if no PROMPT X markers, treat the whole entry as Prompt 1 + if not prompts and entry_text.strip(): + full_text = entry_text.strip() + trail = _SUMMARY_MARKERS.search(full_text) + if trail: + full_text = full_text[:trail.start()] + + prompts.append({ + "number": 1, + "concept": "Result", + "fullText": full_text.strip() + }) + return prompts @@ -160,6 +174,9 @@ def _save_thumbnails(session_id, run_number, num_prompts, batch_count, img_np = (img_t.cpu().numpy() * 255).clip(0, 255).astype(np.uint8) pil_img = PILImage.fromarray(img_np) + if pil_img.mode != "RGB": + pil_img = pil_img.convert("RGB") + if (new_h, new_w) != (h, w): pil_img = pil_img.resize((new_w, new_h), PILImage.LANCZOS) @@ -309,6 +326,24 @@ async def _api_save_feedback(request): session["feedback"] = feedback _save(session_id, session) + deleted_thumbs = body.get("deleted_thumbs", []) + if deleted_thumbs: + del_count = 0 + for thumb_rel in deleted_thumbs: + # Reconstruct absolute paths safely + safe_rel = thumb_rel.lstrip("/\\") + if ".." in safe_rel: + continue + abs_path = os.path.join(SESSIONS_DIR, safe_rel) + if os.path.isfile(abs_path): + try: + os.remove(abs_path) + del_count += 1 + except OSError as e: + logger.warning(f"[Session Feedback] Could not delete thumb {thumb_rel}: {e}") + if del_count > 0: + logger.info(f"[Session Feedback] Permanently deleted {del_count} selected thumbnails.") + logger.info( f"[Session Feedback] Saved feedback for Run {run_number} " f"in session '{session_id}' ({len(prompts_fb)} prompts)." @@ -334,6 +369,146 @@ async def _api_resume_feedback(request): return web.json_response({"status": "not_found"}) +@PromptServer.instance.routes.post("/session_feedback/delete_run") +async def _api_delete_run(request): + """Delete a specific run and its thumbnails.""" + 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", "")) + + if not run_number: + return web.json_response({"error": "run_number required"}, status=400) + + session = _load(session_id) + + del_run_int = int(run_number) + entries = session.get("entries", []) + run_count = session.get("run_count", len(entries)) + idx = del_run_int - 1 + + # 1. Update entries + if 0 <= idx < len(entries): + entries.pop(idx) + session["run_count"] = max(0, run_count - 1) + session["entries"] = entries + + # 2. Update feedback keys + feedback = session.get("feedback", {}) + new_feedback = {} + for k_str, v in feedback.items(): + try: + k_int = int(k_str) + if k_int < del_run_int: + new_feedback[str(k_int)] = v + elif k_int > del_run_int: + new_feedback[str(k_int - 1)] = v + except ValueError: + pass + session["feedback"] = new_feedback + _save(session_id, session) + + # 3. Delete the target thumbnail folder + safe_sid = _safe_id(session_id) + del_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{del_run_int}") + if os.path.isdir(del_dir): + shutil.rmtree(del_dir, ignore_errors=True) + + # 4. Shift subsequent thumbnail folders down by 1 + for k in range(del_run_int + 1, run_count + 1): + old_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{k}") + new_dir = os.path.join(SESSIONS_DIR, safe_sid, f"run_{k-1}") + if os.path.isdir(old_dir): + try: + os.rename(old_dir, new_dir) + except OSError as e: + logger.error(f"[Session Feedback] Rename failed: {e}") + + logger.info(f"[Session Feedback] Deleted Run {del_run_int} in session '{session_id}' and shifted remaining runs.") + return web.json_response({"status": "deleted"}) + + +@PromptServer.instance.routes.post("/session_feedback/delete_session") +async def _api_delete_session(request): + """Completely delete a session JSON and all its image assets.""" + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + session_id = body.get("session_id", "default") + if not session_id: + return web.json_response({"error": "Session ID required"}, status=400) + + safe_sid = _safe_id(session_id) + + # 1. Delete the JSON file + json_path = os.path.join(SESSIONS_DIR, f"{safe_sid}.json") + if os.path.isfile(json_path): + os.remove(json_path) + + # 2. Delete the entire thumbnails folder + img_dir = os.path.join(SESSIONS_DIR, safe_sid) + if os.path.isdir(img_dir): + shutil.rmtree(img_dir, ignore_errors=True) + + logger.warning(f"[Session Feedback] NUKED session '{session_id}' entirely.") + return web.json_response({"status": "deleted"}) + + +@PromptServer.instance.routes.post("/session_feedback/rename_session") +async def _api_rename_session(request): + """Rename a session JSON and its thumbnail folder.""" + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + old_id = body.get("old_session_id", "") + new_id = body.get("new_session_id", "") + + if not old_id or not new_id: + return web.json_response({"error": "old_session_id and new_session_id required"}, status=400) + + safe_old = _safe_id(old_id) + safe_new = _safe_id(new_id) + + if safe_old == safe_new: + return web.json_response({"status": "renamed"}) # Nothing to do + + old_json = os.path.join(SESSIONS_DIR, f"{safe_old}.json") + new_json = os.path.join(SESSIONS_DIR, f"{safe_new}.json") + + if os.path.exists(new_json): + return web.json_response({"error": f"Session '{new_id}' already exists."}, status=400) + + # Rename JSON + if os.path.isfile(old_json): + try: + os.rename(old_json, new_json) + except OSError as e: + return web.json_response({"error": f"Could not rename file: {e}"}, status=500) + else: + # Create an empty session if it didn't exist before renaming + _save(new_id, {"run_count": 0, "entries": [], "feedback": {}}) + + # Rename images directory + old_dir = os.path.join(SESSIONS_DIR, safe_old) + new_dir = os.path.join(SESSIONS_DIR, safe_new) + + if os.path.isdir(old_dir): + try: + os.rename(old_dir, new_dir) + except OSError as e: + pass # Non-critical if it fails or doesn't exist + + logger.info(f"[Session Feedback] Renamed session from '{old_id}' to '{new_id}'.") + return web.json_response({"status": "renamed"}) + + @PromptServer.instance.routes.get("/session_feedback/thumbnails") async def _api_list_thumbnails(request): """List available thumbnail images for a specific run.""" @@ -531,17 +706,16 @@ class SessionMemoryReaderNode: @classmethod def INPUT_TYPES(cls): + sessions = _get_available_sessions() return { "required": { - "session_id": ("STRING", { - "default": "default", - "tooltip": "Session identifier. Must match Writer/Feedback nodes.", + "existing_session": (["[CREATE NEW]"] + sessions, { + "default": sessions[-1] if sessions else "[CREATE NEW]", + "tooltip": "Select an existing session, or [CREATE NEW] to use the text box below." }), - }, - "optional": { - "reset_session": ("BOOLEAN", { - "default": False, - "tooltip": "Clear all memory, feedback, AND thumbnail images.", + "new_session_name": ("STRING", { + "default": "new_session", + "tooltip": "If [CREATE NEW] is selected above, this will be your new session_id." }), }, } @@ -555,15 +729,14 @@ class SessionMemoryReaderNode: 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) + def read(self, existing_session="[CREATE NEW]", new_session_name="new_session"): + if existing_session == "[CREATE NEW]": + session_id = new_session_name.strip() + else: + session_id = existing_session + + if not session_id: + session_id = "unnamed_session" session = _load(session_id) entries = session.get("entries", []) @@ -670,14 +843,58 @@ class SessionMemoryWriterNode: return (run_summary, session_id) +def _get_available_sessions(): + os.makedirs(SESSIONS_DIR, exist_ok=True) + sessions = [] + for f in os.listdir(SESSIONS_DIR): + if f.endswith(".json"): + sessions.append(f[:-5]) + return sorted(sessions) if sessions else ["default"] + + +class SessionIDSelectorNode: + """ + Select an existing session id from a list, or type a new one to start fresh. + """ + @classmethod + def INPUT_TYPES(cls): + sessions = _get_available_sessions() + return { + "required": { + "existing_session": (["[CREATE NEW]"] + sessions, { + "default": sessions[-1] if sessions else "[CREATE NEW]", + "tooltip": "Select an existing session, or [CREATE NEW] to use the text box below." + }), + "new_session_name": ("STRING", { + "default": "new_session", + "tooltip": "If [CREATE NEW] is selected above, this will be your new session_id." + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("session_id",) + FUNCTION = "select_id" + CATEGORY = "🧠 Memory" + + def select_id(self, existing_session, new_session_name): + if existing_session == "[CREATE NEW]": + final_id = new_session_name.strip() + else: + final_id = existing_session + return (final_id,) + + # ── Registration ─────────────────────────────────────────────────────── NODE_CLASS_MAPPINGS = { + "SessionIDSelector": SessionIDSelectorNode, "SessionFeedbackEditor": SessionFeedbackEditorNode, "SessionMemoryReader": SessionMemoryReaderNode, "SessionMemoryWriter": SessionMemoryWriterNode, } NODE_DISPLAY_NAME_MAPPINGS = { + "SessionIDSelector": "šŸ—‘ļø (DEPRECATED) Session ID Selector", "SessionFeedbackEditor": "\u2b50 Session Feedback Editor", "SessionMemoryReader": "\U0001f9e0 Session Memory Reader", "SessionMemoryWriter": "\U0001f4be Session Memory Writer", diff --git a/web/session_feedback_editor.js b/web/session_feedback_editor.js index b1c3b7e..b371ce4 100644 --- a/web/session_feedback_editor.js +++ b/web/session_feedback_editor.js @@ -7,13 +7,13 @@ const STAR_EMPTY = "ā˜†"; const STAR_COUNT = 5; const GOLD = "#FFD700"; const GREY = "#555"; -const CARD_BG = "#16213e"; -const CARD_BORDER = "#0f3460"; +const CARD_BG = "#1c1c1c"; +const CARD_BORDER = "#333333"; const TEXT_COLOR = "#e0e0e0"; const LABEL_COLOR = "#94a3b8"; const CONCEPT_CLR = "#7dd3fc"; -const NOTES_BG = "#1e293b"; -const NOTES_BORDER = "#334155"; +const NOTES_BG = "#222222"; +const NOTES_BORDER = "#444444"; const BTN_SAVE_BG = "#2563eb"; const BTN_SAVE_HV = "#3b82f6"; const BTN_WAIT_BG = "#d97706"; @@ -64,6 +64,42 @@ app.registerExtension({ } } }); + + // Global keydown to handle Delete/Backspace on selected thumbnails + window.addEventListener("keydown", (e) => { + const key = e.key.toLowerCase(); + const tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || document.activeElement?.isContentEditable) return; + + if (key === "delete" || key === "backspace") { + let targetNode = null; + // Find if a SessionFeedbackEditor node has selected thumbnail images + for (const n of app.graph._nodes) { + if (n.type === "SessionFeedbackEditor" || n.comfyClass === "SessionFeedbackEditor") { + if (n._fb && n._fb.selectedImages && n._fb.selectedImages.size > 0) { + // Ensure the node itself is selected in the graph to avoid accidental deletions across multiple UI panels + const isSel = n.is_selected || (app.canvas?.selected_nodes && app.canvas.selected_nodes[n.id] !== undefined); + if (isSel) { + targetNode = n; + break; + } + } + } + } + + if (targetNode) { + e.preventDefault(); + e.stopImmediatePropagation(); + // Move selected items to pendingDeletes queue + for (const path of targetNode._fb.selectedImages) { + targetNode._fb.pendingDeletes.add(path); + } + targetNode._fb.selectedImages.clear(); + targetNode._fb.lastSelectedIdx = null; + targetNode.setDirtyCanvas(true, true); + } + } + }, { capture: true }); }, async beforeRegisterNodeDef(nodeType, nodeData) { @@ -99,7 +135,7 @@ app.registerExtension({ if (n === 0) return [W, 80]; const cardW = W - 20; const availW = cardW - 20; - let totalH = 48; + let totalH = 72; for (let i = 0; i < n; i++) { const pNum = String(node._fb.prompts[i].number); const collapsed = node._fb.collapsed[pNum] !== false; @@ -129,7 +165,7 @@ app.registerExtension({ cardH += 30 + NOTES_H + 10; // stars + notes + gap totalH += cardH + 10; } - totalH += 56; + totalH += 40; return [W, totalH]; }, draw: (ctx, _n, width, posY) => node._fb_drawPanel(ctx, width, posY), @@ -168,9 +204,36 @@ app.registerExtension({ app.graph.setDirtyCanvas(true, true); }; - nodeType.prototype._fb_loadRun = function (runNumber) { + nodeType.prototype._fb_resolveSessionId = function() { + // Evaluate input link if session_id is wired to another node + const input = this.inputs?.find(i => i.name === "session_id"); + if (input && input.link) { + const l = app.graph.links[input.link]; + if (l) { + const srcNode = app.graph.getNodeById(l.origin_id); + if (srcNode) { + if ( + srcNode.type === "SessionIDSelector" || srcNode.comfyClass === "SessionIDSelector" || + srcNode.type === "SessionMemoryReader" || srcNode.comfyClass === "SessionMemoryReader" + ) { + const ex = srcNode.widgets?.find(w => w.name === "existing_session")?.value; + const nw = srcNode.widgets?.find(w => w.name === "new_session_name")?.value; + if (ex === "[CREATE NEW]") return nw?.trim() || "default"; + return ex || "default"; + } + if (srcNode.type === "PrimitiveNode" || (srcNode.type && srcNode.type.includes("String"))) { + return srcNode.widgets?.[0]?.value || "default"; + } + } + } + } + // Fallback to local widget const w = this.widgets?.find(w => w.name === "session_id"); - const sid = w?.value || "default"; + return w?.value || this._fb.sessionId || "default"; + }; + + nodeType.prototype._fb_loadRun = function (runNumber) { + const sid = this._fb_resolveSessionId(); this._fb.sessionId = sid; const rp = runNumber ? `&run_number=${runNumber}` : ""; fetch(`/session_feedback/load?session_id=${encodeURIComponent(sid)}${rp}`) @@ -201,9 +264,15 @@ app.registerExtension({ .then(data => { this._fb.thumbnails = {}; this._fb.thumbsLoading = 0; + this._fb.selectedImages = new Set(); + this._fb.pendingDeletes = new Set(); + this._fb.lastSelectedIdx = -1; + let globalIdx = 0; for (const [pNum, urls] of Object.entries(data)) { this._fb.thumbnails[pNum] = urls.map(url => { const img = new Image(); + img._urlPath = url.split("?path=")[1] || url; + img._globalIdx = globalIdx++; this._fb.thumbsLoading++; img.onload = () => { this._fb.thumbsLoading--; @@ -218,7 +287,12 @@ app.registerExtension({ img.src = url; return img; }); + + if (!this._fb.prompts.find((p) => String(p.number) === pNum)) { + this._fb.prompts.push({ number: parseInt(pNum, 10), concept: `Result ${pNum}`, fullText: "" }); + } } + this._fb.prompts.sort((a, b) => a.number - b.number); this.setSize([this.size[0], this.computeSize()[1]]); this.setDirtyCanvas(true); }) @@ -255,7 +329,7 @@ app.registerExtension({ position: "fixed", left: `${sr.x}px`, top: `${sr.y}px`, width: `${sr.w}px`, height: `${sr.h}px`, - background: "#1e293b", + background: "#222222", color: "#e0e0e0", caretColor: "#7dd3fc", border: "1.5px solid #2563eb", @@ -283,6 +357,7 @@ app.registerExtension({ ta.addEventListener("input", () => { fb.notes[promptNum] = ta.value; + node._fb_autosave(); }); ta.addEventListener("keydown", (e) => { if (e.key === "Escape") { node._fb_stopEditing(); return; } @@ -310,33 +385,86 @@ app.registerExtension({ ta.setSelectionRange(ta.value.length, ta.value.length); }; - // ── Save ──────────────────────────────────────────────────────── - nodeType.prototype._fb_saveFeedback = function () { + // ── Save / Autosave ───────────────────────────────────────────── + nodeType.prototype._fb_autosave = function () { + if (this._autosaveTimer) clearTimeout(this._autosaveTimer); + this._autosaveTimer = setTimeout(() => { + this._fb_saveFeedback(false); + }, 800); + }; + + nodeType.prototype._fb_saveFeedback = function (includeDeletions = true) { if (this._fb.selectedRun === 0) return; - this._fb_stopEditing(); + if (includeDeletions) this._fb_stopEditing(); + 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 }), + body: JSON.stringify({ + session_id: this._fb.sessionId, + run_number: this._fb.selectedRun, + prompts, + deleted_thumbs: includeDeletions ? Array.from(this._fb.pendingDeletes || []) : [] + }), }) .then(() => { this._fb.saveFlash = Date.now(); + if (includeDeletions) { + if (this._fb.pendingDeletes) this._fb.pendingDeletes.clear(); + if (this._fb.selectedImages) this._fb.selectedImages.clear(); + } this.setDirtyCanvas(true); - if (this._fb.waiting) { + + if (this._fb.waiting && includeDeletions) { 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); }); } + + if (includeDeletions) this._fb_loadThumbnails(); }); }; + nodeType.prototype._fb_resumeWorkflow = function () { + if (!this._fb.waiting) return; + 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); }); + }; + + nodeType.prototype._fb_deleteRun = function () { + if (this._fb.selectedRun === 0) return; + this._fb_stopEditing(); + fetch("/session_feedback/delete_run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: this._fb.sessionId, run_number: this._fb.selectedRun }), + }) + .then(() => { + // Unblock workflow if waiting + 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._fb.selectedRun = 0; + this._fb_loadRun(0); + }) + .catch(err => console.error("[Feedback] Delete error:", err)); + }; + // ── Draw panel ────────────────────────────────────────────────── nodeType.prototype._fb_drawPanel = function (ctx, width, posY) { const pad = 14; @@ -347,10 +475,19 @@ app.registerExtension({ ctx.save(); if (fb.runCount === 0) { + ctx.fillStyle = LABEL_COLOR; + ctx.font = "bold 12px Inter, Arial, sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText("Session:", pad, curY + 14); + + ctx.fillStyle = "#fff"; + ctx.font = "bold 13px Inter, Arial, sans-serif"; + ctx.fillText(fb.sessionId || "default", pad + 55, curY + 14); + 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.fillText("No runs in this session.", pad, curY + 40); + ctx.restore(); return; } @@ -364,18 +501,29 @@ app.registerExtension({ 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.fillText("āø Workflow paused — add feedback and click Resume Workflow", pad + 6, curY + 13); ctx.globalAlpha = 1; curY += barH + 4; requestAnimationFrame(() => this.setDirtyCanvas(true)); } - // Run navigator - fb.hitAreas = { leftArrow: null, rightArrow: null, stars: [], headers: [], notes: [], saveBtn: null }; + fb.hitAreas = { leftArrow: null, rightArrow: null, stars: [], headers: [], notes: [], saveBtn: null, thumbnails: [], trashBtn: null, undoBtn: null, confirmBtn: null }; + // --- ROW 1: Session Info & Load/Nuke Buttons --- ctx.fillStyle = LABEL_COLOR; ctx.font = "bold 12px Inter, Arial, sans-serif"; ctx.textBaseline = "middle"; + ctx.fillText("Session:", pad, curY + 14); + + ctx.fillStyle = "#fff"; + ctx.font = "bold 13px Inter, Arial, sans-serif"; + ctx.fillText(fb.sessionId || "default", pad + 55, curY + 14); + + curY += 28; // move to row 2 + + // --- ROW 2: Run Info & Delete Button --- + ctx.fillStyle = LABEL_COLOR; + ctx.font = "bold 12px Inter, Arial, sans-serif"; ctx.fillText("Run:", pad, curY + 14); const leftX = pad + 35, labelX = leftX + 26, rightX = labelX + 80; @@ -392,6 +540,79 @@ app.registerExtension({ ctx.font = "bold 16px Inter, Arial, sans-serif"; ctx.fillText("ā–¶", rightX + 2, curY + 14); fb.hitAreas.rightArrow = { x: rightX, y: curY + 4, w: 24, h: 22 }; + + // Draw "Delete" (run) right-aligned with the right arrow for tidiness, or just fixed offset + const delX = rightX + 30; + const borderColor = fb.hoverDelete ? "#fca5a5" : "#f87171"; // lighter red/salmon + const bgColor = fb.hoverDelete ? "#451a1a" : "#2c1414"; // dark red tint + + ctx.fillStyle = bgColor; + ctx.beginPath(); + ctx.roundRect(delX, curY + 3, 100, 22, 4); + ctx.fill(); + + ctx.strokeStyle = borderColor; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = borderColor; + ctx.font = "12px Inter, Arial, sans-serif"; + ctx.fillText("āœ• Delete Run", delX + 10, curY + 14); + fb.hitAreas.deleteBtn = { x: delX, y: curY + 3, w: 100, h: 22 }; + + let dynX = delX + 110; + + // Trash Button + if (fb.selectedImages && fb.selectedImages.size > 0) { + const trashBorder = fb.hoverTrash ? "#93c5fd" : "#3b82f6"; + const trashBg = fb.hoverTrash ? "#1e3a8a" : "#1e40af"; + const txt = `šŸ—‘ Delete ${fb.selectedImages.size}`; + const textW = ctx.measureText(txt).width + 24; + + ctx.fillStyle = trashBg; + ctx.beginPath(); ctx.roundRect(dynX, curY + 3, textW, 22, 4); ctx.fill(); + ctx.strokeStyle = trashBorder; ctx.lineWidth = 1; ctx.stroke(); + + ctx.fillStyle = trashBorder; + ctx.font = "12px Inter, Arial, sans-serif"; + ctx.fillText(txt, dynX + 10, curY + 14); + fb.hitAreas.trashBtn = { x: dynX, y: curY + 3, w: textW, h: 22 }; + dynX += textW + 10; + } + + // Undo & Confirm Deletion + if (fb.pendingDeletes && fb.pendingDeletes.size > 0) { + const undoBorder = fb.hoverUndo ? "#fcd34d" : "#f59e0b"; + const undoBg = fb.hoverUndo ? "#78350f" : "#451a03"; + const uTxt = `ā†©ļø Undo (${fb.pendingDeletes.size})`; + const uTextW = ctx.measureText(uTxt).width + 24; + + ctx.fillStyle = undoBg; + ctx.beginPath(); ctx.roundRect(dynX, curY + 3, uTextW, 22, 4); ctx.fill(); + ctx.strokeStyle = undoBorder; ctx.lineWidth = 1; ctx.stroke(); + + ctx.fillStyle = undoBorder; + ctx.font = "12px Inter, Arial, sans-serif"; + ctx.fillText(uTxt, dynX + 10, curY + 14); + fb.hitAreas.undoBtn = { x: dynX, y: curY + 3, w: uTextW, h: 22 }; + dynX += uTextW + 10; + + const confirmBorder = fb.hoverConfirm ? "#fca5a5" : "#ef4444"; + const confirmBg = fb.hoverConfirm ? "#7f1d1d" : "#450a0a"; + const cTxt = `āš ļø Confirm Deletion`; + const cTextW = ctx.measureText(cTxt).width + 24; + + ctx.fillStyle = confirmBg; + ctx.beginPath(); ctx.roundRect(dynX, curY + 3, cTextW, 22, 4); ctx.fill(); + ctx.strokeStyle = confirmBorder; ctx.lineWidth = 1; ctx.stroke(); + + ctx.fillStyle = confirmBorder; + ctx.font = "bold 12px Inter, Arial, sans-serif"; + ctx.fillText(cTxt, dynX + 10, curY + 14); + fb.hitAreas.confirmBtn = { x: dynX, y: curY + 3, w: cTextW, h: 22 }; + dynX += cTextW + 10; + } + curY += 38; // Prompt cards @@ -400,7 +621,7 @@ app.registerExtension({ const pNum = String(prompt.number); const rating = fb.ratings[pNum] || 0; const notes = fb.notes[pNum] || ""; - const thumbs = fb.thumbnails[pNum] || []; + const thumbs = (fb.thumbnails[pNum] || []).filter(img => !fb.pendingDeletes.has(img._urlPath)); const collapsed = fb.collapsed[pNum] !== false; const cX = pad - 4; @@ -502,8 +723,22 @@ app.registerExtension({ ctx.beginPath(); ctx.roundRect(tX, tY, tw, THUMB_MAX_H, 5); ctx.clip(); ctx.drawImage(img, tX, tY, tw, THUMB_MAX_H); ctx.restore(); - ctx.strokeStyle = "#334155"; ctx.lineWidth = 1; + + const isSelected = fb.selectedImages && fb.selectedImages.has(img._urlPath); + + if (isSelected) { + ctx.fillStyle = "rgba(59,130,246,0.25)"; + ctx.beginPath(); ctx.roundRect(tX, tY, tw, THUMB_MAX_H, 5); ctx.fill(); + ctx.strokeStyle = "#60a5fa"; + ctx.lineWidth = 2.5; + } else { + ctx.strokeStyle = "#334155"; + ctx.lineWidth = 1; + } + ctx.beginPath(); ctx.roundRect(tX, tY, tw, THUMB_MAX_H, 5); ctx.stroke(); + + fb.hitAreas.thumbnails.push({ x: tX, y: tY, w: tw, h: THUMB_MAX_H, path: img._urlPath, globalIdx: img._globalIdx }); tX += tw + THUMB_GAP; } tY += THUMB_MAX_H + THUMB_GAP; @@ -576,25 +811,34 @@ app.registerExtension({ curY += cardH + 10; } - // Save button - const btnW = inW + 8, btnH = 34, btnX = pad - 4, btnY = curY + 2; + // Bottom button: Only "Resume Option" if blocking, else just a tiny flash text if just saved + const btnX = pad - 4, btnY = curY + 2, btnW = inW + 8; const flash = (Date.now() - fb.saveFlash) < 1500; - const btnBg = flash ? BTN_DONE_BG : (fb.waiting ? (fb.hoverBtn ? BTN_WAIT_HV : BTN_WAIT_BG) : (fb.hoverBtn ? BTN_SAVE_HV : BTN_SAVE_BG)); - const btnText = flash ? "āœ… Saved" : (fb.waiting ? "šŸ’¾ Save & Continue ā–¶" : "šŸ’¾ 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; + if (fb.waiting) { + const btnH = 34; + const btnBg = fb.hoverBtn ? BTN_WAIT_HV : BTN_WAIT_BG; + const btnText = "ā–¶ Resume Workflow"; - 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 }; + 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 }; + } else if (flash) { + ctx.fillStyle = "#4ade80"; + ctx.font = "italic 11px Inter, Arial, sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText("āœ… Autosaved", btnX + 4, btnY + 12); + requestAnimationFrame(() => this.setDirtyCanvas(true)); + } - if (flash) requestAnimationFrame(() => this.setDirtyCanvas(true)); ctx.restore(); }; @@ -606,9 +850,14 @@ app.registerExtension({ const hit = (r) => r && mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h; if (event.type === "pointermove" || event.type === "mousemove") { - const was = fb.hoverBtn; + const wasH = fb.hoverBtn, wasD = fb.hoverDelete; + const wasT = fb.hoverTrash, wasU = fb.hoverUndo, wasC = fb.hoverConfirm; fb.hoverBtn = hit(fb.hitAreas.saveBtn); - if (was !== fb.hoverBtn) this.setDirtyCanvas(true); + fb.hoverDelete = hit(fb.hitAreas.deleteBtn); + fb.hoverTrash = hit(fb.hitAreas.trashBtn); + fb.hoverUndo = hit(fb.hitAreas.undoBtn); + fb.hoverConfirm = hit(fb.hitAreas.confirmBtn); + if (wasH !== fb.hoverBtn || wasD !== fb.hoverDelete || wasT !== fb.hoverTrash || wasU !== fb.hoverUndo || wasC !== fb.hoverConfirm) this.setDirtyCanvas(true); return false; } @@ -616,6 +865,8 @@ app.registerExtension({ event.type === (LiteGraph.pointerevents_method + "down"); if (!isClick) return false; + + if (hit(fb.hitAreas.leftArrow) && fb.selectedRun > 1) { this._fb_stopEditing(); this._fb_loadRun(fb.selectedRun - 1); return true; } @@ -623,6 +874,65 @@ app.registerExtension({ this._fb_stopEditing(); this._fb_loadRun(fb.selectedRun + 1); return true; } + if (hit(fb.hitAreas.deleteBtn) && fb.selectedRun > 0) { + this._fb_stopEditing(); + if (window.confirm("šŸ—‘ļø Completely delete Run " + fb.selectedRun + "?\n\nThis will remove it from the session history along with all its thumbnails. This action cannot be undone.")) { + this._fb_deleteRun(); + } + return true; + } + + if (hit(fb.hitAreas.trashBtn)) { + for (const path of fb.selectedImages) fb.pendingDeletes.add(path); + fb.selectedImages.clear(); + fb.lastSelectedIdx = -1; + this._fb_stopEditing(); + requestAnimationFrame(() => { + this.setSize([this.size[0], this.computeSize()[1]]); + app.graph.setDirtyCanvas(true, true); + }); + return true; + } + + if (hit(fb.hitAreas.undoBtn)) { + fb.pendingDeletes.clear(); + this._fb_stopEditing(); + requestAnimationFrame(() => { + this.setSize([this.size[0], this.computeSize()[1]]); + app.graph.setDirtyCanvas(true, true); + }); + return true; + } + + // Thumbnails selection + for (const t of (fb.hitAreas.thumbnails || [])) { + if (!hit(t)) continue; + + this._fb_stopEditing(); + + if (event.shiftKey && fb.lastSelectedIdx >= 0) { + const start = Math.min(fb.lastSelectedIdx, t.globalIdx); + const end = Math.max(fb.lastSelectedIdx, t.globalIdx); + for (const ht of fb.hitAreas.thumbnails) { + if (ht.globalIdx >= start && ht.globalIdx <= end) { + if (!fb.pendingDeletes.has(ht.path)) { + fb.selectedImages.add(ht.path); + } + } + } + } else { + // Additive Selection default + if (fb.selectedImages.has(t.path)) { + fb.selectedImages.delete(t.path); + } else { + fb.selectedImages.add(t.path); + fb.lastSelectedIdx = t.globalIdx; + } + } + this.setDirtyCanvas(true); + return true; + } + // Header toggle for (const h of (fb.hitAreas.headers || [])) { if (!h || !hit(h)) continue; @@ -642,6 +952,7 @@ app.registerExtension({ if (!s || !hit(s)) continue; const nr = s.starIdx + 1; fb.ratings[s.promptNum] = fb.ratings[s.promptNum] === nr ? 0 : nr; + this._fb_autosave(); this.setDirtyCanvas(true); return true; } @@ -655,8 +966,15 @@ app.registerExtension({ return true; } - // Save - if (hit(fb.hitAreas.saveBtn)) { this._fb_saveFeedback(); return true; } + // Confirm explicit Deletions + if (hit(fb.hitAreas.confirmBtn)) { this._fb_saveFeedback(true); return true; } + + // Resume workflow explicit button (when paused/blocking) + if (hit(fb.hitAreas.saveBtn) && fb.waiting) { + this._fb_saveFeedback(false); + this._fb_resumeWorkflow(); + return true; + } // Click elsewhere closes textarea this._fb_stopEditing(); @@ -664,18 +982,116 @@ app.registerExtension({ }; } - // ── Session Memory Reader — reset confirmation ──────────────────── - if (nodeData.name === "SessionMemoryReader") { - const orig = nodeType.prototype.onWidgetChanged; - nodeType.prototype.onWidgetChanged = function (widget) { - if (widget?.name === "reset_session" && widget.value === true) { - const ok = window.confirm( - "āš ļø Reset session?\n\nALL data will be permanently deleted:\n" + - "• Run history\n• Feedback & ratings\n• All thumbnail images\n\nThis action cannot be undone." - ); - if (!ok) { widget.value = false; this.setDirtyCanvas(true); return; } - } - if (orig) orig.apply(this, arguments); + // ── Session Memory Reader & Selector — dynamic load and custom buttons ── + if (nodeData.name === "SessionMemoryReader" || nodeData.name === "SessionIDSelector") { + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + if (origOnNodeCreated) origOnNodeCreated.apply(this, arguments); + + // Native Buttons for Rename and Nuke + this.addWidget("button", "āœļø Rename Session", "rename_session", () => { + const w = this.widgets.find(x => x.name === "existing_session"); + const oldName = w ? w.value : null; + + if (!oldName || oldName === "[CREATE NEW]") return alert("Please select an active session to rename."); + + const newName = window.prompt("Enter new session name:", oldName); + if (!newName || newName === oldName || newName.trim() === "") return; + + fetch("/session_feedback/rename_session", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ old_session_id: oldName, new_session_id: newName.trim() }) + }) + .then(res => res.json()) + .then(data => { + if (data.error) return alert("Error renaming: " + data.error); + + // Rename in all dropdowns + app.graph._nodes.forEach(n => { + if (n.type === "SessionMemoryReader" || n.comfyClass === "SessionMemoryReader" || + n.type === "SessionIDSelector" || n.comfyClass === "SessionIDSelector") { + const combo = n.widgets?.find(x => x.name === "existing_session"); + if (combo && combo.options && combo.options.values) { + const idx = combo.options.values.indexOf(oldName); + if (idx !== -1) { + combo.options.values[idx] = newName.trim(); + } else { + combo.options.values.push(newName.trim()); // Fallback if missing + } + if (combo.value === oldName) combo.value = newName.trim(); + } + } + }); + // Refresh all Feedback Editor nodes + app.graph._nodes.forEach(n => { + if (n.type === "SessionFeedbackEditor" || n.comfyClass === "SessionFeedbackEditor") { + if (n._fb_loadRun) n._fb_loadRun(0); + } + }); + }).catch(err => console.error("[Feedback] Rename session error:", err)); + }); + + this.addWidget("button", "ā˜¢ļø Nuke Session", "nuke_session", () => { + const w = this.widgets.find(x => x.name === "existing_session"); + const sessionName = w ? w.value : null; + + if (!sessionName || sessionName === "[CREATE NEW]") return alert("Please select a session to Nuke."); + + if (window.confirm(`āš ļø WARNING āš ļø\n\nYou are about to DELETE the entire session '${sessionName}'.\nThis removes all runs, prompts, and thumbnails permanently.\n\nAre you sure you want to proceed?`)) { + fetch("/session_feedback/delete_session", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionName }) + }).then(() => { + // Remove from all dropdowns + app.graph._nodes.forEach(n => { + if (n.type === "SessionMemoryReader" || n.comfyClass === "SessionMemoryReader" || + n.type === "SessionIDSelector" || n.comfyClass === "SessionIDSelector") { + const combo = n.widgets?.find(x => x.name === "existing_session"); + if (combo && combo.options && combo.options.values) { + combo.options.values = combo.options.values.filter(v => v !== sessionName); + if (combo.value === sessionName) combo.value = combo.options.values[0] || "[CREATE NEW]"; + } + } + }); + // Refresh all Feedback Editor nodes + app.graph._nodes.forEach(n => { + if (n.type === "SessionFeedbackEditor" || n.comfyClass === "SessionFeedbackEditor") { + if (n._fb_loadRun) n._fb_loadRun(0); + } + }); + }).catch(err => console.error("[Feedback] Delete session error:", err)); + } + }); + + // We wrap this in a small timeout to let ComfyUI finish building native widgets + setTimeout(() => { + if (!this.widgets) return; + // Filter out the old reset_session widget if it still exists from cached versions + const rsIdx = this.widgets.findIndex(w => w.name === "reset_session"); + if (rsIdx !== -1) this.widgets.splice(rsIdx, 1); + + for (const w of this.widgets) { + const origCb = w.callback; + w.callback = function (v, canvas, node, pos, event) { + const res = origCb ? origCb.apply(this, arguments) : v; + + if (w.name === "existing_session" || w.name === "new_session_name") { + // Find node by referencing 'this' from the outer scope, or 'node' argument + const parentNode = node || app.graph.getNodeById(this?.id) || {}; + clearTimeout(parentNode._fb_loadTimeout); + parentNode._fb_loadTimeout = setTimeout(() => { + app.graph._nodes.forEach(n => { + if (n.type === "SessionFeedbackEditor" || n.comfyClass === "SessionFeedbackEditor") { + if (n._fb_loadRun) n._fb_loadRun(0); + } + }); + }, 300); + } + + return res; + }; + } + }, 100); }; } },