modulearn 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modulearn/__init__.py +16 -0
- modulearn/cli.py +88 -0
- modulearn/compiler.py +238 -0
- modulearn/demo.py +81 -0
- modulearn/registry.py +283 -0
- modulearn/server.py +195 -0
- modulearn/static/graph-engine.js +623 -0
- modulearn/static/graph.html +170 -0
- modulearn/static/graph.js +1003 -0
- modulearn-0.1.0.dist-info/METADATA +163 -0
- modulearn-0.1.0.dist-info/RECORD +14 -0
- modulearn-0.1.0.dist-info/WHEEL +4 -0
- modulearn-0.1.0.dist-info/entry_points.txt +2 -0
- modulearn-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/* ModuLearn — the visual ('Unreal Blueprints') front end for training runs.
|
|
2
|
+
*
|
|
3
|
+
* Flow:
|
|
4
|
+
* 1. Fetch the node catalog from GET /api/nodes (built from the host app's
|
|
5
|
+
* modulearn.Registry) and register one graph-engine node type per entry,
|
|
6
|
+
* translating each catalog Param into an editable node widget.
|
|
7
|
+
* 2. The user drops nodes from the left palette and wires them on the canvas.
|
|
8
|
+
* 3. On every edit we serialize the canvas to the compact graph JSON that
|
|
9
|
+
* modulearn/compiler.py expects and POST it to /api/graph/compile — a dry run
|
|
10
|
+
* that returns either the resolved config or the list of wiring errors, shown
|
|
11
|
+
* live in the corner panel.
|
|
12
|
+
* 4. "Train" POSTs the same graph to /api/graph/start, which compiles + launches.
|
|
13
|
+
*
|
|
14
|
+
* The catalog is authoritative: this file contains no hardcoded node list, so the
|
|
15
|
+
* palette can never drift from the Python it compiles to.
|
|
16
|
+
*/
|
|
17
|
+
(function () {
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
const graph = new LGraph();
|
|
21
|
+
const canvas = new LGraphCanvas("#graphcanvas", graph);
|
|
22
|
+
let CATALOG = null; // {port_types, categories, nodes:[spec,...]}
|
|
23
|
+
const SPEC_BY_TYPE = {}; // node type id -> catalog spec (for serialize)
|
|
24
|
+
const _meas = document.createElement("canvas").getContext("2d"); // offscreen text metrics
|
|
25
|
+
const history = []; // array of serialize() snapshots (JSON strings)
|
|
26
|
+
let histIdx = -1; // index of the currently-shown snapshot
|
|
27
|
+
let restoring = false; // true while reuild() replays a snapshot
|
|
28
|
+
let histTimer = null;
|
|
29
|
+
|
|
30
|
+
function captureHistory(){
|
|
31
|
+
if (restoring) return;
|
|
32
|
+
clearTimeout(histTimer);
|
|
33
|
+
histTimer = setTimeout(() => { // coalesce rapid edits (slider drags)
|
|
34
|
+
const snap = JSON.stringify(serialize());
|
|
35
|
+
if (snap === history[histIdx]) return; // no structural change
|
|
36
|
+
history.splice(histIdx + 1); // drop the redo tail
|
|
37
|
+
history.push(snap);
|
|
38
|
+
if (history.length > 100) history.shift();
|
|
39
|
+
histIdx = history.length - 1;
|
|
40
|
+
updateUndoButtons();
|
|
41
|
+
}, 350);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applySnapshot(snap){
|
|
45
|
+
restoring = true; // suppress capture from rebuild's node events
|
|
46
|
+
graph.clear();
|
|
47
|
+
rebuild(JSON.parse(snap));
|
|
48
|
+
restoring = false;
|
|
49
|
+
validate(); // revalidate WITHOUT recapturing (scheduleValidate would)
|
|
50
|
+
persist();
|
|
51
|
+
updateUndoButtons();
|
|
52
|
+
}
|
|
53
|
+
function undo() {
|
|
54
|
+
if (histIdx > 0){
|
|
55
|
+
histIdx--;
|
|
56
|
+
applySnapshot(history[histIdx]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function redo(){
|
|
60
|
+
if (histIdx < history.length - 1){
|
|
61
|
+
histIdx++;
|
|
62
|
+
applySnapshot(history[histIdx]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function updateUndoButtons(){
|
|
66
|
+
const u = document.getElementById("undoBtn"), r = document.getElementById("redoBtn");
|
|
67
|
+
if (u) u.disabled = histIdx <= 0;
|
|
68
|
+
if (r) r.disabled = histIdx >= history.length - 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- canvas sizing ---------------------------------------------------------
|
|
72
|
+
function resize() {
|
|
73
|
+
const c = document.getElementById("graphcanvas");
|
|
74
|
+
c.width = c.parentElement.clientWidth;
|
|
75
|
+
c.height = c.parentElement.clientHeight;
|
|
76
|
+
canvas.resize();
|
|
77
|
+
}
|
|
78
|
+
window.addEventListener("resize", resize);
|
|
79
|
+
|
|
80
|
+
// ---- animation engine ------------------------------------------------------
|
|
81
|
+
// The engine only repaints when "dirty"; we drive a rAF loop that forces a redraw
|
|
82
|
+
// while there's motion to show (nodes present or a run active), and paint all the
|
|
83
|
+
// ambient effects in canvas.onDrawForeground (graph space, after nodes).
|
|
84
|
+
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
85
|
+
const PORT_COLORS = { dataset: "#5aa9e6", model: "#c8663c", loss: "#c98bdb",
|
|
86
|
+
scalar: "#6fb98f", run: "#d8a24a" };
|
|
87
|
+
// Port types may be a family ("model") or a subtype ("scalar/lr"); color by base.
|
|
88
|
+
function linkColor(type) {
|
|
89
|
+
return PORT_COLORS[type] || PORT_COLORS[(type || "").split("/")[0]] || "#8b98a9";
|
|
90
|
+
}
|
|
91
|
+
const _pa = new Float32Array(2), _pb = new Float32Array(2);
|
|
92
|
+
|
|
93
|
+
function trainNode() { return graph._nodes.find((n) => n.type === "train.train"); }
|
|
94
|
+
|
|
95
|
+
// ---- helpers shared by the panel + navigation ------------------------------
|
|
96
|
+
function escapeHtml(s) {
|
|
97
|
+
return String(s).replace(/[&<>"']/g, (c) => (
|
|
98
|
+
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Compiler errors name the offending node(s) by their serialized id (e.g.
|
|
102
|
+
// "type mismatch on wire 3.model -> 5.dataset"). Find which live node ids a
|
|
103
|
+
// given message references so its line can deep-link back to the canvas.
|
|
104
|
+
function idsInError(err) {
|
|
105
|
+
const ids = [];
|
|
106
|
+
for (const n of graph._nodes) {
|
|
107
|
+
const id = String(n.id);
|
|
108
|
+
if (new RegExp("(?:^|[^0-9])" + id + "(?:[^0-9]|$)").test(err)) ids.push(id);
|
|
109
|
+
}
|
|
110
|
+
return ids;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Select a node and pan the view so it sits centre-canvas.
|
|
114
|
+
function focusNode(id) {
|
|
115
|
+
const n = graph.getNodeById(+id);
|
|
116
|
+
if (!n) return;
|
|
117
|
+
canvas.selectNode(n);
|
|
118
|
+
canvas.centerOnNode(n);
|
|
119
|
+
canvas.setDirty(true, true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Frame every node in view (or reset to 1:1 when the canvas is empty).
|
|
123
|
+
function zoomToFit() {
|
|
124
|
+
const ds = canvas.ds, nodes = graph._nodes;
|
|
125
|
+
if (!nodes.length) { ds.scale = 1; ds.offset[0] = 0; ds.offset[1] = 0;
|
|
126
|
+
canvas.setDirty(true, true); return; }
|
|
127
|
+
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
|
128
|
+
for (const n of nodes) {
|
|
129
|
+
minx = Math.min(minx, n.pos[0]);
|
|
130
|
+
miny = Math.min(miny, n.pos[1] - LiteGraph.NODE_TITLE_HEIGHT);
|
|
131
|
+
maxx = Math.max(maxx, n.pos[0] + n.size[0]);
|
|
132
|
+
maxy = Math.max(maxy, n.pos[1] + n.size[1]);
|
|
133
|
+
}
|
|
134
|
+
const pad = 60, cw = canvas.canvas.width, ch = canvas.canvas.height;
|
|
135
|
+
const scale = Math.max(0.15, Math.min(cw / (maxx - minx + pad * 2),
|
|
136
|
+
ch / (maxy - miny + pad * 2), 1.6));
|
|
137
|
+
ds.scale = scale; // screen = (graph + offset)*scale
|
|
138
|
+
ds.offset[0] = cw / (2 * scale) - (minx + maxx) / 2;
|
|
139
|
+
ds.offset[1] = ch / (2 * scale) - (miny + maxy) / 2;
|
|
140
|
+
canvas.setDirty(true, true);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- Train button state: invalid | ready | running ------------------------
|
|
144
|
+
let graphValid = false;
|
|
145
|
+
function setTrainState(s) {
|
|
146
|
+
const b = document.getElementById("trainBtn");
|
|
147
|
+
if (!b) return;
|
|
148
|
+
b.classList.toggle("running", s === "running");
|
|
149
|
+
b.disabled = s === "invalid" || s === "running";
|
|
150
|
+
b.lastChild.textContent = s === "running" ? " Training…" : " Train";
|
|
151
|
+
b.title = s === "invalid" ? "Wire a valid graph to enable launch"
|
|
152
|
+
: s === "running" ? "A run is in progress" : "Compile and launch this graph";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bezier(p0, p1, p2, p3, t) {
|
|
156
|
+
const u = 1 - t, uu = u * u, tt = t * t, uuu = uu * u, ttt = tt * t;
|
|
157
|
+
return [uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0],
|
|
158
|
+
uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1]];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Dots traveling along each wire in flow direction; faster on wires feeding a
|
|
162
|
+
// currently-running Train node.
|
|
163
|
+
function drawFlowingWires(ctx, now) {
|
|
164
|
+
const tn = trainNode(), running = tn && tn._running;
|
|
165
|
+
for (const id in graph.links) {
|
|
166
|
+
const l = graph.links[id];
|
|
167
|
+
if (!l) continue;
|
|
168
|
+
const src = graph.getNodeById(l.origin_id), dst = graph.getNodeById(l.target_id);
|
|
169
|
+
if (!src || !dst) continue;
|
|
170
|
+
src.getConnectionPos(false, l.origin_slot, _pa);
|
|
171
|
+
dst.getConnectionPos(true, l.target_slot, _pb);
|
|
172
|
+
const a = [_pa[0], _pa[1]], b = [_pb[0], _pb[1]];
|
|
173
|
+
const dist = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
174
|
+
const c1 = [a[0] + dist * 0.25, a[1]], c2 = [b[0] - dist * 0.25, b[1]];
|
|
175
|
+
const hot = running && dst === tn;
|
|
176
|
+
const color = linkColor(l.type);
|
|
177
|
+
const speed = hot ? 0.00085 : 0.00032, gap = 0.3;
|
|
178
|
+
const phase = ((now * speed) % gap + gap) % gap;
|
|
179
|
+
for (let t = phase; t < 1; t += gap) {
|
|
180
|
+
const p = bezier(a, c1, c2, b, t);
|
|
181
|
+
ctx.beginPath();
|
|
182
|
+
ctx.arc(p[0], p[1], hot ? 3.4 : 2.4, 0, Math.PI * 2);
|
|
183
|
+
ctx.fillStyle = color;
|
|
184
|
+
ctx.globalAlpha = hot ? 0.95 : 0.8;
|
|
185
|
+
ctx.fill();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
ctx.globalAlpha = 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A brief expanding ring when a node is dropped from the palette.
|
|
192
|
+
function drawSpawnRings(ctx, now) {
|
|
193
|
+
for (const n of graph._nodes) {
|
|
194
|
+
if (!n._spawn) continue;
|
|
195
|
+
const age = now - n._spawn, life = 450;
|
|
196
|
+
if (age > life) { n._spawn = 0; continue; }
|
|
197
|
+
const k = age / life;
|
|
198
|
+
const cx = n.pos[0] + n.size[0] / 2, cy = n.pos[1] + n.size[1] / 2;
|
|
199
|
+
ctx.beginPath();
|
|
200
|
+
ctx.arc(cx, cy, 12 + k * Math.max(n.size[0], n.size[1]) * 0.7, 0, Math.PI * 2);
|
|
201
|
+
ctx.strokeStyle = "rgba(200,102,60," + (1 - k) * 0.5 + ")";
|
|
202
|
+
ctx.lineWidth = 2; ctx.stroke();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Glow ports of the hovered node, and — while dragging a wire — pulse every
|
|
207
|
+
// input port whose type matches the output being dragged.
|
|
208
|
+
function drawPortCues(ctx, now) {
|
|
209
|
+
const pulse = 0.5 + 0.5 * Math.sin(now * 0.006);
|
|
210
|
+
const ring = (x, y, r, color, alpha) => {
|
|
211
|
+
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
212
|
+
ctx.strokeStyle = color; ctx.globalAlpha = alpha; ctx.lineWidth = 2; ctx.stroke();
|
|
213
|
+
ctx.globalAlpha = 1;
|
|
214
|
+
};
|
|
215
|
+
const over = canvas.node_over;
|
|
216
|
+
if (over) {
|
|
217
|
+
(over.outputs || []).forEach((o, i) => {
|
|
218
|
+
over.getConnectionPos(false, i, _pa);
|
|
219
|
+
ring(_pa[0], _pa[1], 6 + pulse * 2, linkColor(o.type), 0.5);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const co = canvas.connecting_output;
|
|
223
|
+
if (co) {
|
|
224
|
+
for (const n of graph._nodes) {
|
|
225
|
+
(n.inputs || []).forEach((inp, i) => {
|
|
226
|
+
if (inp.type !== co.type) return;
|
|
227
|
+
n.getConnectionPos(true, i, _pb);
|
|
228
|
+
ring(_pb[0], _pb[1], 6 + pulse * 4, linkColor(inp.type), 0.35 + pulse * 0.45);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Train node liveness: a pulsing colored border (accent while running, green on
|
|
235
|
+
// done, red on error) and a bottom progress bar tracking epoch completion.
|
|
236
|
+
function drawTrainStatus(node, ctx) {
|
|
237
|
+
const w = node.size[0], h = node.size[1];
|
|
238
|
+
const color = node._phase === "error" ? "#f85149"
|
|
239
|
+
: node._phase === "done" ? "#3fb950" : "#c8663c";
|
|
240
|
+
const running = node._phase === "running";
|
|
241
|
+
const pulse = 0.5 + 0.5 * Math.sin(performance.now() * 0.005);
|
|
242
|
+
ctx.save();
|
|
243
|
+
ctx.strokeStyle = color;
|
|
244
|
+
ctx.globalAlpha = running ? 0.35 + pulse * 0.5 : 0.85;
|
|
245
|
+
ctx.lineWidth = 2;
|
|
246
|
+
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(1, 1, w - 2, h - 2, 7); ctx.stroke(); }
|
|
247
|
+
else ctx.strokeRect(1, 1, w - 2, h - 2);
|
|
248
|
+
const p = Math.max(0, Math.min(1, node._progress || 0));
|
|
249
|
+
ctx.globalAlpha = 1;
|
|
250
|
+
ctx.fillStyle = "rgba(255,255,255,.08)";
|
|
251
|
+
ctx.fillRect(8, h - 9, w - 16, 4);
|
|
252
|
+
ctx.fillStyle = color;
|
|
253
|
+
ctx.fillRect(8, h - 9, (w - 16) * p, 4);
|
|
254
|
+
ctx.restore();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ambientDraw(ctx) {
|
|
258
|
+
if (reduceMotion) return;
|
|
259
|
+
const now = performance.now();
|
|
260
|
+
drawFlowingWires(ctx, now);
|
|
261
|
+
drawSpawnRings(ctx, now);
|
|
262
|
+
drawPortCues(ctx, now);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function animLoop() {
|
|
266
|
+
// Repaint only when there is something to animate — idle canvas stays still.
|
|
267
|
+
if (!reduceMotion && (graph._nodes.length || pollTimer)) canvas.draw(true, true);
|
|
268
|
+
requestAnimationFrame(animLoop);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- widget factory: one catalog Param -> one node widget ------------------
|
|
272
|
+
// Keeps node.properties[name] in sync so serialization can read a flat param map.
|
|
273
|
+
// int_list is stored/edited as "128,64" text and parsed to an array at serialize.
|
|
274
|
+
function addWidget(node, p) {
|
|
275
|
+
node.properties[p.name] = p.default;
|
|
276
|
+
// Update the param and re-validate. The engine invokes this with (value, ...),
|
|
277
|
+
// and there is no canvas-level widget hook, so validation must originate here.
|
|
278
|
+
const set = (v) => { node.properties[p.name] = v; scheduleValidate(); };
|
|
279
|
+
|
|
280
|
+
if (p.kind === "float" || p.kind === "int") {
|
|
281
|
+
const opts = { step: p.kind === "int" ? 1 : 0.1,
|
|
282
|
+
precision: p.kind === "int" ? 0 : 5 };
|
|
283
|
+
if (p.min != null) opts.min = p.min;
|
|
284
|
+
if (p.max != null) opts.max = p.max;
|
|
285
|
+
node.addWidget("number", p.label, p.default, set, opts);
|
|
286
|
+
} else if (p.kind === "enum") {
|
|
287
|
+
node.addWidget("combo", p.label, p.default, set, { values: p.choices });
|
|
288
|
+
} else if (p.kind === "bool") {
|
|
289
|
+
node.addWidget("toggle", p.label, !!p.default, set);
|
|
290
|
+
} else if (p.kind === "int_list") {
|
|
291
|
+
const text = (p.default || []).join(",");
|
|
292
|
+
node.properties[p.name] = text;
|
|
293
|
+
node.addWidget("text", p.label, text, set);
|
|
294
|
+
} else {
|
|
295
|
+
node.addWidget("text", p.label, String(p.default ?? ""), set);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Grow a node so its title, widget labels and descriptor lines all fit — the
|
|
300
|
+
// engine's computeSize() uses a fixed base width, so without this long strings
|
|
301
|
+
// render outside the node box. `spec` is passed explicitly because node.type is
|
|
302
|
+
// set by createNode before the constructor body runs but we keep it explicit.
|
|
303
|
+
function fitNode(node, spec) {
|
|
304
|
+
node.size = node.computeSize();
|
|
305
|
+
_meas.font = "14px Arial";
|
|
306
|
+
let w = _meas.measureText(node.title || "").width;
|
|
307
|
+
(node.widgets || []).forEach((wd) => {
|
|
308
|
+
w = Math.max(w, _meas.measureText(`${wd.name}: ${wd.value}`).width);
|
|
309
|
+
});
|
|
310
|
+
(node._descriptors || []).forEach((d) => {
|
|
311
|
+
w = Math.max(w, _meas.measureText(`${d.label}: ${d.value}`).width);
|
|
312
|
+
});
|
|
313
|
+
node.size[0] = Math.max(node.size[0], Math.ceil(w) + 46);
|
|
314
|
+
if (node._descriptors && node._descriptors.length) {
|
|
315
|
+
node.size[1] += node._descriptors.length * 15 + 4; // room for the subtitle
|
|
316
|
+
}
|
|
317
|
+
const isTrain = (spec && spec.id === "train.train") || node.type === "train.train";
|
|
318
|
+
if (isTrain) {
|
|
319
|
+
node.size[1] += 18; // a dedicated lane below the slots for the progress bar
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- register every catalog node as a graph-engine type --------------------
|
|
324
|
+
function registerNodes() {
|
|
325
|
+
for (const spec of CATALOG.nodes) {
|
|
326
|
+
SPEC_BY_TYPE[spec.id] = spec;
|
|
327
|
+
const Node = function () {
|
|
328
|
+
spec.inputs.forEach((pt) => this.addInput(pt.name, pt.type));
|
|
329
|
+
spec.outputs.forEach((pt) => this.addOutput(pt.name, pt.type));
|
|
330
|
+
this.properties = {};
|
|
331
|
+
this._descriptors = []; // readonly values drawn as a compact subtitle
|
|
332
|
+
spec.params.forEach((p) => {
|
|
333
|
+
if (p.readonly) {
|
|
334
|
+
this.properties[p.name] = p.default;
|
|
335
|
+
this._descriptors.push({ label: p.label, value: p.default });
|
|
336
|
+
} else {
|
|
337
|
+
addWidget(this, p);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
fitNode(this, spec);
|
|
341
|
+
};
|
|
342
|
+
Node.title = spec.title_short || spec.title;
|
|
343
|
+
Node.desc = spec.help;
|
|
344
|
+
// Draw readonly descriptors as small muted lines, plus the Train node's live
|
|
345
|
+
// status (glowing border + epoch progress bar) while a run is attached.
|
|
346
|
+
Node.prototype.onDrawForeground = function (ctx) {
|
|
347
|
+
if (this.flags.collapsed) return;
|
|
348
|
+
if (this._descriptors && this._descriptors.length) {
|
|
349
|
+
ctx.save();
|
|
350
|
+
ctx.font = "11px Arial";
|
|
351
|
+
ctx.fillStyle = "#8b98a9";
|
|
352
|
+
let y = this.size[1] - this._descriptors.length * 15 + 3;
|
|
353
|
+
for (const d of this._descriptors) {
|
|
354
|
+
ctx.fillText(`${d.label}: ${d.value}`, 12, y);
|
|
355
|
+
y += 15;
|
|
356
|
+
}
|
|
357
|
+
ctx.restore();
|
|
358
|
+
}
|
|
359
|
+
if (this.type === "train.train" && this._phase) drawTrainStatus(this, ctx);
|
|
360
|
+
};
|
|
361
|
+
LiteGraph.registerNodeType(spec.id, Node);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---- left palette: searchable dropdowns, grouped by category ----------------
|
|
366
|
+
// Each category is a collapsible block (a dropdown). Entries carry a lowercase
|
|
367
|
+
// haystack (title + id + help) for the filter. Visibility is a single pass over
|
|
368
|
+
// both the current query and each block's collapsed state.
|
|
369
|
+
let paletteBlocks = []; // [{ block, items:[{el, hay}], collapsed }]
|
|
370
|
+
let paletteQueryStr = "";
|
|
371
|
+
|
|
372
|
+
function buildPalette() {
|
|
373
|
+
const list = document.getElementById("paletteList");
|
|
374
|
+
const empty = document.getElementById("paletteEmpty");
|
|
375
|
+
paletteBlocks = [];
|
|
376
|
+
const byCat = {};
|
|
377
|
+
CATALOG.nodes.forEach((n) => (byCat[n.category] ||= []).push(n));
|
|
378
|
+
CATALOG.categories.forEach((cat) => {
|
|
379
|
+
if (!byCat[cat]) return;
|
|
380
|
+
const block = document.createElement("div");
|
|
381
|
+
block.className = "catblock collapsed"; // dropdowns start closed
|
|
382
|
+
const h = document.createElement("div");
|
|
383
|
+
h.className = "cat";
|
|
384
|
+
h.innerHTML = `<span class="caret">▶</span><span>${cat}</span>` +
|
|
385
|
+
`<span class="catcount">${byCat[cat].length}</span>`;
|
|
386
|
+
const entry = { block, items: [], collapsed: true };
|
|
387
|
+
h.onclick = () => { entry.collapsed = !entry.collapsed; applyPalette(); };
|
|
388
|
+
block.appendChild(h);
|
|
389
|
+
byCat[cat].forEach((spec) => {
|
|
390
|
+
const item = document.createElement("div");
|
|
391
|
+
item.className = "item" + (spec.experimental ? " exp" : "");
|
|
392
|
+
item.title = spec.help;
|
|
393
|
+
item.innerHTML = `<span>${spec.title}</span>` +
|
|
394
|
+
(spec.experimental ? `<span class="tag">soon</span>` : "");
|
|
395
|
+
item.onclick = () => addNode(spec.id);
|
|
396
|
+
block.appendChild(item);
|
|
397
|
+
entry.items.push({ el: item, hay: `${spec.title} ${spec.id} ${spec.help || ""}`.toLowerCase() });
|
|
398
|
+
});
|
|
399
|
+
list.appendChild(block);
|
|
400
|
+
paletteBlocks.push(entry);
|
|
401
|
+
});
|
|
402
|
+
list.appendChild(empty); // keep the empty-state message last
|
|
403
|
+
applyPalette();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Single visibility pass. An item shows when it matches the query AND its block is
|
|
407
|
+
// open; an active search force-opens any block that has matches so results appear.
|
|
408
|
+
function applyPalette() {
|
|
409
|
+
const q = paletteQueryStr;
|
|
410
|
+
let total = 0;
|
|
411
|
+
for (const b of paletteBlocks) {
|
|
412
|
+
const open = !b.collapsed || !!q;
|
|
413
|
+
let shown = 0;
|
|
414
|
+
for (const { el, hay } of b.items) {
|
|
415
|
+
const match = !q || hay.includes(q);
|
|
416
|
+
el.style.display = (match && open) ? "" : "none";
|
|
417
|
+
if (match) shown++;
|
|
418
|
+
}
|
|
419
|
+
b.block.style.display = shown ? "" : "none";
|
|
420
|
+
b.block.classList.toggle("collapsed", b.collapsed && !q);
|
|
421
|
+
total += shown;
|
|
422
|
+
}
|
|
423
|
+
document.getElementById("paletteEmpty").style.display = total ? "none" : "block";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function filterPalette(q) { paletteQueryStr = (q || "").trim().toLowerCase(); applyPalette(); }
|
|
427
|
+
|
|
428
|
+
function firstVisibleItem() {
|
|
429
|
+
for (const { items } of paletteBlocks) {
|
|
430
|
+
for (const { el } of items) if (el.style.display !== "none") return el;
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function focusPaletteSearch() {
|
|
436
|
+
const q = document.getElementById("paletteQuery");
|
|
437
|
+
q.focus(); q.select();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Wire the search box + global hotkeys (⌘/Ctrl+K, or "/" when not already typing).
|
|
441
|
+
function initPaletteSearch() {
|
|
442
|
+
const q = document.getElementById("paletteQuery");
|
|
443
|
+
q.addEventListener("input", () => filterPalette(q.value));
|
|
444
|
+
q.addEventListener("keydown", (e) => {
|
|
445
|
+
if (e.key === "Escape") { q.value = ""; filterPalette(""); q.blur(); }
|
|
446
|
+
else if (e.key === "Enter") { const it = firstVisibleItem(); if (it) it.click(); }
|
|
447
|
+
});
|
|
448
|
+
window.addEventListener("keydown", (e) => {
|
|
449
|
+
const typing = /^(input|textarea|select)$/i.test(e.target.tagName || "");
|
|
450
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
|
|
451
|
+
e.preventDefault();
|
|
452
|
+
e.shiftKey ? redo() : undo();
|
|
453
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
454
|
+
e.preventDefault(); focusPaletteSearch();
|
|
455
|
+
} else if (e.key === "/" && !typing) {
|
|
456
|
+
e.preventDefault(); focusPaletteSearch();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- palette: collapse toggle + drag-to-resize -----------------------------
|
|
462
|
+
// Width persists across reloads; on narrow screens the palette is an overlay
|
|
463
|
+
// drawer (see the CSS media query) and starts hidden.
|
|
464
|
+
const isNarrow = () => window.matchMedia("(max-width:720px)").matches;
|
|
465
|
+
function initPaletteResize() {
|
|
466
|
+
const app = document.getElementById("app");
|
|
467
|
+
const rez = document.getElementById("resizer");
|
|
468
|
+
const toggle = document.getElementById("paletteToggle");
|
|
469
|
+
const root = document.documentElement;
|
|
470
|
+
|
|
471
|
+
const savedW = parseInt(localStorage.getItem("palette.w"), 10);
|
|
472
|
+
if (savedW >= 160 && savedW <= 480) root.style.setProperty("--palette-w", savedW + "px");
|
|
473
|
+
if (isNarrow() || localStorage.getItem("palette.hidden") === "1")
|
|
474
|
+
app.classList.add("palette-hidden");
|
|
475
|
+
|
|
476
|
+
toggle.onclick = () => {
|
|
477
|
+
const hidden = app.classList.toggle("palette-hidden");
|
|
478
|
+
if (!isNarrow()) localStorage.setItem("palette.hidden", hidden ? "1" : "0");
|
|
479
|
+
resize();
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
let dragging = false;
|
|
483
|
+
rez.addEventListener("mousedown", (e) => {
|
|
484
|
+
dragging = true; e.preventDefault();
|
|
485
|
+
rez.classList.add("dragging"); document.body.style.cursor = "col-resize";
|
|
486
|
+
});
|
|
487
|
+
window.addEventListener("mousemove", (e) => {
|
|
488
|
+
if (!dragging) return;
|
|
489
|
+
root.style.setProperty("--palette-w", Math.max(160, Math.min(480, e.clientX)) + "px");
|
|
490
|
+
resize();
|
|
491
|
+
});
|
|
492
|
+
window.addEventListener("mouseup", () => {
|
|
493
|
+
if (!dragging) return;
|
|
494
|
+
dragging = false; rez.classList.remove("dragging"); document.body.style.cursor = "";
|
|
495
|
+
const w = parseInt(getComputedStyle(root).getPropertyValue("--palette-w"), 10);
|
|
496
|
+
if (w) localStorage.setItem("palette.w", w);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---- validation/training panel: drag by its title bar, resize by the grip ---
|
|
501
|
+
// Position + size persist across reloads. On first interaction we switch the
|
|
502
|
+
// panel from its bottom-right CSS anchor to explicit top/left px so both the
|
|
503
|
+
// drag and the native resize grip (bottom-right corner) behave predictably.
|
|
504
|
+
function initOutPanel() {
|
|
505
|
+
const out = document.getElementById("out");
|
|
506
|
+
const handle = document.getElementById("outTitle");
|
|
507
|
+
const KEY = "out.box";
|
|
508
|
+
|
|
509
|
+
const anchorTopLeft = () => {
|
|
510
|
+
const r = out.getBoundingClientRect();
|
|
511
|
+
out.style.left = r.left + "px"; out.style.top = r.top + "px";
|
|
512
|
+
out.style.right = "auto"; out.style.bottom = "auto";
|
|
513
|
+
};
|
|
514
|
+
const save = () => {
|
|
515
|
+
const r = out.getBoundingClientRect();
|
|
516
|
+
try {
|
|
517
|
+
localStorage.setItem(KEY, JSON.stringify({
|
|
518
|
+
left: Math.round(r.left), top: Math.round(r.top),
|
|
519
|
+
w: out.style.width ? Math.round(r.width) : null,
|
|
520
|
+
h: out.style.height ? Math.round(r.height) : null }));
|
|
521
|
+
} catch (e) { /* quota/private mode */ }
|
|
522
|
+
};
|
|
523
|
+
// Keep the panel (at least its title bar) on-screen after a drag or a
|
|
524
|
+
// window resize, so it can never be lost off the edge.
|
|
525
|
+
const clampIntoView = () => {
|
|
526
|
+
const r = out.getBoundingClientRect();
|
|
527
|
+
const left = Math.max(4, Math.min(r.left, window.innerWidth - r.width - 4));
|
|
528
|
+
const top = Math.max(48, Math.min(r.top, window.innerHeight - 40));
|
|
529
|
+
out.style.left = left + "px"; out.style.top = top + "px";
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// Anchor immediately so the resize grip works from the first drag, keeping
|
|
533
|
+
// the panel visually where the CSS placed it.
|
|
534
|
+
anchorTopLeft();
|
|
535
|
+
try {
|
|
536
|
+
const s = JSON.parse(localStorage.getItem(KEY) || "null");
|
|
537
|
+
if (s) {
|
|
538
|
+
out.style.left = s.left + "px"; out.style.top = s.top + "px";
|
|
539
|
+
if (s.w) out.style.width = s.w + "px";
|
|
540
|
+
if (s.h) out.style.height = s.h + "px";
|
|
541
|
+
clampIntoView();
|
|
542
|
+
}
|
|
543
|
+
} catch (e) { /* ignore malformed geometry */ }
|
|
544
|
+
|
|
545
|
+
// Drag by the title bar.
|
|
546
|
+
let ox = 0, oy = 0, dragging = false;
|
|
547
|
+
handle.addEventListener("mousedown", (e) => {
|
|
548
|
+
dragging = true;
|
|
549
|
+
const r = out.getBoundingClientRect();
|
|
550
|
+
ox = e.clientX - r.left; oy = e.clientY - r.top;
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
});
|
|
553
|
+
window.addEventListener("mousemove", (e) => {
|
|
554
|
+
if (!dragging) return;
|
|
555
|
+
out.style.left = (e.clientX - ox) + "px";
|
|
556
|
+
out.style.top = (e.clientY - oy) + "px";
|
|
557
|
+
});
|
|
558
|
+
window.addEventListener("mouseup", () => {
|
|
559
|
+
if (!dragging) return;
|
|
560
|
+
dragging = false; clampIntoView(); save();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Detect a native resize (grip drag anywhere but the title bar) and persist it.
|
|
564
|
+
out.addEventListener("mousedown", (e) => {
|
|
565
|
+
if (handle.contains(e.target)) return; // that's a move, handled above
|
|
566
|
+
const w0 = out.offsetWidth, h0 = out.offsetHeight;
|
|
567
|
+
const up = () => {
|
|
568
|
+
document.removeEventListener("mouseup", up);
|
|
569
|
+
if (out.offsetWidth !== w0 || out.offsetHeight !== h0) {
|
|
570
|
+
out.style.width = out.offsetWidth + "px";
|
|
571
|
+
out.style.height = out.offsetHeight + "px";
|
|
572
|
+
save();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
document.addEventListener("mouseup", up);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
window.addEventListener("resize", clampIntoView);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Drop a node near the current view centre, then re-validate.
|
|
582
|
+
function addNode(typeId) {
|
|
583
|
+
const node = LiteGraph.createNode(typeId);
|
|
584
|
+
const [cx, cy] = canvas.convertOffsetToCanvas
|
|
585
|
+
? canvas.convertOffsetToCanvas([canvas.canvas.width / 2, canvas.canvas.height / 2])
|
|
586
|
+
: [200, 200];
|
|
587
|
+
node.pos = [cx + (Math.random() * 60 - 30), cy + (Math.random() * 60 - 30)];
|
|
588
|
+
node._spawn = performance.now(); // triggers the drop-in ring
|
|
589
|
+
graph.add(node);
|
|
590
|
+
scheduleValidate();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ---- serialize the canvas to app/graph.py's graph JSON ---------------------
|
|
594
|
+
function serialize() {
|
|
595
|
+
const nodes = graph._nodes.map((n) => ({
|
|
596
|
+
id: String(n.id),
|
|
597
|
+
type: n.type,
|
|
598
|
+
params: collectParams(n),
|
|
599
|
+
pos: [Math.round(n.pos[0]), Math.round(n.pos[1])],
|
|
600
|
+
}));
|
|
601
|
+
const links = [];
|
|
602
|
+
for (const id in graph.links) {
|
|
603
|
+
const l = graph.links[id];
|
|
604
|
+
if (!l) continue;
|
|
605
|
+
const src = graph.getNodeById(l.origin_id);
|
|
606
|
+
const dst = graph.getNodeById(l.target_id);
|
|
607
|
+
if (!src || !dst) continue;
|
|
608
|
+
links.push({
|
|
609
|
+
from: String(l.origin_id), from_port: src.outputs[l.origin_slot].name,
|
|
610
|
+
to: String(l.target_id), to_port: dst.inputs[l.target_slot].name,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return { nodes, links };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// A node's params as a flat map, parsing int_list text ("128,64") back to [128,64].
|
|
617
|
+
function collectParams(node) {
|
|
618
|
+
const spec = SPEC_BY_TYPE[node.type];
|
|
619
|
+
const out = {};
|
|
620
|
+
(spec.params || []).forEach((p) => {
|
|
621
|
+
let v = node.properties[p.name];
|
|
622
|
+
if (p.kind === "int_list") {
|
|
623
|
+
v = String(v).split(",").map((s) => parseInt(s.trim(), 10)).filter((x) => !isNaN(x));
|
|
624
|
+
}
|
|
625
|
+
out[p.name] = v;
|
|
626
|
+
});
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---- canvas persistence ----------------------------------------------------
|
|
631
|
+
// The canvas lives only in memory, so navigating away would lose it. Mirror it
|
|
632
|
+
// to localStorage on every edit and restore it on boot.
|
|
633
|
+
const STORE_KEY = "blueprint.graph";
|
|
634
|
+
function persist() {
|
|
635
|
+
try { localStorage.setItem(STORE_KEY, JSON.stringify(serialize())); } catch (e) { /* quota/private mode */ }
|
|
636
|
+
}
|
|
637
|
+
function restore() {
|
|
638
|
+
try {
|
|
639
|
+
const raw = localStorage.getItem(STORE_KEY);
|
|
640
|
+
if (!raw) return false;
|
|
641
|
+
const g = JSON.parse(raw);
|
|
642
|
+
if (!g.nodes || !g.nodes.length) return false;
|
|
643
|
+
rebuild(g);
|
|
644
|
+
return true;
|
|
645
|
+
} catch (e) { return false; }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// export blueprint button
|
|
649
|
+
function exportBlueprint() {
|
|
650
|
+
const doc = {
|
|
651
|
+
format: "modulearn.blueprint", version: 1,
|
|
652
|
+
graph: serialize(),
|
|
653
|
+
};
|
|
654
|
+
const blob = new Blob([JSON.stringify(doc, null, 2)],
|
|
655
|
+
{ type: "application/json" });
|
|
656
|
+
const a = document.createElement("a");
|
|
657
|
+
a.href = URL.createObjectURL(blob);
|
|
658
|
+
a.download = "modulearn-blueprint.json";
|
|
659
|
+
a.click();
|
|
660
|
+
URL.revokeObjectURL(a.href);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Load a blueprint file back onto the canvas (inverse of exportBlueprint).
|
|
664
|
+
// Accepts either our {format, version, graph} envelope or a bare graph JSON.
|
|
665
|
+
// Node types this registry doesn't know are dropped and reported, never
|
|
666
|
+
// silently lost — the file may come from a differently-configured host.
|
|
667
|
+
function importBlueprint(file) {
|
|
668
|
+
const reader = new FileReader();
|
|
669
|
+
reader.onload = () => {
|
|
670
|
+
let doc;
|
|
671
|
+
try { doc = JSON.parse(reader.result); }
|
|
672
|
+
catch (e) { setPanel("err", "not valid JSON"); return; }
|
|
673
|
+
const g = doc.graph || doc;
|
|
674
|
+
if (!g || !Array.isArray(g.nodes)) { setPanel("err", "no nodes in file"); return; }
|
|
675
|
+
const unknown = [...new Set(g.nodes.filter((n) => !SPEC_BY_TYPE[n.type])
|
|
676
|
+
.map((n) => n.type))];
|
|
677
|
+
g.nodes = g.nodes.filter((n) => SPEC_BY_TYPE[n.type]);
|
|
678
|
+
graph.clear();
|
|
679
|
+
rebuild(g);
|
|
680
|
+
// Surface skipped nodes on the next validate() so the note isn't clobbered
|
|
681
|
+
// by the compile result that scheduleValidate() paints ~250ms later.
|
|
682
|
+
importWarning = unknown.length ? "skipped unknown nodes: " + unknown.join(", ") : "";
|
|
683
|
+
scheduleValidate();
|
|
684
|
+
};
|
|
685
|
+
reader.onerror = () => setPanel("err", "could not read file");
|
|
686
|
+
reader.readAsText(file);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---- live validation via the dry-run compile endpoint ----------------------
|
|
690
|
+
let validateTimer = null;
|
|
691
|
+
let importWarning = ""; // one-shot notice (e.g. skipped nodes) for next validate()
|
|
692
|
+
function scheduleValidate() {
|
|
693
|
+
stopPolling(); // any edit means we're no longer just watching a run
|
|
694
|
+
clearTrainStatus(); // and the finished-run glow no longer applies
|
|
695
|
+
persist();
|
|
696
|
+
captureHistory(); // snapshot this edit for undo/redo
|
|
697
|
+
clearTimeout(validateTimer);
|
|
698
|
+
validateTimer = setTimeout(validate, 250);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function validate() {
|
|
702
|
+
// A pending import note rides along with this validation, then clears.
|
|
703
|
+
const banner = importWarning
|
|
704
|
+
? `<span class="err">⚠ ${escapeHtml(importWarning)}</span><br>` : "";
|
|
705
|
+
importWarning = "";
|
|
706
|
+
const graphJson = serialize();
|
|
707
|
+
if (!graphJson.nodes.length) { graphValid = false; setTrainState("invalid");
|
|
708
|
+
setPanel("k", banner + "Empty graph."); return; }
|
|
709
|
+
try {
|
|
710
|
+
const r = await fetch("/api/graph/compile", {
|
|
711
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
712
|
+
body: JSON.stringify(graphJson),
|
|
713
|
+
});
|
|
714
|
+
const j = await r.json();
|
|
715
|
+
if (r.ok) {
|
|
716
|
+
graphValid = true; setTrainState("ready");
|
|
717
|
+
const cfg = j.config;
|
|
718
|
+
setPanel("ok", banner + "✓ valid — resolved RunConfig:<br>" +
|
|
719
|
+
Object.entries(cfg).map(([k, v]) =>
|
|
720
|
+
`<span class="k">${k}</span> ${escapeHtml(JSON.stringify(v))}`).join("<br>"));
|
|
721
|
+
} else {
|
|
722
|
+
graphValid = false; setTrainState("invalid");
|
|
723
|
+
const errs = (j.detail && j.detail.errors) || [j.detail || "unknown error"];
|
|
724
|
+
// Each line deep-links to its node when the message names one.
|
|
725
|
+
setPanel("err", banner + "✗ " + errs.map((e) => {
|
|
726
|
+
const ids = idsInError(e), safe = escapeHtml(e);
|
|
727
|
+
return ids.length
|
|
728
|
+
? `<span class="errline" data-node="${ids[0]}">• ${safe}</span>`
|
|
729
|
+
: `• ${safe}`;
|
|
730
|
+
}).join("<br>"));
|
|
731
|
+
}
|
|
732
|
+
} catch (e) {
|
|
733
|
+
setPanel("err", "request failed: " + escapeHtml(e));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Re-validate whenever the graph structure changes. These three are real LGraph
|
|
738
|
+
// callbacks fired by graph-engine.js; widget-value edits
|
|
739
|
+
// re-validate via each widget's set() callback above.
|
|
740
|
+
graph.onNodeConnectionChange = scheduleValidate;
|
|
741
|
+
graph.onNodeAdded = scheduleValidate;
|
|
742
|
+
graph.onNodeRemoved = scheduleValidate;
|
|
743
|
+
|
|
744
|
+
// ---- live run tracking -----------------------------------------------------
|
|
745
|
+
// After launching, poll the run's state into the panel so training is visible
|
|
746
|
+
// right here on the canvas — no trip to the classic dashboard needed.
|
|
747
|
+
let pollTimer = null;
|
|
748
|
+
function stopPolling() {
|
|
749
|
+
clearTimeout(pollTimer); pollTimer = null;
|
|
750
|
+
const dot = document.querySelector("#bar .dot");
|
|
751
|
+
if (dot) dot.classList.remove("live");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Clear a finished run's glow from the Train node (called when the graph is edited).
|
|
755
|
+
function clearTrainStatus() {
|
|
756
|
+
const tn = trainNode();
|
|
757
|
+
if (tn) { tn._phase = null; tn._progress = 0; tn._running = false; }
|
|
758
|
+
const dot = document.querySelector("#bar .dot");
|
|
759
|
+
if (dot) dot.classList.remove("live");
|
|
760
|
+
hideChart();
|
|
761
|
+
document.getElementById("outTitle").textContent = "VALIDATION";
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Write the validation/status panel, cross-fading only when the state changes.
|
|
765
|
+
function setPanel(cls, html) {
|
|
766
|
+
const body = document.getElementById("outBody");
|
|
767
|
+
body.className = cls; // resets (drops any .flash)
|
|
768
|
+
if (!reduceMotion && body._lastClass !== cls) {
|
|
769
|
+
void body.offsetWidth; body.classList.add("flash");
|
|
770
|
+
}
|
|
771
|
+
body._lastClass = cls;
|
|
772
|
+
body.innerHTML = html;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function fmt(v, unit) { return v == null ? "—" : (+v).toFixed(3) + (unit || ""); }
|
|
776
|
+
|
|
777
|
+
// Live learning curve (train vs val loss) drawn from the run's per-epoch metrics.
|
|
778
|
+
// Redrawn each poll so you watch the model learn on the canvas itself. Keys match
|
|
779
|
+
// the metric rows the host writes via reporter.metric(epoch=, train=, val=).
|
|
780
|
+
const CHART_SERIES = [
|
|
781
|
+
{ key: "val", color: "#c8663c", label: "val" },
|
|
782
|
+
{ key: "train", color: "#5aa9e6", label: "train" },
|
|
783
|
+
];
|
|
784
|
+
function drawChart(metrics) {
|
|
785
|
+
const cv = document.getElementById("chart");
|
|
786
|
+
const pts = (metrics || []).filter((m) => typeof m.epoch === "number");
|
|
787
|
+
if (pts.length < 2) { cv.style.display = "none"; return; }
|
|
788
|
+
cv.style.display = "block";
|
|
789
|
+
const dpr = window.devicePixelRatio || 1;
|
|
790
|
+
const W = cv.clientWidth || 330, H = 120;
|
|
791
|
+
cv.width = W * dpr; cv.height = H * dpr;
|
|
792
|
+
const ctx = cv.getContext("2d");
|
|
793
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
794
|
+
ctx.clearRect(0, 0, W, H);
|
|
795
|
+
const pad = { l: 34, r: 8, t: 8, b: 18 };
|
|
796
|
+
|
|
797
|
+
const ys = [];
|
|
798
|
+
CHART_SERIES.forEach((s) => pts.forEach((m) => {
|
|
799
|
+
const v = m[s.key]; if (typeof v === "number" && isFinite(v)) ys.push(v);
|
|
800
|
+
}));
|
|
801
|
+
if (!ys.length) { cv.style.display = "none"; return; }
|
|
802
|
+
let ymin = Math.min(...ys), ymax = Math.max(...ys);
|
|
803
|
+
if (ymin === ymax) ymax = ymin + 1;
|
|
804
|
+
const yr = ymax - ymin; ymin -= yr * 0.08; ymax += yr * 0.08;
|
|
805
|
+
const xmin = pts[0].epoch, xmax = pts[pts.length - 1].epoch;
|
|
806
|
+
const X = (e) => pad.l + (xmax === xmin ? 0 : (e - xmin) / (xmax - xmin)) * (W - pad.l - pad.r);
|
|
807
|
+
const Y = (v) => pad.t + (1 - (v - ymin) / (ymax - ymin)) * (H - pad.t - pad.b);
|
|
808
|
+
|
|
809
|
+
// faint horizontal grid + y-axis labels
|
|
810
|
+
ctx.font = "9px ui-monospace, monospace"; ctx.textBaseline = "middle";
|
|
811
|
+
for (let i = 0; i <= 2; i++) {
|
|
812
|
+
const v = ymin + (ymax - ymin) * i / 2, y = Y(v);
|
|
813
|
+
ctx.strokeStyle = "rgba(255,255,255,.07)"; ctx.lineWidth = 1;
|
|
814
|
+
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
|
|
815
|
+
ctx.fillStyle = "#8b98a9"; ctx.fillText(v.toFixed(1), 2, y);
|
|
816
|
+
}
|
|
817
|
+
// series lines with an emphasized latest point
|
|
818
|
+
CHART_SERIES.forEach((s) => {
|
|
819
|
+
ctx.beginPath(); let started = false, last = null;
|
|
820
|
+
pts.forEach((m) => {
|
|
821
|
+
const v = m[s.key]; if (typeof v !== "number" || !isFinite(v)) return;
|
|
822
|
+
const x = X(m.epoch), y = Y(v);
|
|
823
|
+
started ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), started = true);
|
|
824
|
+
last = m;
|
|
825
|
+
});
|
|
826
|
+
ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.stroke();
|
|
827
|
+
if (last) {
|
|
828
|
+
ctx.beginPath(); ctx.arc(X(last.epoch), Y(last[s.key]), 2.6, 0, Math.PI * 2);
|
|
829
|
+
ctx.fillStyle = s.color; ctx.fill();
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
// legend + axis unit
|
|
833
|
+
ctx.textBaseline = "alphabetic";
|
|
834
|
+
let lx = pad.l;
|
|
835
|
+
CHART_SERIES.forEach((s) => {
|
|
836
|
+
ctx.fillStyle = s.color; ctx.fillRect(lx, H - 8, 9, 3);
|
|
837
|
+
ctx.fillStyle = "#8b98a9"; ctx.fillText(s.label, lx + 12, H - 5); lx += 46;
|
|
838
|
+
});
|
|
839
|
+
ctx.fillStyle = "#5f6b7a"; ctx.fillText("loss · epoch", W - 70, H - 5);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function hideChart() { document.getElementById("chart").style.display = "none"; }
|
|
843
|
+
|
|
844
|
+
async function pollRun(runId) {
|
|
845
|
+
let st, j;
|
|
846
|
+
try {
|
|
847
|
+
j = await (await fetch("/api/run/" + runId)).json();
|
|
848
|
+
st = j.state || {};
|
|
849
|
+
} catch (e) { stopPolling(); return; }
|
|
850
|
+
drawChart(j.metrics);
|
|
851
|
+
document.getElementById("outTitle").textContent = "TRAINING";
|
|
852
|
+
|
|
853
|
+
const phase = st.phase || "?";
|
|
854
|
+
const ep = st.epoch != null ? st.epoch + 1 : 0, eps = st.epochs || "?";
|
|
855
|
+
const prog = (typeof st.epochs === "number" && st.epochs > 0)
|
|
856
|
+
? (st.epoch + 1) / st.epochs : 0;
|
|
857
|
+
const done = phase === "done", errored = phase === "error";
|
|
858
|
+
|
|
859
|
+
// Feed the animated Train node + header dot.
|
|
860
|
+
const tn = trainNode();
|
|
861
|
+
if (tn) { tn._phase = phase; tn._progress = prog; tn._running = phase === "running"; }
|
|
862
|
+
const dot = document.querySelector("#bar .dot");
|
|
863
|
+
if (dot) dot.classList.toggle("live", phase === "running");
|
|
864
|
+
setTrainState(phase === "running" ? "running" : (graphValid ? "ready" : "invalid"));
|
|
865
|
+
|
|
866
|
+
setPanel(errored ? "err" : (done ? "ok" : "k"),
|
|
867
|
+
`<b>${done ? "✓" : errored ? "✗" : "▶"} ${runId}</b> · ${phase}<br>` +
|
|
868
|
+
`<span class="k">epoch</span> ${ep}/${eps}` +
|
|
869
|
+
(phase === "running" ? ` <span class="k">(${Math.round(prog * 100)}%)</span>` : "") + `<br>` +
|
|
870
|
+
`<span class="k">best val</span> ${fmt(st.best_val)}<br>` +
|
|
871
|
+
`<span class="k">test score</span> ${fmt(st.test_score)}` +
|
|
872
|
+
(errored && st.error ? `<br><span class="err">${st.error}</span>` : ""));
|
|
873
|
+
|
|
874
|
+
if (done || errored) { stopPolling(); refreshRuns(); return; }
|
|
875
|
+
pollTimer = setTimeout(() => pollRun(runId), 1500);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ---- toolbar actions -------------------------------------------------------
|
|
879
|
+
async function train() {
|
|
880
|
+
const r = await fetch("/api/graph/start", {
|
|
881
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
882
|
+
body: JSON.stringify(serialize()),
|
|
883
|
+
});
|
|
884
|
+
const j = await r.json();
|
|
885
|
+
if (r.ok) {
|
|
886
|
+
stopPolling();
|
|
887
|
+
pollRun(j.run_id); // start streaming live status into the panel
|
|
888
|
+
} else {
|
|
889
|
+
const errs = (j.detail && j.detail.errors) || [j.detail];
|
|
890
|
+
setPanel("err", "✗ cannot launch:<br>" + errs.map((e) => `• ${e}`).join("<br>"));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function refreshRuns() {
|
|
895
|
+
const sel = document.getElementById("runs");
|
|
896
|
+
try {
|
|
897
|
+
const j = await (await fetch("/api/runs")).json();
|
|
898
|
+
sel.innerHTML = `<option value="">— saved blueprints —</option>` +
|
|
899
|
+
j.runs.map((r) => `<option value="${r.run_id}">${r.run_id} (${r.phase})</option>`).join("");
|
|
900
|
+
} catch { /* dashboard offline is fine */ }
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Load a run's saved graph.json back onto the canvas (reopen / fork a run).
|
|
904
|
+
async function loadRun() {
|
|
905
|
+
const run = document.getElementById("runs").value;
|
|
906
|
+
if (!run) return;
|
|
907
|
+
const r = await fetch("/api/graph/" + run);
|
|
908
|
+
const body = document.getElementById("outBody");
|
|
909
|
+
if (!r.ok) { body.className = "err"; body.textContent = `no saved blueprint for ${run}`; return; }
|
|
910
|
+
const g = await r.json();
|
|
911
|
+
graph.clear();
|
|
912
|
+
rebuild(g);
|
|
913
|
+
scheduleValidate();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Reconstruct engine nodes + links from our graph JSON (inverse of serialize).
|
|
917
|
+
function rebuild(g) {
|
|
918
|
+
const byId = {};
|
|
919
|
+
(g.nodes || []).forEach((gn, i) => {
|
|
920
|
+
const node = LiteGraph.createNode(gn.type);
|
|
921
|
+
if (!node) return;
|
|
922
|
+
node.pos = Array.isArray(gn.pos) ? [gn.pos[0], gn.pos[1]] : [80 + (i % 4) * 240, 80 + Math.floor(i / 4) * 170];
|
|
923
|
+
(SPEC_BY_TYPE[gn.type].params || []).forEach((p) => {
|
|
924
|
+
let v = (gn.params || {})[p.name];
|
|
925
|
+
if (p.kind === "int_list" && Array.isArray(v)) v = v.join(",");
|
|
926
|
+
if (v === undefined) return;
|
|
927
|
+
node.properties[p.name] = v;
|
|
928
|
+
// Match the widget by name (index no longer aligns: readonly params have none).
|
|
929
|
+
const w = (node.widgets || []).find((wd) => wd.name === p.label);
|
|
930
|
+
if (w) w.value = v;
|
|
931
|
+
const d = (node._descriptors || []).find((dd) => dd.label === p.label);
|
|
932
|
+
if (d) d.value = v;
|
|
933
|
+
});
|
|
934
|
+
fitNode(node, SPEC_BY_TYPE[gn.type]);
|
|
935
|
+
graph.add(node);
|
|
936
|
+
byId[gn.id] = node;
|
|
937
|
+
});
|
|
938
|
+
(g.links || []).forEach((l) => {
|
|
939
|
+
const src = byId[l.from], dst = byId[l.to];
|
|
940
|
+
if (!src || !dst) return;
|
|
941
|
+
const so = src.outputs.findIndex((p) => p.name === l.from_port);
|
|
942
|
+
const di = dst.inputs.findIndex((p) => p.name === l.to_port);
|
|
943
|
+
if (so >= 0 && di >= 0) src.connect(so, dst, di);
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---- boot ------------------------------------------------------------------
|
|
948
|
+
async function boot() {
|
|
949
|
+
// Header branding comes from the host app (create_app title/subtitle).
|
|
950
|
+
try {
|
|
951
|
+
const meta = await (await fetch("/api/meta")).json();
|
|
952
|
+
if (meta.title) document.getElementById("brand").firstChild.textContent = meta.title + " ";
|
|
953
|
+
if (meta.subtitle) document.getElementById("brandSub").textContent = "· " + meta.subtitle;
|
|
954
|
+
if (meta.title) document.title = meta.title + " — visual training editor";
|
|
955
|
+
} catch { /* branding is optional */ }
|
|
956
|
+
CATALOG = await (await fetch("/api/nodes")).json();
|
|
957
|
+
// Color the wires themselves by port type — including each scalar/<field>
|
|
958
|
+
// subtype — so they match the flowing-pulse colors.
|
|
959
|
+
const linkColors = {};
|
|
960
|
+
CATALOG.nodes.forEach((n) => [...n.inputs, ...n.outputs].forEach(
|
|
961
|
+
(p) => { linkColors[p.type] = linkColor(p.type); }));
|
|
962
|
+
LGraphCanvas.link_type_colors = Object.assign(
|
|
963
|
+
{}, LGraphCanvas.link_type_colors, linkColors);
|
|
964
|
+
registerNodes();
|
|
965
|
+
buildPalette();
|
|
966
|
+
initPaletteSearch();
|
|
967
|
+
initPaletteResize();
|
|
968
|
+
setTrainState("invalid"); // nothing wired yet
|
|
969
|
+
resize();
|
|
970
|
+
initOutPanel(); // draggable + resizable status panel
|
|
971
|
+
canvas.onDrawForeground = ambientDraw; // flowing wires, spawn rings, port cues
|
|
972
|
+
graph.start();
|
|
973
|
+
animLoop(); // ambient repaint loop
|
|
974
|
+
if (restore()) scheduleValidate(); // bring back the last canvas after a reload
|
|
975
|
+
captureHistory(); // seed history entry 0 (empty or restored canvas)
|
|
976
|
+
updateUndoButtons();
|
|
977
|
+
refreshRuns();
|
|
978
|
+
document.getElementById("trainBtn").onclick = train;
|
|
979
|
+
document.getElementById("loadBtn").onclick = loadRun;
|
|
980
|
+
document.getElementById("clearBtn").onclick = () => { graph.clear(); scheduleValidate(); };
|
|
981
|
+
document.getElementById("fitBtn").onclick = zoomToFit;
|
|
982
|
+
document.getElementById("exportBtn").onclick = exportBlueprint;
|
|
983
|
+
document.getElementById("importBtn").onclick = () =>
|
|
984
|
+
document.getElementById("importFile").click();
|
|
985
|
+
document.getElementById("importFile").onchange = (e) => {
|
|
986
|
+
if (e.target.files[0]) importBlueprint(e.target.files[0]);
|
|
987
|
+
e.target.value = ""; // allow re-importing the same file
|
|
988
|
+
};
|
|
989
|
+
// Delegate clicks on error lines to deep-link back to the offending node.
|
|
990
|
+
document.getElementById("outBody").addEventListener("click", (e) => {
|
|
991
|
+
const el = e.target.closest(".errline");
|
|
992
|
+
if (el && el.dataset.node) focusNode(el.dataset.node);
|
|
993
|
+
});
|
|
994
|
+
// Debug handle for automated/headless testing of the live editor.
|
|
995
|
+
window.__bp = { graph, canvas, serialize, addNode, validate, rebuild, train,
|
|
996
|
+
pollRun, persist, restore, SPEC_BY_TYPE,
|
|
997
|
+
zoomToFit, focusNode, setTrainState,
|
|
998
|
+
exportBlueprint, importBlueprint,
|
|
999
|
+
undo, redo, captureHistory, get histIdx() { return histIdx; },
|
|
1000
|
+
history };
|
|
1001
|
+
}
|
|
1002
|
+
boot();
|
|
1003
|
+
})();
|