feat: chatbot

This commit is contained in:
OpenSquared
2026-07-14 17:31:34 +02:00
parent 2dc4d8e91b
commit c4de6957ea
9 changed files with 639 additions and 0 deletions

View File

@@ -37,6 +37,7 @@ import MacroSeriesPage from './pages/MacroSeriesPage'
import EuroSimulator from './pages/EuroSimulator'
import CausalLab from './pages/CausalLab'
import InstrumentModels from './pages/InstrumentModels'
import ChatWidget from './components/ChatWidget'
import { useCycleWatcher } from './hooks/useApi'
// ── Keep-alive pages ──────────────────────────────────────────────────────────
@@ -157,6 +158,7 @@ function AppInner() {
return (
<div className="flex h-screen overflow-hidden">
<GlobalWatcher />
<ChatWidget />
<Sidebar />
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<TabBar />

View File

@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from 'react'
import { Bot, X, Send, RefreshCw, Trash2, Settings2 } from 'lucide-react'
import clsx from 'clsx'
import {
useChatContextBlocks, useChatHistory, useSendChatMessage, useClearChatSession, type ChatMessage,
} from '../hooks/useApi'
const BLOCK_LABELS: Record<string, string> = {
portfolio: 'Portefeuille',
geo_news: 'Risque géopolitique',
macro: 'Régime macro',
patterns: 'Patterns',
options_iv: 'Options / IV',
tech_indicators: 'Indicateurs techniques',
wavelet_signals: 'Signaux ondelettes',
watchlist_quotes: 'Watchlist',
economic_calendar: 'Calendrier économique',
institutional: 'Rapports institutionnels',
super_context: 'Super Contexte',
var_risk: 'VaR / Risque',
}
function getSessionId(): string {
let id = localStorage.getItem('ai_chat_session_id')
if (!id) {
id = (crypto as any).randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`
localStorage.setItem('ai_chat_session_id', id)
}
return id
}
function getEnabledBlocks(allBlocks: string[]): Set<string> {
try {
const raw = localStorage.getItem('ai_chat_enabled_blocks')
if (raw) return new Set(JSON.parse(raw))
} catch { /* ignore */ }
return new Set(allBlocks)
}
export default function ChatWidget() {
const [open, setOpen] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [input, setInput] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([])
const [pendingRefresh, setPendingRefresh] = useState(false)
const sessionId = useRef(getSessionId()).current
const scrollRef = useRef<HTMLDivElement>(null)
const { data: allBlocks } = useChatContextBlocks()
const [enabledBlocks, setEnabledBlocks] = useState<Set<string>>(new Set())
useEffect(() => {
if (allBlocks) setEnabledBlocks(getEnabledBlocks(allBlocks))
}, [allBlocks])
const { data: history } = useChatHistory(sessionId)
useEffect(() => {
if (history) setMessages(history)
}, [history])
const { mutateAsync: sendMessage, isPending } = useSendChatMessage()
const { mutateAsync: clearSession } = useClearChatSession()
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages, isPending])
const toggleBlock = (key: string) => {
const next = new Set(enabledBlocks)
if (next.has(key)) next.delete(key); else next.add(key)
setEnabledBlocks(next)
localStorage.setItem('ai_chat_enabled_blocks', JSON.stringify([...next]))
}
const send = async () => {
const text = input.trim()
if (!text || isPending) return
setInput('')
setMessages(prev => [...prev, { role: 'user', content: text }])
try {
const res = await sendMessage({
session_id: sessionId,
message: text,
enabled_blocks: [...enabledBlocks],
refresh_context: pendingRefresh,
})
setPendingRefresh(false)
setMessages(prev => [...prev, { role: 'assistant', content: res.reply }])
} catch (e: any) {
const msg = e?.response?.data?.detail ?? 'Erreur — vérifie la clé API OpenAI dans Configuration.'
setMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${msg}` }])
}
}
const handleClear = async () => {
await clearSession(sessionId)
setMessages([])
}
return (
<>
{/* Fixed toggle button — mounted once at the app root, survives all route changes */}
<button
onClick={() => setOpen(o => !o)}
title="Assistant IA"
className="fixed top-4 right-5 z-50 w-11 h-11 rounded-full bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/40 flex items-center justify-center transition-all hover:scale-105"
>
<Bot className="w-5 h-5" />
</button>
<div
className={clsx(
'fixed inset-0 bg-black/40 z-40 transition-opacity duration-200',
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none',
)}
onClick={() => setOpen(false)}
/>
<div
className={clsx(
'fixed right-0 top-0 h-full w-[420px] max-w-[92vw] bg-dark-800 border-l border-slate-700/40 z-50 flex flex-col shadow-2xl transition-transform duration-200 ease-out',
open ? 'translate-x-0' : 'translate-x-full',
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-700/40 shrink-0">
<div className="w-7 h-7 rounded-full bg-blue-900/40 flex items-center justify-center">
<Bot className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="text-sm font-semibold text-white">Assistant IA</div>
<div className="text-[10px] text-slate-500">Discussion seule aucune action déclenchée</div>
</div>
<div className="ml-auto flex items-center gap-1">
<button onClick={() => setShowSettings(s => !s)} title="Contexte transmis"
className={clsx('p-1.5 rounded hover:bg-dark-700 transition-colors', showSettings ? 'text-blue-400' : 'text-slate-500')}>
<Settings2 className="w-4 h-4" />
</button>
<button onClick={() => setOpen(false)} className="p-1.5 rounded hover:bg-dark-700 text-slate-500 transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Context settings */}
{showSettings && (
<div className="px-4 py-3 border-b border-slate-700/40 shrink-0 max-h-52 overflow-y-auto">
<div className="text-[10px] text-slate-500 mb-2">Blocs de contexte transmis à l'IA (tout par défaut) :</div>
<div className="grid grid-cols-2 gap-1.5">
{(allBlocks ?? []).map(b => (
<label key={b} className="flex items-center gap-1.5 text-[10px] text-slate-300 cursor-pointer">
<input type="checkbox" checked={enabledBlocks.has(b)} onChange={() => toggleBlock(b)} />
{BLOCK_LABELS[b] ?? b}
</label>
))}
</div>
</div>
)}
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<div className="text-xs text-slate-600 text-center mt-8">
Pose une question sur ta situation actuelle — portefeuille, risque géopolitique, régime macro,
Options Lab, signaux ondelettes... L'IA a accès à un instantané complet, en lecture seule.
</div>
)}
{messages.map((m, i) => (
<div key={i} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
<div className={clsx(
'max-w-[85%] rounded-lg px-3 py-2 text-xs whitespace-pre-wrap leading-relaxed',
m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-dark-700/80 border border-slate-700/40 text-slate-200',
)}>
{m.content}
</div>
</div>
))}
{isPending && (
<div className="flex justify-start">
<div className="bg-dark-700/80 border border-slate-700/40 rounded-lg px-3 py-2 text-xs text-slate-500 italic">
🧠 Réflexion
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 px-4 py-1.5 border-t border-slate-700/30 shrink-0 text-[10px]">
<button onClick={() => setPendingRefresh(true)}
className={clsx('flex items-center gap-1 transition-colors', pendingRefresh ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300')}>
<RefreshCw className="w-3 h-3" /> {pendingRefresh ? 'Contexte sera rafraîchi' : 'Rafraîchir le contexte'}
</button>
<button onClick={handleClear} className="ml-auto flex items-center gap-1 text-slate-500 hover:text-red-400 transition-colors">
<Trash2 className="w-3 h-3" /> Effacer
</button>
</div>
{/* Input */}
<div className="flex items-center gap-2 p-3 border-t border-slate-700/40 shrink-0">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
placeholder="Écris ta question…"
className="flex-1 bg-dark-900 border border-slate-700/40 rounded-lg px-3 py-2 text-xs text-white placeholder:text-slate-600 focus:outline-none focus:border-blue-500/50"
/>
<button onClick={send} disabled={isPending || !input.trim()}
className="w-8 h-8 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white flex items-center justify-center shrink-0 transition-colors">
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</>
)
}

View File

@@ -1426,3 +1426,33 @@ export function useScoreText() {
api.post('/specialist-desks/score-text', body).then(r => r.data) as Promise<TextSentimentResult>,
})
}
// ── AI Chat widget — free-form, read-only, context-aware conversation ────────
export interface ChatMessage { role: 'user' | 'assistant'; content: string; created_at?: string }
export const useChatContextBlocks = () =>
useQuery({
queryKey: ['ai-chat-blocks'],
queryFn: () => api.get('/ai-chat/blocks').then(r => r.data.blocks as string[]),
staleTime: Infinity,
})
export const useChatHistory = (sessionId: string) =>
useQuery({
queryKey: ['ai-chat-history', sessionId],
queryFn: () => api.get('/ai-chat/history', { params: { session_id: sessionId } }).then(r => r.data.messages as ChatMessage[]),
enabled: !!sessionId,
staleTime: Infinity,
})
export const useSendChatMessage = () =>
useMutation({
mutationFn: (body: { session_id: string; message: string; enabled_blocks?: string[]; refresh_context?: boolean }) =>
api.post('/ai-chat/', body).then(r => r.data as { reply: string; blocks_included: string[] }),
})
export const useClearChatSession = () =>
useMutation({
mutationFn: (sessionId: string) => api.post('/ai-chat/clear', { session_id: sessionId }).then(r => r.data),
})