Skip to content

Commit f07c0a0

Browse files
committed
Auto-detect workflow leaves as outputs; better OpenRouter error diagnostics
- executeWorkflow now collects all leaf nodes (no outgoing edges with non-empty output) into lastRunResults instead of relying on a dedicated Output node. - Toolbar shows a Dialog popover with the last run's outputs (thumbnails + text); auto-opens after a run, reopen via "Last run (N)" button. - v1 /api/v1/workflows/{id}/execute returns leaves as outputs instead of filtering by type === "output". - Output node removed from the sidebar's new-node menu (type still registered so old workflows continue to load). - Selecting a node thickens any edge touching it (accent stroke) and enlarges the output handle of nodes feeding into the selection. - Refine outputs N tiles as separate imageUrls instead of stitching; added a Tile Resolution dropdown (default 1K) to keep chained inputs under OpenRouter's 30MB per-request limit. - Server reads OpenRouter responses as text and JSON.parse with fallback so upstream gateway errors surface as readable messages instead of bare "SyntaxError: Unexpected end of JSON input".
1 parent 7cd70dd commit f07c0a0

6 files changed

Lines changed: 119 additions & 11 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ export function RefineNode({ id, data }: Props) {
4343
{data.status === "error" && (
4444
<div className="text-[11px] p-1.5 rounded bg-red-50 text-red-500 break-words">{data.error || "Refine failed"}</div>
4545
)}
46-
{data.status === "success" && data.imageUrls && data.imageUrls.length > 0 && (
47-
<div className="text-[11px] p-1.5 rounded bg-emerald-50 text-emerald-600">
48-
{data.imageUrls.length} tile{data.imageUrls.length === 1 ? "" : "s"} ready
46+
{data.imageUrls && data.imageUrls.length > 0 && (
47+
<div className="nodrag mt-1 grid gap-0.5" style={{ gridTemplateColumns: `repeat(${grid.cols}, minmax(0, 1fr))` }}>
48+
{data.imageUrls.map((url, idx) => (
49+
<img key={idx} className="block w-full aspect-square object-cover rounded border border-border-dim" src={url} alt={`Tile ${idx + 1}`} />
50+
))}
4951
</div>
5052
)}
5153
</div>

src/client/components/sidebar.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const NODE_TYPES = [
77
{ type: "imageInput", icon: "\uD83D\uDCF7", label: "Image Input", desc: "Reference image URL" },
88
{ type: "analyze", icon: "\uD83D\uDD0E", label: "Analyze", desc: "Vision \u2192 text/JSON" },
99
{ type: "refine", icon: "\u2737", label: "Refine", desc: "Tile-based image refinement" },
10-
{ type: "output", icon: "\uD83C\uDFA8", label: "Output", desc: "Display results" },
1110
];
1211

1312
export function Sidebar() {

src/client/components/toolbar.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1+
import { useEffect, useRef, useState } from "react";
12
import { useWorkflow } from "../context";
3+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
24

35
interface Props {
46
workflowView: "canvas" | "outputs";
57
onWorkflowViewChange: (v: "canvas" | "outputs") => void;
68
}
79

810
export function Toolbar({ workflowView, onWorkflowViewChange }: Props) {
9-
const { activeWorkflow, saveWorkflow, executeWorkflow, executing } = useWorkflow();
11+
const { activeWorkflow, saveWorkflow, executeWorkflow, executing, lastRunResults } = useWorkflow();
12+
const [showResults, setShowResults] = useState(false);
13+
// Auto-open the popover when a run finishes with results.
14+
const wasExecutingRef = useRef(executing);
15+
useEffect(() => {
16+
if (wasExecutingRef.current && !executing && lastRunResults.length > 0) {
17+
setShowResults(true);
18+
}
19+
wasExecutingRef.current = executing;
20+
}, [executing, lastRunResults.length]);
1021

1122
const tabClass = (active: boolean) =>
1223
`px-3 py-1.5 text-xs font-semibold cursor-pointer transition-all ${
@@ -43,7 +54,47 @@ export function Toolbar({ workflowView, onWorkflowViewChange }: Props) {
4354
>
4455
{executing ? (<><span className="spinner !border-white/30 !border-t-white" /> Running...</>) : "▶ Execute"}
4556
</button>
57+
{lastRunResults.length > 0 && (
58+
<button
59+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold border border-border-dim cursor-pointer bg-white text-gray-700 hover:bg-surface-card"
60+
onClick={() => setShowResults(true)}
61+
title="Reopen the most recent run's results"
62+
>
63+
Last run ({lastRunResults.length})
64+
</button>
65+
)}
4666
</div>
67+
68+
<Dialog open={showResults} onOpenChange={setShowResults}>
69+
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
70+
<DialogHeader>
71+
<DialogTitle>Last run · {lastRunResults.length} output{lastRunResults.length === 1 ? "" : "s"}</DialogTitle>
72+
</DialogHeader>
73+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-2">
74+
{lastRunResults.map((r) => (
75+
<div key={r.nodeId} className="border border-border-dim rounded-lg p-3 bg-surface-card">
76+
<div className="flex items-center justify-between mb-2">
77+
<span className="text-xs font-semibold text-gray-700">{r.label}</span>
78+
<span className="text-[10px] text-gray-400 uppercase tracking-wide">{r.type}</span>
79+
</div>
80+
{r.imageUrl && (
81+
<img className="block w-full rounded border border-border-dim mb-2" src={r.imageUrl} alt={r.label} />
82+
)}
83+
{r.imageUrls && r.imageUrls.length > 0 && (
84+
<div className="grid grid-cols-2 gap-1 mb-2">
85+
{r.imageUrls.map((u, i) => (
86+
<img key={i} className="block w-full aspect-square object-cover rounded border border-border-dim" src={u} alt={`${r.label} ${i + 1}`} />
87+
))}
88+
</div>
89+
)}
90+
{r.text && (
91+
<pre className="text-[11px] p-2 rounded bg-white border border-border-dim text-gray-700 max-h-[200px] overflow-y-auto whitespace-pre-wrap break-words">{r.text}</pre>
92+
)}
93+
</div>
94+
))}
95+
</div>
96+
</DialogContent>
97+
</Dialog>
4798
</div>
4899
);
49100
}

src/client/context.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import { createContext, useContext } from "react";
22
import type { Workflow, ModelOption, Generation } from "./types";
33
import type { Node, Edge, Viewport } from "@xyflow/react";
44

5+
export interface LeafResult {
6+
nodeId: string;
7+
label: string;
8+
type: string;
9+
imageUrl?: string;
10+
imageUrls?: string[];
11+
text?: string;
12+
}
13+
514
export interface WorkflowContextValue {
615
// Workflow list
716
workflows: Workflow[];
@@ -27,6 +36,10 @@ export interface WorkflowContextValue {
2736
runNode: (nodeId: string) => Promise<void>;
2837
executing: boolean;
2938

39+
/** Outputs from the most recent execution — one entry per leaf node (no outgoing edges). */
40+
lastRunResults: LeafResult[];
41+
setLastRunResults: (r: LeafResult[]) => void;
42+
3043
// Models
3144
models: ModelOption[];
3245

src/client/hooks/use-workflow.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "@xyflow/react";
1010
import { api } from "../api";
1111
import type { Workflow, ModelOption, Generation } from "../types";
12-
import type { WorkflowContextValue } from "../context";
12+
import type { WorkflowContextValue, LeafResult } from "../context";
1313

1414
let nodeIdCounter = 0;
1515
function nextNodeId() {
@@ -75,6 +75,7 @@ export function useWorkflowState(): WorkflowContextValue {
7575
const [edges, setEdges, onEdgesChangeBase] = useEdgesState<Edge>([]);
7676
const [models, setModels] = useState<ModelOption[]>([]);
7777
const [generations, setGenerations] = useState<Generation[]>([]);
78+
const [lastRunResults, setLastRunResults] = useState<LeafResult[]>([]);
7879
const [executing, setExecuting] = useState(false);
7980
const [loading, setLoading] = useState(true);
8081
const [error, setError] = useState<string | null>(null);
@@ -590,6 +591,26 @@ export function useWorkflowState(): WorkflowContextValue {
590591
await Promise.allSettled(level.map((nid) => executeNode(nid, nodeMap, edges, outputs)));
591592
}
592593

594+
// Leaves = nodes with no outgoing edges. Treat them as the workflow's
595+
// "outputs" — Output node becomes optional.
596+
const hasOutgoing = new Set(edges.map((e) => e.source));
597+
const leaves: LeafResult[] = [];
598+
for (const n of nodes) {
599+
if (hasOutgoing.has(n.id)) continue;
600+
const out = outputs.get(n.id);
601+
if (!out) continue;
602+
if (!out.imageUrl && !out.text && !(out.imageUrls && out.imageUrls.length)) continue;
603+
leaves.push({
604+
nodeId: n.id,
605+
label: ((n.data as Record<string, unknown>).label as string) || n.id,
606+
type: n.type || "",
607+
imageUrl: out.imageUrl,
608+
imageUrls: out.imageUrls,
609+
text: out.text,
610+
});
611+
}
612+
setLastRunResults(leaves);
613+
593614
await saveWorkflow();
594615

595616
if (activeIdRef.current) {
@@ -671,6 +692,8 @@ export function useWorkflowState(): WorkflowContextValue {
671692
executeWorkflow,
672693
runNode,
673694
executing,
695+
lastRunResults,
696+
setLastRunResults,
674697
models,
675698
generations,
676699
isAgent,

src/server/index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,25 @@ app.openapi(generateImage, async (c) => {
276276
return c.json({ error: `OpenRouter error: ${err}` }, 500);
277277
}
278278

279-
const data = (await response.json()) as {
279+
// Read as text so we can surface the actual response when it isn't valid
280+
// JSON (CF gateway timeout, partial body, etc.) — `response.json()` would
281+
// throw a bare SyntaxError here that's useless for debugging.
282+
const rawText = await response.text();
283+
let data: {
280284
choices?: Array<{
281285
message?: {
282286
content?: string;
283287
images?: Array<{ image_url: { url: string } }>;
284288
};
285289
}>;
286290
};
291+
try {
292+
data = JSON.parse(rawText);
293+
} catch {
294+
return c.json({
295+
error: `OpenRouter returned non-JSON (status ${response.status}, ${rawText.length} bytes): ${rawText.slice(0, 400)}`,
296+
}, 500);
297+
}
287298

288299
const message = data.choices?.[0]?.message;
289300

@@ -460,7 +471,13 @@ async function runAnalyze(
460471
const err = await response.text();
461472
throw new Error(`OpenRouter error: ${err}`);
462473
}
463-
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
474+
const rawText = await response.text();
475+
let data: { choices?: Array<{ message?: { content?: string } }> };
476+
try {
477+
data = JSON.parse(rawText);
478+
} catch {
479+
throw new Error(`OpenRouter returned non-JSON (status ${response.status}, ${rawText.length} bytes): ${rawText.slice(0, 400)}`);
480+
}
464481
let text = data.choices?.[0]?.message?.content?.trim() || "";
465482
if (outputFormat === "json") {
466483
// Strip code fences if the model added them despite our instruction.
@@ -908,11 +925,14 @@ publicApp.openapi(executeWorkflow, async (c) => {
908925
await Promise.allSettled(level.map((nid) => limiter.run(() => executeNode(nid))));
909926
}
910927

911-
// Return output nodes as the primary result
912-
const outputNodes = results.filter((r) => r.type === "output");
928+
// Treat any node with no outgoing edges as a workflow output (leaf). This
929+
// makes the dedicated Output node optional — wiring up to a leaf is the
930+
// implicit "this is what I want back" signal.
931+
const hasOutgoing = new Set(edges.map((e) => e.source));
932+
const leafResults = results.filter((r) => !hasOutgoing.has(r.node_id));
913933
return c.json({
914934
workflow_id: wf.id,
915-
outputs: outputNodes.map((r) => r.output),
935+
outputs: leafResults.map((r) => ({ node_id: r.node_id, label: r.label, type: r.type, ...r.output })),
916936
all_nodes: results,
917937
}, 200);
918938
});

0 commit comments

Comments
 (0)