import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; // ── Visual constants ────────────────────────────────────────────────── const STAR_FILLED = "★"; const STAR_EMPTY = "☆"; const STAR_COUNT = 5; const GOLD = "#FFD700"; const GREY = "#555"; const CARD_BG = "#1c1c1c"; const CARD_BORDER = "#333333"; const TEXT_COLOR = "#e0e0e0"; const LABEL_COLOR = "#94a3b8"; const CONCEPT_CLR = "#7dd3fc"; const NOTES_BG = "#222222"; const NOTES_BORDER = "#444444"; 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 = 400; const THUMB_MAX_H = 150; const THUMB_GAP = 8; const NOTES_H = 62; const PROMPT_FONT = "12px 'Courier New', Consolas, monospace"; const PROMPT_LH = 17; const RATING_LABELS = { 0: "", 1: "Poor — AVOID", 2: "Weak — AVOID", 3: "Neutral", 4: "Good — REPLICATE", 5: "Excellent — REPLICATE", }; // Word-wrap helper: returns array of display lines respecting maxW in canvas pixels function wrapText(ctx, text, maxW) { const out = []; for (const para of text.split("\n")) { if (!para) { out.push(""); continue; } const words = para.split(" "); let cur = ""; for (const w of words) { const test = cur ? cur + " " + w : w; if (ctx.measureText(test).width > maxW && cur) { out.push(cur); cur = w; } else { cur = test; } } if (cur) out.push(cur); } return out.length ? out : [""]; } app.registerExtension({ name: "session.FeedbackEditor", async setup() { api.addEventListener("session_feedback_update", (event) => { const data = event.detail; for (const node of app.graph._nodes) { if (String(node.id) === String(data.node_id) && node._fb_onUpdate) { node._fb_onUpdate(data); break; } } }); // 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) { // ── Session Feedback Editor ─────────────────────────────────────── if (nodeData.name === "SessionFeedbackEditor") { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { if (onNodeCreated) onNodeCreated.apply(this, arguments); this._fb = { sessionId: "default", runCount: 0, selectedRun: 0, prompts: [], ratings: {}, notes: {}, waiting: false, saveFlash: 0, hitAreas: null, hoverBtn: false, thumbnails: {}, thumbsLoading: 0, collapsed: {}, // text editing: visible textarea overlay _editingPrompt: null, _editingTA: null, _notesScreenRects: {}, // populated during draw }; if (this.size[0] < NODE_MIN_W) this.size[0] = NODE_MIN_W; const node = this; this.addCustomWidget({ name: "feedback_panel", type: "custom", value: "", computeSize: () => { const n = node._fb.prompts.length; const W = node.size[0]; // preserve user width if (n === 0) return [W, 80]; const cardW = W - 20; const availW = cardW - 20; 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; const thumbs = (node._fb.thumbnails[pNum]) || []; let cardH = 34; // header // Full prompt text (only when expanded) if (!collapsed) { const ft = node._fb.prompts[i].fullText || ""; if (ft) { const cpl = Math.max(10, Math.floor(availW / 7)); cardH += 10 + Math.ceil(ft.length / cpl) * PROMPT_LH + 8; } } // Thumbnails: always visible if (thumbs.length > 0) { // Estimate rows: pack thumbs side by side at THUMB_MAX_H let rowW = 0, rows = 1; for (const img of thumbs) { const tw = img.complete && img.naturalHeight > 0 ? Math.round(img.naturalWidth * (THUMB_MAX_H / img.naturalHeight)) : 120; if (rowW + tw + THUMB_GAP > availW && rowW > 0) { rows++; rowW = tw + THUMB_GAP; } else rowW += tw + THUMB_GAP; } cardH += rows * (THUMB_MAX_H + THUMB_GAP) + 8; } cardH += 30 + NOTES_H + 10; // stars + notes + gap totalH += cardH + 10; } totalH += 40; return [W, totalH]; }, draw: (ctx, _n, width, posY) => node._fb_drawPanel(ctx, width, posY), mouse: (event, pos) => node._fb_handleMouse(event, pos), serialize: false, }); setTimeout(() => this._fb_loadRun(), 400); this.onRemoved = () => this._fb_stopEditing(); }; 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(); } }; 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 = {}; for (const [k, v] of Object.entries(data.feedback || {})) { this._fb.ratings[k] = v.rating || 0; this._fb.notes[k] = v.notes || ""; } this._fb_loadThumbnails(); this.setDirtyCanvas(true); app.graph.setDirtyCanvas(true, true); }; 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"); 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}`) .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 = {}; for (const [k, v] of Object.entries(data.feedback || {})) { this._fb.ratings[k] = v.rating || 0; this._fb.notes[k] = v.notes || ""; } this._fb_loadThumbnails(); this.setDirtyCanvas(true); app.graph.setDirtyCanvas(true, true); }) .catch(err => console.error("[Feedback] Load error:", err)); }; nodeType.prototype._fb_loadThumbnails = function () { const sid = this._fb.sessionId; const run = this._fb.selectedRun; if (!run) { this._fb.thumbnails = {}; return; } fetch(`/session_feedback/thumbnails?session_id=${encodeURIComponent(sid)}&run_number=${run}`) .then(r => r.json()) .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--; if (this._fb.thumbsLoading <= 0) { requestAnimationFrame(() => { this.setSize([this.size[0], this.computeSize()[1]]); app.graph.setDirtyCanvas(true, true); }); } else { this.setDirtyCanvas(true); } }; img.onerror = () => { this._fb.thumbsLoading--; }; 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); }) .catch(err => console.error("[Feedback] Thumbnail load error:", err)); }; // ── Visible textarea overlay ──────────────────────────────────── // Position computed from ctx.getTransform() captured during draw. nodeType.prototype._fb_stopEditing = function () { const fb = this._fb; if (fb._editingTA) { fb.notes[fb._editingPrompt] = fb._editingTA.value; fb._editingTA.remove(); fb._editingTA = null; } if (fb._editingClickOut) { document.removeEventListener("pointerdown", fb._editingClickOut, true); fb._editingClickOut = null; } fb._editingPrompt = null; this.setDirtyCanvas(true); }; nodeType.prototype._fb_startEditing = function (promptNum) { this._fb_stopEditing(); const fb = this._fb; const sr = fb._notesScreenRects[promptNum]; if (!sr) return; const ta = document.createElement("textarea"); ta.value = fb.notes[promptNum] || ""; ta.placeholder = "Add notes…"; Object.assign(ta.style, { position: "fixed", left: `${sr.x}px`, top: `${sr.y}px`, width: `${sr.w}px`, height: `${sr.h}px`, background: "#222222", color: "#e0e0e0", caretColor: "#7dd3fc", border: "1.5px solid #2563eb", borderRadius: "5px", padding: "6px 8px", font: "11px Inter, Arial, sans-serif", lineHeight: "1.4", resize: "none", zIndex: "999999", boxSizing: "border-box", outline: "none", overflow: "auto", }); const node = this; // Close when user clicks OUTSIDE the textarea (capture phase, runs first) const onClickOut = (e) => { if (!ta.contains(e.target)) { node._fb_stopEditing(); } }; document.addEventListener("pointerdown", onClickOut, true); fb._editingClickOut = onClickOut; ta.addEventListener("input", () => { fb.notes[promptNum] = ta.value; node._fb_autosave(); }); ta.addEventListener("keydown", (e) => { if (e.key === "Escape") { node._fb_stopEditing(); return; } e.stopPropagation(); e.stopImmediatePropagation(); }); ta.addEventListener("keyup", (e) => { e.stopPropagation(); e.stopImmediatePropagation(); }); ta.addEventListener("keypress",(e) => { e.stopPropagation(); e.stopImmediatePropagation(); }); // Refocus only if LiteGraph steals focus without a user click ta.addEventListener("blur", () => { setTimeout(() => { if (fb._editingTA === ta && document.activeElement !== ta) { ta.focus(); } }, 30); }); // Prevent canvas drag when clicking inside textarea for (const ev of ["pointerdown","mousedown","pointermove","mousemove","pointerup","mouseup","click","dblclick","wheel"]) ta.addEventListener(ev, (e) => e.stopPropagation()); document.body.appendChild(ta); fb._editingTA = ta; fb._editingPrompt = promptNum; ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }; // ── 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; 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, 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 && 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; const inW = width - pad * 2; let curY = posY + 6; const fb = this._fb; 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.fillText("No runs in this session.", pad, curY + 40); ctx.restore(); return; } // Blocking bar if (fb.waiting) { const barH = 26; ctx.fillStyle = "#451a03"; ctx.beginPath(); ctx.roundRect(pad - 4, curY, inW + 8, barH, 5); ctx.fill(); ctx.globalAlpha = 0.6 + 0.4 * Math.sin(Date.now() / 300); ctx.fillStyle = BTN_WAIT_BG; ctx.font = "bold 11px Inter, Arial, sans-serif"; ctx.textBaseline = "middle"; ctx.fillText("⏸ Workflow paused — add feedback and click Resume Workflow", pad + 6, curY + 13); ctx.globalAlpha = 1; curY += barH + 4; requestAnimationFrame(() => this.setDirtyCanvas(true)); } 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; 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: curY + 4, w: 24, h: 22 }; ctx.fillStyle = "#fff"; ctx.font = "bold 13px Inter, Arial, sans-serif"; ctx.fillText(`Run ${fb.selectedRun} / ${fb.runCount}`, labelX + 2, curY + 14); 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: 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 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[pNum] || []).filter(img => !fb.pendingDeletes.has(img._urlPath)); const collapsed = fb.collapsed[pNum] !== false; const cX = pad - 4; const cW = inW + 8; const availW = cW - 20; // Measure prompt text lines (only when expanded) let promptLines = []; if (!collapsed && prompt.fullText) { const words = prompt.fullText.split(/(\s+)/); ctx.font = PROMPT_FONT; let line = ""; for (const tok of words) { const test = line + tok; if (ctx.measureText(test).width > availW - 4 && line.trim()) { promptLines.push(line.trimEnd()); line = tok.trimStart(); } else { line = test; } } if (line.trim()) promptLines.push(line.trimEnd()); } // Layout thumb rows (always visible) const thumbRows = []; if (thumbs.length > 0) { let row = [], rowW = 0; for (const img of thumbs) { const tw = img.complete && img.naturalHeight > 0 ? Math.round(img.naturalWidth * (THUMB_MAX_H / img.naturalHeight)) : 120; if (row.length > 0 && rowW + tw + THUMB_GAP > availW) { thumbRows.push(row); row = [img]; rowW = tw + THUMB_GAP; } else { row.push(img); rowW += tw + THUMB_GAP; } } if (row.length) thumbRows.push(row); } // Compute card height let cardH = 34; // header if (promptLines.length > 0) cardH += 10 + promptLines.length * PROMPT_LH + 8; if (thumbRows.length > 0) cardH += thumbRows.length * (THUMB_MAX_H + THUMB_GAP) + 8; cardH += 30 + NOTES_H + 10; // Card bg ctx.fillStyle = CARD_BG; ctx.strokeStyle = CARD_BORDER; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(cX, curY, cW, cardH, 6); ctx.fill(); ctx.stroke(); // Header (toggle) const chevron = collapsed ? "▶" : "▼"; ctx.font = "bold 11px Inter, Arial, sans-serif"; ctx.fillStyle = CONCEPT_CLR; ctx.textBaseline = "middle"; let headerLabel = `${chevron} 📋 Prompt ${pNum}: ${prompt.concept || ""}`; while (ctx.measureText(headerLabel).width > cW - 22 && headerLabel.length > 20) headerLabel = headerLabel.slice(0, -4) + "…"; ctx.fillText(headerLabel, cX + 10, curY + 17); fb.hitAreas.headers[i] = { x: cX, y: curY, w: cW, h: 34, promptNum: pNum }; // Separator ctx.strokeStyle = "#1e3a5f"; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(cX + 8, curY + 34); ctx.lineTo(cX + cW - 8, curY + 34); ctx.stroke(); let innerY = curY + 38; // Full prompt text (only when expanded) if (promptLines.length > 0) { ctx.font = PROMPT_FONT; ctx.fillStyle = "#94a3b8"; ctx.textBaseline = "top"; for (let li = 0; li < promptLines.length; li++) ctx.fillText(promptLines[li], cX + 10, innerY + li * PROMPT_LH); innerY += promptLines.length * PROMPT_LH + 10; ctx.textBaseline = "middle"; } // Thumbnails — ALWAYS VISIBLE if (thumbRows.length > 0) { let tY = innerY; for (const row of thumbRows) { let tX = cX + 10; for (const img of row) { if (!img.complete || img.naturalWidth === 0) { // Placeholder ctx.fillStyle = "#0d1117"; ctx.beginPath(); ctx.roundRect(tX, tY, 120, THUMB_MAX_H, 5); ctx.fill(); const phase = (Date.now() / 400) % 3; for (let d = 0; d < 3; d++) { ctx.fillStyle = d === Math.floor(phase) ? "#475569" : "#1e293b"; ctx.beginPath(); ctx.arc(tX + 60 - 10 + d * 10, tY + THUMB_MAX_H / 2, 2.5, 0, Math.PI * 2); ctx.fill(); } tX += 120 + THUMB_GAP; continue; } const tw = Math.round(img.naturalWidth * (THUMB_MAX_H / img.naturalHeight)); ctx.save(); ctx.beginPath(); ctx.roundRect(tX, tY, tw, THUMB_MAX_H, 5); ctx.clip(); ctx.drawImage(img, tX, tY, tw, THUMB_MAX_H); ctx.restore(); 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; } innerY = tY + 4; if (fb.thumbsLoading > 0) requestAnimationFrame(() => this.setDirtyCanvas(true)); } // Stars const sX = cX + 10, sY = innerY; const sSpc = 23, sSize = 18; fb.hitAreas.stars[i] = []; for (let s = 0; s < STAR_COUNT; s++) { ctx.fillStyle = s < rating ? GOLD : GREY; ctx.font = `${sSize}px Inter, Arial, sans-serif`; ctx.textBaseline = "middle"; ctx.fillText(s < rating ? 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 }; } 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 isEditing = fb._editingPrompt === pNum; const nY = sY + 26, nW = cW - 20; ctx.fillStyle = NOTES_BG; ctx.strokeStyle = isEditing ? "#2563eb" : NOTES_BORDER; ctx.lineWidth = isEditing ? 1.5 : 0.5; ctx.beginPath(); ctx.roundRect(cX + 10, nY, nW, NOTES_H, 5); ctx.fill(); ctx.stroke(); // Capture screen rect for textarea positioning via ctx transform const t = ctx.getTransform(); const canvasEl = app.canvas.canvas; const cRect = canvasEl.getBoundingClientRect(); const ratio = cRect.width / canvasEl.width; fb._notesScreenRects[pNum] = { x: cRect.left + (t.a * (cX + 10) + t.e) * ratio, y: cRect.top + (t.d * nY + t.f) * ratio, w: nW * t.a * ratio, h: NOTES_H * t.d * ratio, }; // Only draw text on canvas when NOT editing (textarea is on top) if (!isEditing) { ctx.font = "11px Inter, Arial, sans-serif"; ctx.textBaseline = "top"; if (notes) { ctx.fillStyle = TEXT_COLOR; const wrapped = wrapText(ctx, notes, nW - 16); for (let nl = 0; nl < Math.min(wrapped.length, 4); nl++) ctx.fillText(wrapped[nl], cX + 16, nY + 8 + nl * 14); } else { ctx.fillStyle = "#475569"; ctx.fillText("Add notes\u2026", cX + 14, nY + 8); } ctx.textBaseline = "middle"; } else if (fb._editingTA) { // Reposition textarea on every frame (handles pan/zoom) const sr = fb._notesScreenRects[pNum]; fb._editingTA.style.left = `${sr.x}px`; fb._editingTA.style.top = `${sr.y}px`; fb._editingTA.style.width = `${sr.w}px`; fb._editingTA.style.height = `${sr.h}px`; } fb.hitAreas.notes[i] = { x: cX + 10, y: nY, w: nW, h: NOTES_H, promptNum: pNum }; curY += cardH + 10; } // 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; if (fb.waiting) { const btnH = 34; const btnBg = fb.hoverBtn ? BTN_WAIT_HV : BTN_WAIT_BG; const btnText = "▶ Resume Workflow"; 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)); } ctx.restore(); }; // ── Mouse handler ─────────────────────────────────────────────── 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; if (event.type === "pointermove" || event.type === "mousemove") { const wasH = fb.hoverBtn, wasD = fb.hoverDelete; const wasT = fb.hoverTrash, wasU = fb.hoverUndo, wasC = fb.hoverConfirm; fb.hoverBtn = hit(fb.hitAreas.saveBtn); 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; } const isClick = event.type === "pointerdown" || 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; } if (hit(fb.hitAreas.rightArrow) && fb.selectedRun < fb.runCount) { 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; fb.collapsed[h.promptNum] = !(fb.collapsed[h.promptNum] !== false); this._fb_stopEditing(); requestAnimationFrame(() => { this.setSize([this.size[0], this.computeSize()[1]]); app.graph.setDirtyCanvas(true, true); }); return true; } // Stars for (const stars of (fb.hitAreas.stars || [])) { if (!stars) continue; for (const s of stars) { 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; } } // Notes → open textarea overlay for (const n of (fb.hitAreas.notes || [])) { if (!n || !hit(n)) continue; this._fb_startEditing(n.promptNum); this.setDirtyCanvas(true); 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(); return false; }; } // ── 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); }; } }, });