/* * ============================================================================= * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform * ----------------------------------------------------------------------------- * File: client/src/hooks/useChatSession.ts * * 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, useRef, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useChatMutation } from "./useChatMutation"; import { useAnalytics } from "./useAnalytics"; import type { SearchResult } from "@shared/types"; import type { AgentStep } from "@/components/chat/agent-steps"; export interface SearchQuery { query: string; results?: SearchResult[]; } export interface ToolResult { tool: string; result: unknown; } export interface ConversationMessage { question: string; answer: string; } export interface FigureBatch { id: string; figures: string[]; output?: string; description?: string; } const FMP_TOOLS = [ 'get_company_profile', 'get_income_statement', 'get_balance_sheet', 'get_cash_flow', 'get_key_metrics', 'get_financial_ratios', 'get_stock_quote', 'get_historical_price', 'search_companies', 'get_stock_peers', 'get_financial_news', 'get_rsi', 'get_macd', 'get_ema', 'get_sma', 'get_adx', 'get_williams_r', 'get_cci', 'get_stochastic', 'get_economic_calendar', 'get_earnings_calendar', 'get_insider_trading', 'get_forex_quote', 'get_commodity_quotes', 'get_treasury_rates', 'get_economic_indicator', 'get_earnings_surprises', 'get_analyst_estimates', 'get_cot_report', 'get_press_releases', 'get_dividend_history', 'get_stock_split_history', 'get_ipo_calendar', 'create_plot', 'estimate_garch_volatility', 'calculate_var', 'optimize_portfolio', 'analyze_risk_metrics', 'firecrawl_scrape', 'firecrawl_crawl', ] as const; function resetChatState() { return { streamingAnswer: "", thinkingContent: "", sources: [] as SearchResult[], searchQueries: [] as SearchQuery[], toolResults: [] as ToolResult[], agentSteps: [] as AgentStep[], statusMessage: "", currentSearchIndex: 0, pythonCode: "", monteCarloResults: null as unknown, optionsPricingResults: null as unknown, customPythonFigures: [] as FigureBatch[], figureRegistry: new Map(), figureUrls: [] as string[], }; } export type ClaudeModel = | 'claude-fable-5' | 'claude-opus-4-6' | 'claude-opus-4-7' | 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' | 'gpt-5.6-sol' | 'gpt-5.6-terra' | 'gpt-5.6-luna' | 'gpt-5.5' | 'gpt-5.4' | 'gpt-5.2' | 'gpt-5.1' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano' | 'gpt-4.1' | 'gpt-4.1-mini' | 'gemini-3.5-flash' | 'gemini-3.1-pro-preview' | 'gemini-3-flash-preview' | 'gemini-3.1-flash-lite'; export function useChatSession() { const queryClient = useQueryClient(); // ─── Session state ────────────────────────────────────── const [currentSessionId, setCurrentSessionId] = useState(null); const [historyOpen, setHistoryOpen] = useState(false); const [conversationHistory, setConversationHistory] = useState([]); const [currentQuestion, setCurrentQuestion] = useState(""); const [selectedModel, setSelectedModel] = useState(() => { const stored = localStorage.getItem('vquant-model-v2') as ClaudeModel | null; return stored ?? 'claude-fable-5'; }); // ─── Chat response state ──────────────────────────────── const [streamingAnswer, setStreamingAnswer] = useState(""); const [thinkingContent, setThinkingContent] = useState(""); const [sources, setSources] = useState([]); const [searchQueries, setSearchQueries] = useState([]); const [toolResults, setToolResults] = useState([]); const [agentSteps, setAgentSteps] = useState([]); const [statusMessage, setStatusMessage] = useState(""); const [currentSearchIndex, setCurrentSearchIndex] = useState(0); const [pythonCode, setPythonCode] = useState(""); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Python executor returns untyped JSON const [monteCarloResults, setMonteCarloResults] = useState(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any const [optionsPricingResults, setOptionsPricingResults] = useState(null); const [customPythonFigures, setCustomPythonFigures] = useState([]); const [figureRegistry, setFigureRegistry] = useState>(new Map()); const [figureUrls, setFigureUrls] = useState([]); const resultsRef = useRef(null); // ─── Auth ─────────────────────────────────────────────── const { data: authData } = useQuery<{ user: { id: string; displayName: string } | null }>({ queryKey: ['/api/auth/me'], }); const currentUser = authData?.user ?? null; // ─── SSE event handler ────────────────────────────────── const chatMutation = useChatMutation((event) => { switch (event.type) { case 'text': setStreamingAnswer(prev => prev + event.content); break; case 'thinking': setThinkingContent(prev => prev + event.content); break; case 'sources': setSources(event.sources); break; case 'done': if (event.sessionId && !currentSessionId) { setCurrentSessionId(event.sessionId); } break; case 'status': setStatusMessage(event.message); break; case 'search_queries': setSearchQueries(event.queries.map((q: string) => ({ query: q }))); setStatusMessage(event.message); break; case 'search_progress': setCurrentSearchIndex(event.index); setStatusMessage(`Search ${event.index}/${event.total}: ${event.query}`); break; case 'search_result': setSearchQueries(prev => { const updated = [...prev]; if (updated[event.index - 1]) { updated[event.index - 1].results = event.results; } return updated; }); break; case 'tool_start': setAgentSteps(prev => [...prev, { tool: event.tool, status: 'running', input: event.input, }]); break; case 'tool_complete': setAgentSteps(prev => prev.map(step => step.tool === event.tool && step.status === 'running' ? { ...step, status: 'completed', duration: event.duration } : step, )); break; case 'tool_error': setAgentSteps(prev => prev.map(step => step.tool === event.tool && step.status === 'running' ? { ...step, status: 'error', error: event.error } : step, )); break; case 'python_code': setPythonCode(event.code); break; case 'custom_python_figures': { const figId = event.figureId || `fig-${Date.now()}`; const isPlotTool = figId.startsWith('plot-'); if (!isPlotTool) { setCustomPythonFigures(prev => [...prev, { id: figId, figures: event.figures || [], output: event.output, description: event.description, }]); } const urls = event.figureUrls || event.figures || []; if (urls.length > 0) { setFigureRegistry(prev => { const next = new Map(prev); urls.forEach((url: string, idx: number) => next.set(`${figId}-${idx}`, url)); return next; }); setFigureUrls(prev => [...prev, ...urls]); } break; } case 'tool_result': if (event.tool === 'run_monte_carlo_simulation') { setMonteCarloResults(event.result); } else if (event.tool === 'calculate_options_price') { setOptionsPricingResults(event.result); } else if (FMP_TOOLS.includes(event.tool)) { setToolResults(prev => [...prev, { tool: event.tool, result: event.result }]); } break; } }); // ─── Analytics ────────────────────────────────────────── const analyticsStatus = chatMutation.isPending ? 'generating' : chatMutation.isError ? 'error' : 'idle'; useAnalytics(analyticsStatus, chatMutation.isPending ? currentQuestion : undefined); // ─── Derived state ────────────────────────────────────── const hasSearched = streamingAnswer.length > 0 || chatMutation.isPending || chatMutation.isError || searchQueries.length > 0; // ─── Actions ──────────────────────────────────────────── const clearResponseState = useCallback(() => { setStreamingAnswer(""); setThinkingContent(""); setSources([]); setSearchQueries([]); setToolResults([]); setAgentSteps([]); setStatusMessage(""); setCurrentSearchIndex(0); setPythonCode(""); setMonteCarloResults(null); setOptionsPricingResults(null); setCustomPythonFigures([]); setFigureRegistry(new Map()); setFigureUrls([]); }, []); const handleSearch = useCallback((query: string, imageData?: string, imageMimeType?: string) => { if (currentQuestion && streamingAnswer) { setConversationHistory(prev => [...prev, { question: currentQuestion, answer: streamingAnswer, }]); } setCurrentQuestion(query); clearResponseState(); chatMutation.mutate({ query, history: currentQuestion && streamingAnswer ? [...conversationHistory, { question: currentQuestion, answer: streamingAnswer }] : conversationHistory, sessionId: currentSessionId || undefined, imageData, imageMimeType, model: selectedModel, }); }, [currentQuestion, streamingAnswer, conversationHistory, currentSessionId, clearResponseState, chatMutation, selectedModel]); const handleRetry = useCallback(() => { chatMutation.reset(); clearResponseState(); }, [chatMutation, clearResponseState]); const handleNewConversation = useCallback(() => { setCurrentSessionId(null); setConversationHistory([]); setCurrentQuestion(""); clearResponseState(); chatMutation.reset(); }, [clearResponseState, chatMutation]); const handleLogout = useCallback(async () => { await fetch('/api/auth/logout', { method: 'POST' }); queryClient.invalidateQueries({ queryKey: ['/api/auth/me'] }); queryClient.invalidateQueries({ queryKey: ['/api/sessions'] }); }, [queryClient]); const handleLoadSession = useCallback(async (sessionId: string) => { try { const response = await fetch(`/api/sessions/${sessionId}`); if (!response.ok) return; const session = await response.json(); setCurrentSessionId(session.sessionId); if (session.messages) { const msgs = typeof session.messages === 'string' ? JSON.parse(session.messages) : session.messages; setConversationHistory(msgs.map((m: { question?: string; answer?: string }) => ({ question: m.question || '', answer: m.answer || '', }))); } setHistoryOpen(false); } catch (error) { console.error('Failed to load session:', error); } }, []); const handleModelChange = useCallback((model: ClaudeModel) => { setSelectedModel(model); localStorage.setItem('vquant-model-v2', model); }, []); return { // Auth currentUser, handleLogout, // Session currentSessionId, historyOpen, setHistoryOpen, handleLoadSession, // Chat state conversationHistory, currentQuestion, streamingAnswer, thinkingContent, sources, searchQueries, toolResults, agentSteps, statusMessage, currentSearchIndex, pythonCode, monteCarloResults, optionsPricingResults, customPythonFigures, figureRegistry, figureUrls, resultsRef, // Model selection selectedModel, handleModelChange, // Mutation state chatMutation, hasSearched, // Actions handleSearch, handleRetry, handleNewConversation, }; }