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/chat/custom-python-figure.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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";18import { Badge } from "@/components/ui/badge";19import { useState, useEffect } from "react";20import { ChevronDown, ChevronUp, Download } from "lucide-react";21import { Button } from "@/components/ui/button";2223interface FigureBatch {24 id: string;25 figures: string[]; // URLs (/figures/xxx.png) or base64 encoded images26 output?: string; // Text output from Python27 description?: string;28}2930function figureSrc(figure: string): string {31 // If it starts with / or http, it's a URL — use directly32 if (figure.startsWith('/') || figure.startsWith('http')) return figure;33 // Otherwise treat as base6434 return `data:image/png;base64,${figure}`;35}3637interface CustomPythonFigureGalleryProps {38 figureBatches: FigureBatch[]; // Array of figure batches39}4041export function CustomPythonFigure({ figureBatches }: CustomPythonFigureGalleryProps) {42 // Initialize with all batches expanded43 const [expandedBatches, setExpandedBatches] = useState<Set<string>>(44 new Set(figureBatches.map(b => b.id))45 );4647 // When new batches are added, expand them automatically48 useEffect(() => {49 const allIds = new Set(figureBatches.map(b => b.id));50 setExpandedBatches(allIds);51 }, [figureBatches.length]); // Update when number of batches changes5253 if (!figureBatches || figureBatches.length === 0) {54 return null;55 }5657 const totalFigures = figureBatches.reduce((sum, batch) => sum + batch.figures.length, 0);5859 const toggleBatch = (batchId: string) => {60 setExpandedBatches(prev => {61 const newSet = new Set(prev);62 if (newSet.has(batchId)) {63 newSet.delete(batchId);64 } else {65 newSet.add(batchId);66 }67 return newSet;68 });69 };7071 return (72 <Card className="w-full">73 <CardHeader>74 <CardTitle className="flex items-center gap-2">75 <span className="text-2xl">🐍</span>76 Galerie d'Analyses Python77 <Badge variant="secondary" className="ml-auto">78 {totalFigures} figure{totalFigures > 1 ? 's' : ''}79 </Badge>80 </CardTitle>81 <CardDescription>82 {figureBatches.length} analyse{figureBatches.length > 1 ? 's' : ''} Python générée{figureBatches.length > 1 ? 's' : ''}83 </CardDescription>84 </CardHeader>85 <CardContent className="space-y-4">86 {figureBatches.map((batch, batchIndex) => {87 const isExpanded = expandedBatches.has(batch.id);88 const batchNumber = batchIndex + 1;8990 return (91 <div key={batch.id} className="border rounded-lg overflow-hidden">92 {/* Batch Header - Collapsible */}93 <div94 className="flex items-center justify-between p-4 bg-muted/30 cursor-pointer hover:bg-muted/50 transition-colors"95 onClick={() => toggleBatch(batch.id)}96 >97 <div className="flex items-center gap-3">98 <Badge variant="outline">#{batchNumber}</Badge>99 <div>100 <div className="font-semibold text-sm">101 {batch.description || `Analyse Python #${batchNumber}`}102 </div>103 <div className="text-xs text-muted-foreground">104 {batch.figures.length} figure{batch.figures.length > 1 ? 's' : ''}105 {batch.output && ` • ${batch.output.split('\n').length} lignes de résultats`}106 </div>107 </div>108 </div>109 <Button variant="ghost" size="sm">110 {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}111 </Button>112 </div>113114 {/* Batch Content - Expandable */}115 {isExpanded && (116 <div className="p-4 space-y-4">117 {/* Text output if available */}118 {batch.output && batch.output.trim() && (119 <div className="bg-muted/50 p-4 rounded-lg">120 <h4 className="font-semibold mb-2 text-sm">📊 Résultats</h4>121 <pre className="text-sm font-mono whitespace-pre-wrap overflow-x-auto">122 {batch.output}123 </pre>124 </div>125 )}126127 {/* Figures Grid */}128 <div className="space-y-3">129 <div className="flex items-center justify-between">130 <h4 className="font-semibold text-sm">131 📈 Graphique{batch.figures.length > 1 ? 's' : ''}132 </h4>133 {batch.figures.length > 1 && (134 <Button135 variant="outline"136 size="sm"137 onClick={(e) => {138 e.stopPropagation();139 // Download all figures as zip would be nice, but for now just show button140 }}141 className="text-xs"142 >143 <Download className="w-3 h-3 mr-1" />144 Tout télécharger145 </Button>146 )}147 </div>148149 <div className={`grid gap-4 ${batch.figures.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>150 {batch.figures.map((figure, figIndex) => {151 const figureId = `${batch.id}-${figIndex}`;152153 return (154 <div key={figIndex} className="border rounded-lg overflow-hidden bg-card group relative">155 {/* Figure ID badge for reference */}156 <div className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">157 <Badge variant="secondary" className="text-xs font-mono">158 {figureId}159 </Badge>160 </div>161162 <img163 src={figureSrc(figure)}164 alt={`Figure ${batchNumber}.${figIndex + 1}`}165 className="w-full h-auto cursor-pointer"166 style={{ maxHeight: '600px', objectFit: 'contain' }}167 onError={(e) => {168 const img = e.currentTarget;169 if (!img.dataset.retried) {170 img.dataset.retried = 'true';171 // Retry with cache-busted URL172 const src = figureSrc(figure);173 img.src = src.includes('?') ? `${src}&t=${Date.now()}` : `${src}?t=${Date.now()}`;174 } else {175 img.style.display = 'none';176 const fallback = img.parentElement?.querySelector('.figure-fallback');177 if (fallback) (fallback as HTMLElement).style.display = 'flex';178 }179 }}180 onClick={() => {181 const win = window.open();182 if (win) {183 win.document.write(`<img src="${figureSrc(figure)}" style="max-width: 100%; height: auto;" />`);184 win.document.title = `Figure ${batchNumber}.${figIndex + 1}`;185 }186 }}187 />188 <div className="figure-fallback items-center justify-center p-8 bg-muted/30" style={{ display: 'none' }}>189 <p className="text-sm text-muted-foreground">Figure non disponible</p>190 </div>191192 {/* Figure info footer */}193 <div className="p-2 bg-muted/30 border-t flex items-center justify-between">194 <div className="text-xs text-muted-foreground">195 Figure {batchNumber}.{figIndex + 1}196 </div>197 <a198 href={figureSrc(figure)}199 download={`vibequant-figure-${figureId}.png`}200 className="text-xs text-primary hover:underline flex items-center gap-1"201 onClick={(e) => e.stopPropagation()}202 >203 <Download className="w-3 h-3" />204 Télécharger205 </a>206 </div>207 </div>208 );209 })}210 </div>211 </div>212 </div>213 )}214 </div>215 );216 })}217 </CardContent>218 </Card>219 );220}221