/* * ============================================================================= * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform * ----------------------------------------------------------------------------- * File: client/src/components/streaming-answer.tsx * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Website: https://www.spboucher.ai * Demo: https://www.vquant.ai * License: MIT (see LICENSE) * * Copyright © 2026 Simon-Pierre Boucher. All rights reserved. * ============================================================================= */ import { useState, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { ExternalLink, Check, Search, Loader2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { SearchResult } from "@shared/types"; interface SearchQuery { query: string; results?: SearchResult[]; } interface StreamingAnswerProps { content: string; sources?: SearchResult[]; isStreaming?: boolean; searchQueries?: SearchQuery[]; statusMessage?: string; currentSearchIndex?: number; figureRegistry?: Map; // Map of figure IDs to base64 images figureUrls?: string[]; // Actual URLs of generated figures } export function StreamingAnswer({ content, sources = [], isStreaming = false, searchQueries = [], statusMessage = "", currentSearchIndex = 0, figureRegistry = new Map(), figureUrls = [] }: StreamingAnswerProps) { const streamingCursorRef = useRef(null); // Build an ordered list of resolved figure sources (data URIs or URLs) // This is the single source of truth for all figure rendering const resolvedFigures = useMemo(() => { const sources: string[] = []; // Priority 1: figureUrls (may be data URIs from shared reports or file URLs from live session) if (figureUrls.length > 0) { sources.push(...figureUrls); } // Priority 2: figureRegistry values else if (figureRegistry.size > 0) { figureRegistry.forEach((value) => { if (!value) return; if (value.startsWith('data:') || value.startsWith('/') || value.startsWith('http')) { sources.push(value); } else { // Raw base64 — wrap as data URI sources.push(`data:image/png;base64,${value}`); } }); } return sources; }, [figureRegistry, figureUrls]); // Replace ALL markdown image references with resolved figure sources const processedContent = useMemo(() => { if (resolvedFigures.length === 0) return content; let processed = content; const allImagePattern = /!\[([^\]]*)\]\(([^)]+)\)/gi; let counter = 0; processed = processed.replace(allImagePattern, (match, alt, url) => { // Keep external URLs (http) and already-working data URIs if (url.startsWith('http') || url.startsWith('data:')) { return match; } // Any local path (/figures/, /plots/, fig://, hallucinated, etc.) // → replace with next resolved figure source if (counter < resolvedFigures.length) { const resolved = resolvedFigures[counter]; counter++; return `![${alt}](${resolved})`; } return match; }); return processed; }, [content, resolvedFigures]); // Display content directly without artificial delay - streaming is already real-time from server const displayedContent = processedContent; // Auto-scroll to keep streaming cursor visible useEffect(() => { if (isStreaming && streamingCursorRef.current) { streamingCursorRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } }, [content, isStreaming]); return ( {/* Search Queries Section */} {searchQueries.length > 0 && (

Requêtes de recherche optimisées

{searchQueries.map((searchQuery, index) => (
{searchQuery.results ? ( ) : currentSearchIndex === index + 1 ? ( ) : (
{index + 1}
)}

{searchQuery.query}

{searchQuery.results && searchQuery.results.length > 0 && (
{searchQuery.results.slice(0, 3).map((result, resultIndex) => ( {new URL(result.url).hostname} ))}
)}
))}
{statusMessage && ( {statusMessage} )}
)} {/* Answer Section */} {displayedContent && (

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, p: ({ children }) =>

{children}

, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, li: ({ children }) =>
  • {children}
  • , a: ({ href, children }) => { // Don't create clickable links for fig:// references (they're for images only) if (href?.startsWith('fig://')) { return <>{children}; } return ( {children} ); }, strong: ({ children }) => {children}, code: ({ children }) => ( {children} ), pre: ({ children }) => (
                        {children}
                      
    ), blockquote: ({ children }) => (
    {children}
    ), img: ({ src, alt }) => { let imgSrc = src || ''; // Step 1: Resolve fig:// from registry if (imgSrc.startsWith('fig://')) { const figureId = imgSrc.replace('fig://', ''); const val = figureRegistry.get(figureId); if (val) { imgSrc = val.startsWith('/') || val.startsWith('http') || val.startsWith('data:') ? val : `data:image/png;base64,${val}`; } } // Step 2: If still unresolved (empty, fig://, or broken local path), // try resolvedFigures by figure number in alt text if (!imgSrc || imgSrc.startsWith('fig://') || (imgSrc.startsWith('/') && resolvedFigures.length > 0)) { const figMatch = (alt || '').match(/(\d+)/); if (figMatch) { const idx = parseInt(figMatch[1], 10) - 1; if (idx >= 0 && idx < resolvedFigures.length) { imgSrc = resolvedFigures[idx]; } } } // Step 3: Still nothing? Take first available resolved figure if ((!imgSrc || imgSrc.startsWith('fig://')) && resolvedFigures.length > 0) { imgSrc = resolvedFigures[0]; } // Render if we have a valid source if (imgSrc && !imgSrc.startsWith('fig://')) { return (
    {alt { const img = e.currentTarget; const retryCount = parseInt(img.dataset.retryCount || '0', 10); // Try resolvedFigures one by one as fallback if (retryCount < resolvedFigures.length) { const candidate = resolvedFigures[retryCount]; // Skip if it's the same URL that just failed if (candidate === img.src || candidate === imgSrc) { img.dataset.retryCount = String(retryCount + 1); if (retryCount + 1 < resolvedFigures.length) { img.src = resolvedFigures[retryCount + 1]; img.dataset.retryCount = String(retryCount + 2); return; } } else { img.dataset.retryCount = String(retryCount + 1); img.src = candidate; return; } } // Exhausted all options — hide image img.style.display = 'none'; const fallback = img.parentElement?.querySelector('.figure-fallback'); if (fallback) (fallback as HTMLElement).style.display = 'flex'; }} />

    Figure non disponible

    {alt && (

    {alt}

    )}
    ); } // Truly no figure data available — hide silently // (figures are shown in the Python Gallery section below) return null; }, }} > {displayedContent}
    {isStreaming && ( )}
    {sources.length > 0 && (

    Sources

    {sources.map((source, index) => (
    {source.favicon ? ( { e.currentTarget.style.display = 'none'; }} /> ) : (
    {new URL(source.url).hostname[0].toUpperCase()}
    )}
    {source.title}

    {new URL(source.url).hostname}

    Verified
    ))}
    )}
    )}
    ); }