feat: chatbot
This commit is contained in:
@@ -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 />
|
||||
|
||||
213
frontend/src/components/ChatWidget.tsx
Normal file
213
frontend/src/components/ChatWidget.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user