diff --git a/apps/ai-studio/src/components/visualize/visualize-card.tsx b/apps/ai-studio/src/components/visualize/visualize-card.tsx index ce2bb4b27..96b909bfd 100644 --- a/apps/ai-studio/src/components/visualize/visualize-card.tsx +++ b/apps/ai-studio/src/components/visualize/visualize-card.tsx @@ -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'; @@ -22,7 +22,6 @@ type Props = { type VisualizeMode = VisualizeRenderer | 'auto'; const VALID_MODES = new Set(VISUALIZE_MODES); -const ADAPTABLE = new Set(['diagram', 'chart', 'table', 'json', 'stat-cards']); function EmptyState({ running }: { running: boolean }) { if (running) { @@ -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(null); - const [adapting, setAdapting] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); const contentRef = useRef(null); // Nodes/edges are static during a run, so snapshot reads are fine. @@ -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'; @@ -109,20 +84,20 @@ export function VisualizeCard({ props }: Props) {
{badge}
- contentRef.current} text={renderText} - disabled={adapting} + disabled={isAdapting} />
- {adapting ? ( + {isAdapting ? (
@@ -151,14 +126,14 @@ export function VisualizeCard({ props }: Props) { ) : ( )} - {expanded && ( + {isExpanded && ( setExpanded(false)} + onClose={() => setIsExpanded(false)} /> )}
diff --git a/apps/ai-studio/src/hooks/use-adapted-visualization.ts b/apps/ai-studio/src/hooks/use-adapted-visualization.ts new file mode 100644 index 000000000..701959242 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-adapted-visualization.ts @@ -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(['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(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, + }; +} diff --git a/apps/ai-studio/src/utils/adapt-visualization.ts b/apps/ai-studio/src/utils/adapt-visualization.ts index 1f7a94026..3be48f2a5 100644 --- a/apps/ai-studio/src/utils/adapt-visualization.ts +++ b/apps/ai-studio/src/utils/adapt-visualization.ts @@ -1,7 +1,7 @@ import { BACKEND_URL } from '../config'; import { getTurnstileToken } from '../security/turnstile'; -export async function adaptVisualization(content: string, format: string): Promise { +export async function adaptVisualization(content: string, format: string, signal?: AbortSignal): Promise { const token = await getTurnstileToken(); const response = await fetch(`${BACKEND_URL}/api/visualize/adapt`, { method: 'POST', @@ -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 };