Skip to content

Commit 88633ac

Browse files
committed
Add re-run-only-this-node button to GenerateImage, Analyze, Refine
- Extracts executeNode out of executeWorkflow as a reusable callback that takes (nodeId, nodeMap, edges, outputs) - New runNode(nodeId) snapshots upstream nodes' persisted runtime fields (imageUrl, text/result, lastPrompt) into a one-shot outputs map and runs only the target node — useful for tweaking model/prompt config without paying for the whole upstream chain again - "Run" button in NodeToolbar wired via canRerun prop - Refine merged-image cap: keeps PNG when under 15MB, otherwise falls back to JPEG with descending quality, then 25%-per-pass downscale - OpenRouter attribution headers now use clawnify.com / "Clawnify"
1 parent 92353d3 commit 88633ac

6 files changed

Lines changed: 164 additions & 74 deletions

File tree

src/client/components/nodes/analyze-node.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function AnalyzeNode({ id, data }: Props) {
3131

3232
return (
3333
<div className={`group flow-node analyze-node status-${data.status} relative`}>
34-
<NodeToolbar id={id} />
34+
<NodeToolbar id={id} canRerun />
3535
<NodeHeader id={id} label={data.label} icon="&#128270;" bgClass="bg-amber-50" textClass="text-amber-600" />
3636
<div className="p-2.5 flex flex-col gap-1.5">
3737
<label className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Instruction</label>

src/client/components/nodes/generate-node.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function GenerateNode({ id, data }: Props) {
3939

4040
return (
4141
<div className={`group flow-node generate-node status-${data.status} relative`}>
42-
<NodeToolbar id={id} />
42+
<NodeToolbar id={id} canRerun />
4343
<NodeHeader id={id} label={data.label} icon="&#9881;" bgClass="bg-emerald-50" textClass="text-emerald-600" />
4444
<div className="p-2.5 flex flex-col gap-1.5">
4545
<label className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Model</label>

src/client/components/nodes/node-toolbar.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,28 @@ interface Props {
44
id: string;
55
isInput?: boolean;
66
onToggleInput?: (value: boolean) => void;
7+
/** Show a "Run" button that re-executes only this node (using upstream nodes' last outputs). */
8+
canRerun?: boolean;
79
}
810

9-
export function NodeToolbar({ id, isInput, onToggleInput }: Props) {
10-
const { deleteNode } = useWorkflow();
11+
export function NodeToolbar({ id, isInput, onToggleInput, canRerun }: Props) {
12+
const { deleteNode, runNode, executing } = useWorkflow();
1113

1214
return (
1315
<div className="node-toolbar">
16+
{canRerun && (
17+
<button
18+
className="node-toolbar__btn"
19+
onClick={() => runNode(id)}
20+
disabled={executing}
21+
data-tooltip="Run only this node (reuses upstream outputs)"
22+
>
23+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
24+
<polygon points="5 3 19 12 5 21 5 3" />
25+
</svg>
26+
<span>Run</span>
27+
</button>
28+
)}
1429
{onToggleInput && (
1530
<button
1631
className={`node-toolbar__btn ${isInput ? "node-toolbar__btn--active" : ""}`}

src/client/components/nodes/refine-node.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function RefineNode({ id, data }: Props) {
3131

3232
return (
3333
<div className={`group flow-node refine-node status-${data.status} relative`} style={{ width: 280 }}>
34-
<NodeToolbar id={id} />
34+
<NodeToolbar id={id} canRerun />
3535
<NodeHeader id={id} label={data.label} icon="&#9783;" bgClass="bg-fuchsia-50" textClass="text-fuchsia-600" />
3636
<div className="p-2.5 flex flex-col gap-1.5">
3737
<label className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Model</label>

src/client/context.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface WorkflowContextValue {
2424

2525
// Execution
2626
executeWorkflow: () => Promise<void>;
27+
runNode: (nodeId: string) => Promise<void>;
2728
executing: boolean;
2829

2930
// Models

src/client/hooks/use-workflow.ts

Lines changed: 143 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,41 @@ async function splitImage(src: string, rows: number, cols: number): Promise<stri
6060
return tiles;
6161
}
6262

63+
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
64+
return new Promise((resolve, reject) => {
65+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Canvas toBlob failed"))), type, quality);
66+
});
67+
}
68+
69+
/**
70+
* PNG when it fits under `maxBytes`; otherwise JPEG with descending quality,
71+
* then 25%-per-pass downscale. Ensures the merged image stays small enough to
72+
* be used as a source for downstream nodes (model upload limits).
73+
*/
74+
async function canvasToCappedBlob(canvas: HTMLCanvasElement, maxBytes: number): Promise<Blob> {
75+
const png = await canvasToBlob(canvas, "image/png");
76+
if (png.size <= maxBytes) return png;
77+
78+
let work = canvas;
79+
let quality = 0.92;
80+
for (let attempt = 0; attempt < 6; attempt++) {
81+
const blob = await canvasToBlob(work, "image/jpeg", quality);
82+
if (blob.size <= maxBytes) return blob;
83+
if (quality > 0.6) { quality -= 0.1; continue; }
84+
const scaled = document.createElement("canvas");
85+
scaled.width = Math.max(1, Math.floor(work.width * 0.75));
86+
scaled.height = Math.max(1, Math.floor(work.height * 0.75));
87+
const ctx = scaled.getContext("2d");
88+
if (!ctx) break;
89+
ctx.drawImage(work, 0, 0, scaled.width, scaled.height);
90+
work = scaled;
91+
quality = 0.85;
92+
}
93+
return canvasToBlob(work, "image/jpeg", 0.7);
94+
}
95+
96+
const REFINE_MERGED_MAX_BYTES = 15 * 1024 * 1024;
97+
6398
async function stitchTiles(srcs: string[], rows: number, cols: number): Promise<Blob> {
6499
const imgs = await Promise.all(srcs.map(loadImage));
65100
// Use the largest dimensions seen across tiles (model output sizes can drift slightly)
@@ -76,9 +111,7 @@ async function stitchTiles(srcs: string[], rows: number, cols: number): Promise<
76111
ctx.drawImage(imgs[idx], c * tileW, r * tileH, tileW, tileH);
77112
}
78113
}
79-
return new Promise((resolve, reject) => {
80-
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Canvas toBlob failed"))), "image/png");
81-
});
114+
return canvasToCappedBlob(canvas, REFINE_MERGED_MAX_BYTES);
82115
}
83116

84117
async function uploadBlob(blob: Blob): Promise<string> {
@@ -353,68 +386,19 @@ export function useWorkflowState(): WorkflowContextValue {
353386

354387
// ── Workflow execution engine ──────────────────────────────────
355388

356-
const executeWorkflow = useCallback(async () => {
357-
if (!activeIdRef.current || executing) return;
358-
setExecuting(true);
359-
setError(null);
360-
361-
try {
362-
// Topological sort
363-
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
364-
const inDegree = new Map<string, number>();
365-
const adjList = new Map<string, string[]>();
366-
367-
nodes.forEach((n) => {
368-
inDegree.set(n.id, 0);
369-
adjList.set(n.id, []);
370-
});
389+
type ExecOutputs = Map<string, { text?: string; promptText?: string; imageUrl?: string }>;
371390

372-
// Real edges + virtual edges from {{nodeId}} pill references inside prompt text.
373-
// Without these, a prompt that references an analyze node only via {{...}} (no
374-
// drawn edge) can run before the analyze node and resolve to "".
375-
const allDeps: Array<{ source: string; target: string }> = edges.map((e) => ({ source: e.source, target: e.target }));
376-
for (const n of nodes) {
377-
if (n.type !== "prompt") continue;
378-
const text = ((n.data as Record<string, unknown>).text as string) || "";
379-
for (const m of text.matchAll(/\{\{(.+?)\}\}/g)) {
380-
const refId = m[1].trim();
381-
if (refId === n.id || !nodeMap.has(refId)) continue;
382-
if (allDeps.some((d) => d.source === refId && d.target === n.id)) continue;
383-
allDeps.push({ source: refId, target: n.id });
384-
}
385-
}
386-
allDeps.forEach((e) => {
387-
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1);
388-
adjList.get(e.source)?.push(e.target);
389-
});
390-
391-
// Topological levels — nodes at the same level have no dependencies on
392-
// each other and can run in parallel.
393-
const levels: string[][] = [];
394-
let frontier: string[] = [];
395-
inDegree.forEach((deg, id) => { if (deg === 0) frontier.push(id); });
396-
while (frontier.length > 0) {
397-
levels.push(frontier);
398-
const next: string[] = [];
399-
for (const id of frontier) {
400-
for (const child of adjList.get(id) || []) {
401-
const newDeg = (inDegree.get(child) || 1) - 1;
402-
inDegree.set(child, newDeg);
403-
if (newDeg === 0) next.push(child);
404-
}
405-
}
406-
frontier = next;
407-
}
408-
409-
// Execute nodes in order, passing data through edges
410-
const outputs = new Map<string, { text?: string; promptText?: string; imageUrl?: string }>();
411-
412-
const executeNode = async (nodeId: string) => {
391+
const executeNode = useCallback(async (
392+
nodeId: string,
393+
nodeMap: Map<string, Node>,
394+
allEdges: Edge[],
395+
outputs: ExecOutputs,
396+
) => {
413397
const node = nodeMap.get(nodeId);
414398
if (!node) return;
415399

416400
// Gather inputs from connected source nodes
417-
const incoming = edges.filter((e) => e.target === nodeId);
401+
const incoming = allEdges.filter((e) => e.target === nodeId);
418402
let inputText = "";
419403
let inputPromptText = "";
420404
const inputImages: string[] = [];
@@ -602,20 +586,64 @@ export function useWorkflowState(): WorkflowContextValue {
602586
break;
603587
}
604588
}
605-
};
589+
}, [updateNodeData]);
590+
591+
const executeWorkflow = useCallback(async () => {
592+
if (!activeIdRef.current || executing) return;
593+
setExecuting(true);
594+
setError(null);
595+
596+
try {
597+
// Topological sort
598+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
599+
const inDegree = new Map<string, number>();
600+
const adjList = new Map<string, string[]>();
601+
602+
nodes.forEach((n) => {
603+
inDegree.set(n.id, 0);
604+
adjList.set(n.id, []);
605+
});
606+
607+
// Real edges + virtual edges from {{nodeId}} pill references inside prompt text.
608+
const allDeps: Array<{ source: string; target: string }> = edges.map((e) => ({ source: e.source, target: e.target }));
609+
for (const n of nodes) {
610+
if (n.type !== "prompt") continue;
611+
const text = ((n.data as Record<string, unknown>).text as string) || "";
612+
for (const m of text.matchAll(/\{\{(.+?)\}\}/g)) {
613+
const refId = m[1].trim();
614+
if (refId === n.id || !nodeMap.has(refId)) continue;
615+
if (allDeps.some((d) => d.source === refId && d.target === n.id)) continue;
616+
allDeps.push({ source: refId, target: n.id });
617+
}
618+
}
619+
allDeps.forEach((e) => {
620+
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1);
621+
adjList.get(e.source)?.push(e.target);
622+
});
623+
624+
const levels: string[][] = [];
625+
let frontier: string[] = [];
626+
inDegree.forEach((deg, id) => { if (deg === 0) frontier.push(id); });
627+
while (frontier.length > 0) {
628+
levels.push(frontier);
629+
const next: string[] = [];
630+
for (const id of frontier) {
631+
for (const child of adjList.get(id) || []) {
632+
const newDeg = (inDegree.get(child) || 1) - 1;
633+
inDegree.set(child, newDeg);
634+
if (newDeg === 0) next.push(child);
635+
}
636+
}
637+
frontier = next;
638+
}
606639

607-
// Same-level nodes run in parallel; rate-limit-aware throttling lives
608-
// server-side (the /api/analyze and /api/generate endpoints retry on
609-
// 429 with backoff). Promise.allSettled prevents one failed node from
610-
// killing the rest of the level.
640+
const outputs: ExecOutputs = new Map();
611641
for (const level of levels) {
612-
await Promise.allSettled(level.map(executeNode));
642+
await Promise.allSettled(level.map((nid) => executeNode(nid, nodeMap, edges, outputs)));
613643
}
614644

615-
// Auto-save after execution
616645
await saveWorkflow();
617646

618-
// Refresh generations
619647
if (activeIdRef.current) {
620648
const gens = await api<Generation[]>("GET", `/api/generations/${activeIdRef.current}`);
621649
setGenerations(gens);
@@ -625,7 +653,52 @@ export function useWorkflowState(): WorkflowContextValue {
625653
} finally {
626654
setExecuting(false);
627655
}
628-
}, [nodes, edges, executing, updateNodeData, saveWorkflow]);
656+
}, [nodes, edges, executing, executeNode, saveWorkflow]);
657+
658+
/**
659+
* Run a single node, reusing upstream nodes' previously-stored runtime
660+
* outputs as inputs. Useful for tweaking config (model, prompts) without
661+
* paying for the whole upstream chain again.
662+
*/
663+
const runNode = useCallback(async (nodeId: string) => {
664+
if (!activeIdRef.current || executing) return;
665+
setExecuting(true);
666+
setError(null);
667+
668+
try {
669+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
670+
const target = nodeMap.get(nodeId);
671+
if (!target) return;
672+
673+
// Snapshot upstream outputs from each source node's persisted runtime data.
674+
const outputs: ExecOutputs = new Map();
675+
const incoming = edges.filter((e) => e.target === nodeId);
676+
for (const edge of incoming) {
677+
const src = nodeMap.get(edge.source);
678+
if (!src) continue;
679+
const d = src.data as Record<string, unknown>;
680+
const out: { text?: string; promptText?: string; imageUrl?: string } = {};
681+
const txt = (d.text as string) || (d.result as string);
682+
if (txt) out.text = txt;
683+
const img = d.imageUrl as string;
684+
if (img) out.imageUrl = img;
685+
const lastPrompt = d.lastPrompt as string;
686+
if (lastPrompt && !out.text) out.promptText = lastPrompt;
687+
outputs.set(edge.source, out);
688+
}
689+
690+
await executeNode(nodeId, nodeMap, edges, outputs);
691+
692+
if (activeIdRef.current) {
693+
const gens = await api<Generation[]>("GET", `/api/generations/${activeIdRef.current}`);
694+
setGenerations(gens);
695+
}
696+
} catch (e) {
697+
setError(String(e));
698+
} finally {
699+
setExecuting(false);
700+
}
701+
}, [nodes, edges, executing, executeNode]);
629702

630703
const clearError = useCallback(() => setError(null), []);
631704

@@ -646,6 +719,7 @@ export function useWorkflowState(): WorkflowContextValue {
646719
deleteNode,
647720
saveWorkflow,
648721
executeWorkflow,
722+
runNode,
649723
executing,
650724
models,
651725
generations,

0 commit comments

Comments
 (0)