Files
2026-08-29 20:46:23 +02:00

124 lines
3.8 KiB
JavaScript

// APNext H3 - failed-run banner.
//
// When an H3 run dies (Ollama context overflow, dead backend, CLI missing,
// timeout), the Python side calls report_node_error() with the reason before
// raising. This draws that reason as a red, word-wrapped panel pinned under
// the failed node, so the WHY is readable on the graph itself instead of
// flashing by in a toast or hiding in the server console. The banner clears
// on the next queue.
//
// Generic on purpose: any node that sends "apnext.h3.node_error" with its
// UNIQUE_ID gets the banner - nothing here is writer-specific.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
const FONT = "12px ui-monospace, 'Cascadia Code', 'JetBrains Mono', Menlo, Consolas, monospace";
const TITLE_FONT = "bold 12px Inter, Arial, sans-serif";
const LINE_H = 16;
const PAD = 10;
const MAX_LINES = 14;
const MIN_W = 300;
function wrapText(ctx, text, maxWidth) {
const lines = [];
for (const raw of String(text).split("\n")) {
let line = "";
for (const word of raw.split(/\s+/)) {
const probe = line ? `${line} ${word}` : word;
if (ctx.measureText(probe).width <= maxWidth) {
line = probe;
continue;
}
if (line) lines.push(line);
// hard-break a single word wider than the panel (paths, model ids)
line = word;
while (ctx.measureText(line).width > maxWidth && line.length > 1) {
let cut = line.length - 1;
while (cut > 1 && ctx.measureText(line.slice(0, cut)).width > maxWidth) cut--;
lines.push(line.slice(0, cut));
line = line.slice(cut);
}
}
lines.push(line);
}
if (lines.length > MAX_LINES) {
lines.length = MAX_LINES;
lines[MAX_LINES - 1] += " …";
}
return lines;
}
function drawErrorPanel(node, ctx) {
const w = Math.max(node.size?.[0] ?? MIN_W, MIN_W);
const top = (node.flags?.collapsed ? 0 : node.size?.[1] ?? 0) + 6;
ctx.save();
ctx.font = FONT;
const lines = wrapText(ctx, node.__h3Error, w - PAD * 2);
const h = PAD * 2 + LINE_H * (lines.length + 1) + 4;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(0, top, w, h, 6);
else ctx.rect(0, top, w, h);
ctx.fillStyle = "rgba(60, 18, 18, 0.96)";
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#e5484d";
ctx.stroke();
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.font = TITLE_FONT;
ctx.fillStyle = "#ff8a8e";
ctx.fillText("⛔ Run failed — fix, then queue again", PAD, top + PAD + 10);
ctx.font = FONT;
ctx.fillStyle = "#f4d7d7";
let y = top + PAD + 10 + LINE_H + 4;
for (const line of lines) {
ctx.fillText(line, PAD, y);
y += LINE_H;
}
ctx.restore();
}
function hookDraw(node) {
if (node.__h3ErrorHooked) return;
node.__h3ErrorHooked = true;
const prev = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
const r = prev?.apply(this, arguments);
if (this.__h3Error) drawErrorPanel(this, ctx);
return r;
};
}
app.registerExtension({
name: "apnext.h3.nodeError",
setup() {
api.addEventListener("apnext.h3.node_error", (ev) => {
const { node: id, text } = ev.detail || {};
if (!id || !text) return;
const node =
app.graph?.getNodeById?.(Number(id)) ?? app.graph?.getNodeById?.(id);
if (!node) return;
hookDraw(node);
node.__h3Error = String(text);
node.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
});
// a new queue means the user is retrying: clear every banner
api.addEventListener("execution_start", () => {
let dirty = false;
for (const n of app.graph?._nodes || []) {
if (n?.__h3Error) {
n.__h3Error = null;
dirty = true;
}
}
if (dirty) app.canvas?.setDirty?.(true, true);
});
},
});