SPB Git

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%
17.6 KB · 411 lines tsx
Raw Blame History
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 `![${alt}](${resolved})`;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