Skip to content

Commit 77f35db

Browse files
authored
output config, result, link urls in input/output (#114)
1 parent 2b170fe commit 77f35db

File tree

8 files changed

+363
-44
lines changed

8 files changed

+363
-44
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,5 @@ tmp/
4747
# generated files
4848
lib/types/integration.ts
4949
lib/codegen-registry.ts
50-
lib/step-registry.ts
50+
lib/step-registry.ts
51+
lib/output-display-configs.ts

components/workflow/workflow-runs.tsx

Lines changed: 252 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ChevronRight,
88
Clock,
99
Copy,
10+
ExternalLink,
1011
Loader2,
1112
Play,
1213
X,
@@ -15,6 +16,10 @@ import Image from "next/image";
1516
import type { JSX } from "react";
1617
import { useCallback, useEffect, useRef, useState } from "react";
1718
import { api } from "@/lib/api-client";
19+
import {
20+
OUTPUT_DISPLAY_CONFIGS,
21+
type OutputDisplayConfig,
22+
} from "@/lib/output-display-configs";
1823
import { cn } from "@/lib/utils";
1924
import { getRelativeTime } from "@/lib/utils/time";
2025
import {
@@ -55,7 +60,27 @@ type WorkflowRunsProps = {
5560
onStartRun?: (executionId: string) => void;
5661
};
5762

58-
// Helper to detect if output is a base64 image from generateImage step
63+
// Helper to get the output display config for a node type
64+
function getOutputConfig(nodeType: string): OutputDisplayConfig | undefined {
65+
return OUTPUT_DISPLAY_CONFIGS[nodeType];
66+
}
67+
68+
// Helper to extract the displayable value from output based on config
69+
function getOutputDisplayValue(
70+
output: unknown,
71+
config: OutputDisplayConfig
72+
): string | undefined {
73+
if (typeof output !== "object" || output === null) {
74+
return;
75+
}
76+
const value = (output as Record<string, unknown>)[config.field];
77+
if (typeof value === "string" && value.length > 0) {
78+
return value;
79+
}
80+
return;
81+
}
82+
83+
// Fallback: detect if output is a base64 image (for legacy support)
5984
function isBase64ImageOutput(output: unknown): output is { base64: string } {
6085
return (
6186
typeof output === "object" &&
@@ -99,6 +124,51 @@ function createExecutionLogsMap(logs: ExecutionLog[]): Record<
99124
return logsMap;
100125
}
101126

127+
// Helper to check if a string is a URL
128+
function isUrl(str: string): boolean {
129+
try {
130+
const url = new URL(str);
131+
return url.protocol === "http:" || url.protocol === "https:";
132+
} catch {
133+
return false;
134+
}
135+
}
136+
137+
// Component to render JSON with clickable links
138+
function JsonWithLinks({ data }: { data: unknown }) {
139+
// Use regex to find and replace URLs in the JSON string
140+
const jsonString = JSON.stringify(data, null, 2);
141+
142+
// Split by quoted strings to preserve structure
143+
const parts = jsonString.split(/("https?:\/\/[^"]+"|"[^"]*")/g);
144+
145+
return (
146+
<>
147+
{parts.map((part) => {
148+
// Check if this part is a quoted URL string
149+
if (part.startsWith('"') && part.endsWith('"')) {
150+
const innerValue = part.slice(1, -1);
151+
if (isUrl(innerValue)) {
152+
return (
153+
<a
154+
className="text-blue-500 underline hover:text-blue-400"
155+
href={innerValue}
156+
key={innerValue}
157+
rel="noopener noreferrer"
158+
target="_blank"
159+
>
160+
{part}
161+
</a>
162+
);
163+
}
164+
}
165+
// For non-URL parts, just render as text (no key needed for text nodes)
166+
return part;
167+
})}
168+
</>
169+
);
170+
}
171+
102172
// Reusable copy button component
103173
function CopyButton({
104174
data,
@@ -138,6 +208,176 @@ function CopyButton({
138208
);
139209
}
140210

211+
// Collapsible section component
212+
function CollapsibleSection({
213+
title,
214+
children,
215+
defaultExpanded = false,
216+
copyData,
217+
isError = false,
218+
externalLink,
219+
}: {
220+
title: string;
221+
children: React.ReactNode;
222+
defaultExpanded?: boolean;
223+
copyData?: unknown;
224+
isError?: boolean;
225+
externalLink?: string;
226+
}) {
227+
const [isOpen, setIsOpen] = useState(defaultExpanded);
228+
229+
return (
230+
<div>
231+
<div className="mb-2 flex w-full items-center justify-between">
232+
<button
233+
className="flex items-center gap-1.5"
234+
onClick={() => setIsOpen(!isOpen)}
235+
type="button"
236+
>
237+
{isOpen ? (
238+
<ChevronDown className="h-3 w-3 text-muted-foreground" />
239+
) : (
240+
<ChevronRight className="h-3 w-3 text-muted-foreground" />
241+
)}
242+
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
243+
{title}
244+
</span>
245+
</button>
246+
<div className="flex items-center gap-1">
247+
{externalLink && (
248+
<Button asChild className="h-7 px-2" size="sm" variant="ghost">
249+
<a href={externalLink} rel="noopener noreferrer" target="_blank">
250+
<ExternalLink className="h-3 w-3" />
251+
</a>
252+
</Button>
253+
)}
254+
{copyData !== undefined && (
255+
<CopyButton data={copyData} isError={isError} />
256+
)}
257+
</div>
258+
</div>
259+
{isOpen && children}
260+
</div>
261+
);
262+
}
263+
264+
// Component for rendering output with rich display support
265+
function OutputDisplay({
266+
output,
267+
input,
268+
}: {
269+
output: unknown;
270+
input?: unknown;
271+
}) {
272+
// Get actionType from input to look up the output config
273+
const actionType =
274+
typeof input === "object" && input !== null
275+
? (input as Record<string, unknown>).actionType
276+
: undefined;
277+
const config =
278+
typeof actionType === "string" ? getOutputConfig(actionType) : undefined;
279+
const displayValue = config
280+
? getOutputDisplayValue(output, config)
281+
: undefined;
282+
283+
// Check for legacy base64 image
284+
const isLegacyBase64 = !config && isBase64ImageOutput(output);
285+
286+
const renderRichResult = () => {
287+
if (config && displayValue) {
288+
switch (config.type) {
289+
case "image": {
290+
// Handle base64 images by adding data URI prefix if needed
291+
const imageSrc =
292+
config.field === "base64" && !displayValue.startsWith("data:")
293+
? `data:image/png;base64,${displayValue}`
294+
: displayValue;
295+
return (
296+
<div className="overflow-hidden rounded-lg border bg-muted/50 p-3">
297+
<Image
298+
alt="Generated image"
299+
className="max-h-96 w-auto rounded"
300+
height={384}
301+
src={imageSrc}
302+
unoptimized
303+
width={384}
304+
/>
305+
</div>
306+
);
307+
}
308+
case "video":
309+
return (
310+
<div className="overflow-hidden rounded-lg border bg-muted/50 p-3">
311+
<video
312+
className="max-h-96 w-auto rounded"
313+
controls
314+
src={displayValue}
315+
>
316+
<track kind="captions" />
317+
</video>
318+
</div>
319+
);
320+
case "url":
321+
return (
322+
<div className="overflow-hidden rounded-lg border bg-muted/50">
323+
<iframe
324+
className="h-96 w-full rounded"
325+
sandbox="allow-scripts allow-same-origin"
326+
src={displayValue}
327+
title="Output preview"
328+
/>
329+
</div>
330+
);
331+
default:
332+
return null;
333+
}
334+
}
335+
336+
// Fallback: legacy base64 image detection
337+
if (isLegacyBase64) {
338+
return (
339+
<div className="overflow-hidden rounded-lg border bg-muted/50 p-3">
340+
<Image
341+
alt="AI generated output"
342+
className="max-h-96 w-auto rounded"
343+
height={384}
344+
src={`data:image/png;base64,${(output as { base64: string }).base64}`}
345+
unoptimized
346+
width={384}
347+
/>
348+
</div>
349+
);
350+
}
351+
352+
return null;
353+
};
354+
355+
const richResult = renderRichResult();
356+
const hasRichResult = richResult !== null;
357+
358+
return (
359+
<>
360+
{/* Always show JSON output */}
361+
<CollapsibleSection copyData={output} title="Output">
362+
<pre className="overflow-auto rounded-lg border bg-muted/50 p-3 font-mono text-xs leading-relaxed">
363+
<JsonWithLinks data={output} />
364+
</pre>
365+
</CollapsibleSection>
366+
367+
{/* Show rich result if available */}
368+
{hasRichResult && (
369+
<CollapsibleSection
370+
defaultExpanded
371+
externalLink={config?.type === "url" ? displayValue : undefined}
372+
title="Result"
373+
>
374+
{richResult}
375+
</CollapsibleSection>
376+
)}
377+
</>
378+
);
379+
}
380+
141381
// Component for rendering individual execution log entries
142382
function ExecutionLogEntry({
143383
log,
@@ -211,56 +451,26 @@ function ExecutionLogEntry({
211451
{isExpanded && (
212452
<div className="mt-2 mb-2 space-y-3 px-3">
213453
{log.input !== null && log.input !== undefined && (
214-
<div>
215-
<div className="mb-2 flex items-center justify-between">
216-
<div className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
217-
Input
218-
</div>
219-
<CopyButton data={log.input} />
220-
</div>
454+
<CollapsibleSection copyData={log.input} title="Input">
221455
<pre className="overflow-auto rounded-lg border bg-muted/50 p-3 font-mono text-xs leading-relaxed">
222-
{JSON.stringify(log.input, null, 2)}
456+
<JsonWithLinks data={log.input} />
223457
</pre>
224-
</div>
458+
</CollapsibleSection>
225459
)}
226460
{log.output !== null && log.output !== undefined && (
227-
<div>
228-
<div className="mb-2 flex items-center justify-between">
229-
<div className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
230-
Output
231-
</div>
232-
<CopyButton data={log.output} />
233-
</div>
234-
{isBase64ImageOutput(log.output) ? (
235-
<div className="overflow-hidden rounded-lg border bg-muted/50 p-3">
236-
<Image
237-
alt="AI generated output"
238-
className="max-h-96 w-auto rounded"
239-
height={384}
240-
src={`data:image/png;base64,${(log.output as { base64: string }).base64}`}
241-
unoptimized
242-
width={384}
243-
/>
244-
</div>
245-
) : (
246-
<pre className="overflow-auto rounded-lg border bg-muted/50 p-3 font-mono text-xs leading-relaxed">
247-
{JSON.stringify(log.output, null, 2)}
248-
</pre>
249-
)}
250-
</div>
461+
<OutputDisplay input={log.input} output={log.output} />
251462
)}
252463
{log.error && (
253-
<div>
254-
<div className="mb-2 flex items-center justify-between">
255-
<div className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
256-
Error
257-
</div>
258-
<CopyButton data={log.error} isError />
259-
</div>
464+
<CollapsibleSection
465+
copyData={log.error}
466+
defaultExpanded
467+
isError
468+
title="Error"
469+
>
260470
<pre className="overflow-auto rounded-lg border border-red-500/20 bg-red-500/5 p-3 font-mono text-red-600 text-xs leading-relaxed">
261471
{log.error}
262472
</pre>
263-
</div>
473+
</CollapsibleSection>
264474
)}
265475
{!(log.input || log.output || log.error) && (
266476
<div className="rounded-lg border bg-muted/30 py-4 text-center text-muted-foreground text-xs">

lib/workflow-executor.workflow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ export async function executeWorkflow(input: WorkflowExecutionInput) {
514514
executionId,
515515
nodeId: node.id,
516516
nodeName: getNodeName(node),
517-
nodeType: node.data.type,
517+
nodeType: actionType,
518518
};
519519

520520
// Execute the action step with stepHandler (logging is handled inside)

plugins/ai-gateway/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const aiGatewayPlugin: IntegrationPlugin = {
113113
stepFunction: "generateImageStep",
114114
stepImportPath: "generate-image",
115115
outputFields: [{ field: "base64", description: "Base64-encoded image data" }],
116+
outputConfig: { type: "image", field: "base64" },
116117
configFields: [
117118
{
118119
key: "imageModel",

0 commit comments

Comments
 (0)