124 lines
3.8 KiB
JavaScript
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);
|
|
});
|
|
},
|
|
});
|