spb/vquant Public MIT
VibeQuant — AI-powered institutional-grade financial intelligence platform.
TypeScript 84.3%
Python 11.7%
JavaScript 1.6%
CSS 1.5%
HTML 0.7%
1/*2 * =============================================================================3 * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform4 * -----------------------------------------------------------------------------5 * File: client/src/components/streaming-answer.tsx6 *7 * Author: Simon-Pierre Boucher8 * Contact: contact@spboucher.ai9 * Website: https://www.spboucher.ai10 * Demo: https://www.vquant.ai11 * License: MIT (see LICENSE)12 *13 * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.14 * =============================================================================15 */1617import { useState, useEffect, useRef, useMemo } from "react";18import { motion, AnimatePresence } from "framer-motion";19import { ExternalLink, Check, Search, Loader2 } from "lucide-react";20import { Badge } from "@/components/ui/badge";21import ReactMarkdown from "react-markdown";22import remarkGfm from "remark-gfm";23import type { SearchResult } from "@shared/types";2425interface SearchQuery {26 query: string;27 results?: SearchResult[];28}2930interface StreamingAnswerProps {31 content: string;32 sources?: SearchResult[];33 isStreaming?: boolean;34 searchQueries?: SearchQuery[];35 statusMessage?: string;36 currentSearchIndex?: number;37 figureRegistry?: Map<string, string>; // Map of figure IDs to base64 images38 figureUrls?: string[]; // Actual URLs of generated figures39}4041export function StreamingAnswer({42 content,43 sources = [],44 isStreaming = false,45 searchQueries = [],46 statusMessage = "",47 currentSearchIndex = 0,48 figureRegistry = new Map(),49 figureUrls = []50}: StreamingAnswerProps) {51 const streamingCursorRef = useRef<HTMLSpanElement>(null);5253 // Build an ordered list of resolved figure sources (data URIs or URLs)54 // This is the single source of truth for all figure rendering55 const resolvedFigures = useMemo(() => {56 const sources: string[] = [];5758 // Priority 1: figureUrls (may be data URIs from shared reports or file URLs from live session)59 if (figureUrls.length > 0) {60 sources.push(...figureUrls);61 }62 // Priority 2: figureRegistry values63 else if (figureRegistry.size > 0) {64 figureRegistry.forEach((value) => {65 if (!value) return;66 if (value.startsWith('data:') || value.startsWith('/') || value.startsWith('http')) {67 sources.push(value);68 } else {69 // Raw base64 — wrap as data URI70 sources.push(`data:image/png;base64,${value}`);71 }72 });73 }7475 return sources;76 }, [figureRegistry, figureUrls]);7778 // Replace ALL markdown image references with resolved figure sources79 const processedContent = useMemo(() => {80 if (resolvedFigures.length === 0) return content;8182 let processed = content;83 const allImagePattern = /!\[([^\]]*)\]\(([^)]+)\)/gi;84 let counter = 0;8586 processed = processed.replace(allImagePattern, (match, alt, url) => {87 // Keep external URLs (http) and already-working data URIs88 if (url.startsWith('http') || url.startsWith('data:')) {89 return match;90 }9192 // Any local path (/figures/, /plots/, fig://, hallucinated, etc.)93 // → replace with next resolved figure source94 if (counter < resolvedFigures.length) {95 const resolved = resolvedFigures[counter];96 counter++;97 return ``;98 }99 return match;100 });101102 return processed;103 }, [content, resolvedFigures]);104105 // Display content directly without artificial delay - streaming is already real-time from server106 const displayedContent = processedContent;107108 // Auto-scroll to keep streaming cursor visible109 useEffect(() => {110 if (isStreaming && streamingCursorRef.current) {111 streamingCursorRef.current.scrollIntoView({112 behavior: 'smooth',113 block: 'nearest',114 inline: 'nearest'115 });116 }117 }, [content, isStreaming]);118119 return (120 <motion.div121 initial={{ opacity: 0, y: 8 }}122 animate={{ opacity: 1, y: 0 }}123 transition={{ duration: 0.3 }}124 className="w-full max-w-5xl mx-auto space-y-6"125 data-testid="streaming-answer"126 >127 {/* Search Queries Section */}128 {searchQueries.length > 0 && (129 <motion.div130 initial={{ opacity: 0, y: 12, scale: 0.95 }}131 animate={{ opacity: 1, y: 0, scale: 1 }}132 transition={{ duration: 0.5, ease: [0.4, 0, 0.2, 1] }}133 className="bg-card border border-border rounded-xl p-6"134 data-testid="search-queries-section"135 >136 <div className="flex items-center gap-2 mb-4">137 <Search className="w-5 h-5 text-primary" />138 <h3 className="text-lg font-semibold">Requêtes de recherche optimisées</h3>139 </div>140 <div className="space-y-3">141 {searchQueries.map((searchQuery, index) => (142 <motion.div143 key={index}144 initial={{ opacity: 0, x: -20 }}145 animate={{ opacity: 1, x: 0 }}146 transition={{ delay: index * 0.1 }}147 className="flex items-start gap-3 p-4 bg-muted/50 rounded-lg border border-border/50"148 data-testid={`search-query-${index}`}149 >150 <div className="flex-shrink-0">151 {searchQuery.results ? (152 <Check className="w-5 h-5 text-green-500 mt-0.5" />153 ) : currentSearchIndex === index + 1 ? (154 <Loader2 className="w-5 h-5 text-primary animate-spin mt-0.5" />155 ) : (156 <div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center mt-0.5">157 <span className="text-xs font-semibold">{index + 1}</span>158 </div>159 )}160 </div>161 <div className="flex-1 min-w-0">162 <p className="text-sm font-medium text-foreground">163 {searchQuery.query}164 </p>165 {searchQuery.results && searchQuery.results.length > 0 && (166 <div className="mt-2 flex flex-wrap gap-2">167 {searchQuery.results.slice(0, 3).map((result, resultIndex) => (168 <a169 key={resultIndex}170 href={result.url}171 target="_blank"172 rel="noopener noreferrer"173 className="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 px-3 py-1.5 rounded-full bg-background/50 hover:bg-background"174 data-testid={`search-result-link-${index}-${resultIndex}`}175 >176 <ExternalLink className="w-3 h-3" />177 {new URL(result.url).hostname}178 </a>179 ))}180 </div>181 )}182 </div>183 </motion.div>184 ))}185 </div>186 {statusMessage && (187 <motion.div188 initial={{ opacity: 0 }}189 animate={{ opacity: 1 }}190 className="mt-4 text-sm text-muted-foreground flex items-center gap-2"191 data-testid="status-message"192 >193 {statusMessage}194 </motion.div>195 )}196 </motion.div>197 )}198199 {/* Answer Section */}200 {displayedContent && (201 <motion.div202 initial={{ opacity: 0, y: 12, scale: 0.95 }}203 animate={{ opacity: 1, y: 0, scale: 1 }}204 transition={{ duration: 0.5, ease: [0.4, 0, 0.2, 1], delay: 0.1 }}205 className="bg-card border border-border rounded-xl p-6 md:p-8">206 <div className="prose prose-lg max-w-none dark:prose-invert">207 <ReactMarkdown208 remarkPlugins={[remarkGfm]}209 components={{210 h1: ({ children }) => <h1 className="text-3xl font-bold text-foreground mb-4">{children}</h1>,211 h2: ({ children }) => <h2 className="text-2xl font-semibold text-foreground mt-6 mb-3">{children}</h2>,212 h3: ({ children }) => <h3 className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h3>,213 p: ({ children }) => <p className="text-foreground leading-relaxed mb-4">{children}</p>,214 ul: ({ children }) => <ul className="text-foreground list-disc list-inside space-y-2 mb-4">{children}</ul>,215 ol: ({ children }) => <ol className="text-foreground list-decimal list-inside space-y-2 mb-4">{children}</ol>,216 li: ({ children }) => <li className="text-foreground">{children}</li>,217 a: ({ href, children }) => {218 // Don't create clickable links for fig:// references (they're for images only)219 if (href?.startsWith('fig://')) {220 return <>{children}</>;221 }222223 return (224 <a225 href={href}226 className="text-primary hover:underline font-medium"227 target="_blank"228 rel="noopener noreferrer"229 >230 {children}231 </a>232 );233 },234 strong: ({ children }) => <strong className="font-bold text-foreground">{children}</strong>,235 code: ({ children }) => (236 <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-foreground">237 {children}238 </code>239 ),240 pre: ({ children }) => (241 <pre className="bg-muted p-4 rounded-lg overflow-x-auto mb-4">242 {children}243 </pre>244 ),245 blockquote: ({ children }) => (246 <blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground my-4">247 {children}248 </blockquote>249 ),250 img: ({ src, alt }) => {251 let imgSrc = src || '';252253 // Step 1: Resolve fig:// from registry254 if (imgSrc.startsWith('fig://')) {255 const figureId = imgSrc.replace('fig://', '');256 const val = figureRegistry.get(figureId);257 if (val) {258 imgSrc = val.startsWith('/') || val.startsWith('http') || val.startsWith('data:')259 ? val260 : `data:image/png;base64,${val}`;261 }262 }263264 // Step 2: If still unresolved (empty, fig://, or broken local path),265 // try resolvedFigures by figure number in alt text266 if (!imgSrc || imgSrc.startsWith('fig://') || (imgSrc.startsWith('/') && resolvedFigures.length > 0)) {267 const figMatch = (alt || '').match(/(\d+)/);268 if (figMatch) {269 const idx = parseInt(figMatch[1], 10) - 1;270 if (idx >= 0 && idx < resolvedFigures.length) {271 imgSrc = resolvedFigures[idx];272 }273 }274 }275276 // Step 3: Still nothing? Take first available resolved figure277 if ((!imgSrc || imgSrc.startsWith('fig://')) && resolvedFigures.length > 0) {278 imgSrc = resolvedFigures[0];279 }280281 // Render if we have a valid source282 if (imgSrc && !imgSrc.startsWith('fig://')) {283 return (284 <div className="my-6 border-2 border-primary/20 rounded-xl overflow-hidden shadow-lg">285 <img286 key={imgSrc}287 src={imgSrc}288 alt={alt || 'Python generated figure'}289 className="w-full h-auto cursor-default"290 style={{ maxHeight: '600px', objectFit: 'contain' }}291 loading="lazy"292 onError={(e) => {293 const img = e.currentTarget;294 const retryCount = parseInt(img.dataset.retryCount || '0', 10);295296 // Try resolvedFigures one by one as fallback297 if (retryCount < resolvedFigures.length) {298 const candidate = resolvedFigures[retryCount];299 // Skip if it's the same URL that just failed300 if (candidate === img.src || candidate === imgSrc) {301 img.dataset.retryCount = String(retryCount + 1);302 if (retryCount + 1 < resolvedFigures.length) {303 img.src = resolvedFigures[retryCount + 1];304 img.dataset.retryCount = String(retryCount + 2);305 return;306 }307 } else {308 img.dataset.retryCount = String(retryCount + 1);309 img.src = candidate;310 return;311 }312 }313314 // Exhausted all options — hide image315 img.style.display = 'none';316 const fallback = img.parentElement?.querySelector('.figure-fallback');317 if (fallback) (fallback as HTMLElement).style.display = 'flex';318 }}319 />320 <div className="figure-fallback items-center justify-center p-8 bg-muted/30" style={{ display: 'none' }}>321 <p className="text-sm text-muted-foreground">Figure non disponible</p>322 </div>323 {alt && (324 <div className="p-3 bg-muted/50 border-t border-primary/10">325 <p className="text-sm text-center font-medium">{alt}</p>326 </div>327 )}328 </div>329 );330 }331332 // Truly no figure data available — hide silently333 // (figures are shown in the Python Gallery section below)334 return null;335 },336 }}337 >338 {displayedContent}339 </ReactMarkdown>340 {isStreaming && (341 <motion.span342 ref={streamingCursorRef}343 animate={{ opacity: [1, 0, 1] }}344 transition={{ duration: 0.8, repeat: Infinity }}345 className="inline-block w-2 h-5 bg-primary ml-1"346 />347 )}348 </div>349350 {sources.length > 0 && (351 <div className="mt-8 pt-6 border-t border-border">352 <h3 className="text-sm font-medium text-muted-foreground mb-4">Sources</h3>353 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">354 <AnimatePresence>355 {sources.map((source, index) => (356 <motion.a357 key={source.url}358 href={source.url}359 target="_blank"360 rel="noopener noreferrer"361 initial={{ opacity: 0, x: -20 }}362 animate={{ opacity: 1, x: 0 }}363 transition={{ delay: index * 0.1 }}364 className="group flex items-start gap-3 p-4 bg-muted/30 hover:bg-muted/50 rounded-lg border border-transparent hover:border-primary/30 transition-all hover-elevate"365 data-testid={`link-source-${index}`}366 >367 <div className="flex-shrink-0 mt-1">368 {source.favicon ? (369 <img370 src={source.favicon}371 alt=""372 className="w-5 h-5 rounded"373 onError={(e) => {374 e.currentTarget.style.display = 'none';375 }}376 />377 ) : (378 <div className="w-5 h-5 rounded bg-primary/20 flex items-center justify-center">379 <span className="text-xs font-mono text-primary">380 {new URL(source.url).hostname[0].toUpperCase()}381 </span>382 </div>383 )}384 </div>385 <div className="flex-1 min-w-0">386 <div className="flex items-center gap-2 mb-1">387 <span className="text-sm font-medium text-foreground truncate">388 {source.title}389 </span>390 <ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />391 </div>392 <p className="text-xs text-muted-foreground font-mono truncate">393 {new URL(source.url).hostname}394 </p>395 </div>396 <Badge variant="secondary" className="flex-shrink-0 no-default-hover-elevate no-default-active-elevate">397 <Check className="w-3 h-3 mr-1" />398 Verified399 </Badge>400 </motion.a>401 ))}402 </AnimatePresence>403 </div>404 </div>405 )}406 </motion.div>407 )}408 </motion.div>409 );410}411