Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 11 additions & 36 deletions apps/ai-studio/src/components/visualize/visualize-card.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ArrowsOut, DownloadSimple, Eye } from '@phosphor-icons/react';
import { getStoreEdges, getStoreNodes } from '@workflowbuilder/sdk';
import { Suspense, useEffect, useRef, useState } from 'react';
import { Suspense, useRef, useState } from 'react';

import styles from './visualize-card.module.css';

import { useAdaptedVisualization } from '../../hooks/use-adapted-visualization';
import { VISUALIZE_MODES } from '../../nodes/visualize/schema';
import { useExecutionStore } from '../../stores/use-execution-store';
import { adaptVisualization } from '../../utils/adapt-visualization';
import { type VisualizeRenderer, detectFormat } from '../../utils/detect-format';
import { downloadPng } from '../../utils/export-visualization';
import { extractOutputText } from '../../utils/extract-output-text';
Expand All @@ -22,7 +22,6 @@ type Props = {

type VisualizeMode = VisualizeRenderer | 'auto';
const VALID_MODES = new Set<string>(VISUALIZE_MODES);
const ADAPTABLE = new Set<VisualizeRenderer>(['diagram', 'chart', 'table', 'json', 'stat-cards']);

function EmptyState({ running }: { running: boolean }) {
if (running) {
Expand All @@ -47,9 +46,7 @@ function EmptyState({ running }: { running: boolean }) {

export function VisualizeCard({ props }: Props) {
const nodeId = props?.nodeId ?? '';
const [expanded, setExpanded] = useState(false);
const [adaptedText, setAdaptedText] = useState<string | null>(null);
const [adapting, setAdapting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);

// Nodes/edges are static during a run, so snapshot reads are fine.
Expand All @@ -69,35 +66,13 @@ export function VisualizeCard({ props }: Props) {
const detection = detectFormat(text);
const activeRenderer: VisualizeRenderer = mode === 'auto' ? detection.renderer : mode;

useEffect(() => {
setAdaptedText(null);
}, [text]);

useEffect(() => {
if (!hasOutput || !ADAPTABLE.has(activeRenderer) || adaptedText !== null) return;
let cancelled = false;
setAdapting(true);
adaptVisualization(text, activeRenderer)
.then((output) => {
if (!cancelled) setAdaptedText(output);
})
.catch(() => {
// keep original content
})
.finally(() => {
if (!cancelled) setAdapting(false);
});
return () => {
cancelled = true;
};
}, [hasOutput, activeRenderer, text, adaptedText]);
const { renderText, isAdapted, isAdapting } = useAdaptedVisualization(text, activeRenderer, hasOutput);

if (!isVisualizeNode) {
return null;
}

const renderText = adaptedText ?? text;
const data = adaptedText === null && mode === 'auto' ? detection.data : undefined;
const data = !isAdapted && mode === 'auto' ? detection.data : undefined;
const Renderer = hasOutput ? getRenderer(activeRenderer) : null;
const badge = mode === 'auto' ? `Auto › ${RENDERER_LABELS[activeRenderer]}` : RENDERER_LABELS[activeRenderer];
const isVector = activeRenderer === 'chart' || activeRenderer === 'diagram';
Expand All @@ -109,28 +84,28 @@ export function VisualizeCard({ props }: Props) {
<div className={styles['toolbar']}>
<span className={styles['badge']}>{badge}</span>
<div className={styles['actions']}>
<button type="button" className={styles['action']} title="Expand" onClick={() => setExpanded(true)}>
<button type="button" className={styles['action']} title="Expand" onClick={() => setIsExpanded(true)}>
<ArrowsOut />
</button>
<CopyResultButton
className={styles['action']}
getTarget={() => contentRef.current}
text={renderText}
disabled={adapting}
disabled={isAdapting}
/>
<button
type="button"
className={styles['action']}
title="Download PNG"
disabled={adapting}
disabled={isAdapting}
onClick={() => contentRef.current && void downloadPng(contentRef.current)}
>
<DownloadSimple />
</button>
</div>
</div>
<div className={styles['body']}>
{adapting ? (
{isAdapting ? (
<div className={styles['empty']}>
<div className={styles['dots']}>
<span className={styles['dot']} />
Expand All @@ -151,14 +126,14 @@ export function VisualizeCard({ props }: Props) {
) : (
<EmptyState running={selfStatus === 'running'} />
)}
{expanded && (
{isExpanded && (
<VisualizeModal
renderer={activeRenderer}
text={renderText}
data={data}
badge={badge}
isVector={isVector}
onClose={() => setExpanded(false)}
onClose={() => setIsExpanded(false)}
/>
)}
</div>
Expand Down
51 changes: 51 additions & 0 deletions apps/ai-studio/src/hooks/use-adapted-visualization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';

import { adaptVisualization } from '../utils/adapt-visualization';
import type { VisualizeRenderer } from '../utils/detect-format';

const ADAPTABLE = new Set<VisualizeRenderer>(['diagram', 'chart', 'table', 'json', 'stat-cards']);

// output null = the adapt call failed and the raw text is rendered instead.
type Adaptation = { key: string; output: string | null };

// The adapted content is an async derived value keyed by (renderer, text).
// A stale adaptation is ignored by the key check, never reset by an effect,
// so switching the render format re-adapts for the new one.
export function useAdaptedVisualization(text: string, renderer: VisualizeRenderer, hasOutput: boolean) {
const [adaptation, setAdaptation] = useState<Adaptation | null>(null);
const [isAdapting, setIsAdapting] = useState(false);

const adaptationKey = `${renderer}\n${text}`;
const cached = adaptation?.key === adaptationKey ? adaptation : null;
const shouldAdapt = hasOutput && ADAPTABLE.has(renderer) && cached === null;

useEffect(() => {
if (!shouldAdapt) return;

const controller = new AbortController();

async function adapt() {
setIsAdapting(true);
try {
const output = await adaptVisualization(text, renderer, controller.signal);
setAdaptation({ key: adaptationKey, output });
} catch {
if (!controller.signal.aborted) setAdaptation({ key: adaptationKey, output: null });
} finally {
if (!controller.signal.aborted) setIsAdapting(false);
}
}

void adapt();

return () => controller.abort();
}, [shouldAdapt, adaptationKey, text, renderer]);

const adaptedOutput = cached?.output ?? null;

return {
renderText: adaptedOutput ?? text,
isAdapted: adaptedOutput !== null,
isAdapting,
};
}
3 changes: 2 additions & 1 deletion apps/ai-studio/src/utils/adapt-visualization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BACKEND_URL } from '../config';
import { getTurnstileToken } from '../security/turnstile';

export async function adaptVisualization(content: string, format: string): Promise<string> {
export async function adaptVisualization(content: string, format: string, signal?: AbortSignal): Promise<string> {
const token = await getTurnstileToken();
const response = await fetch(`${BACKEND_URL}/api/visualize/adapt`, {
method: 'POST',
Expand All @@ -10,6 +10,7 @@ export async function adaptVisualization(content: string, format: string): Promi
...(token ? { 'cf-turnstile-token': token } : {}),
},
body: JSON.stringify({ content, format }),
signal,
});
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as { message?: string };
Expand Down
Loading