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

Binary file not shown.

View File

@@ -19,6 +19,7 @@ from routers import causal_lab as causal_lab_router
from routers import instrument_models as instrument_models_router
from routers import instruments_watchlist as instruments_watchlist_router
from routers import wavelet as wavelet_router
from routers import ai_chat as ai_chat_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -237,6 +238,7 @@ app.include_router(causal_lab_router.router)
app.include_router(instrument_models_router.router)
app.include_router(instruments_watchlist_router.router)
app.include_router(wavelet_router.router)
app.include_router(ai_chat_router.router)
@app.get("/")

View File

@@ -0,0 +1,60 @@
"""
Free-form, read-only chat with GPT-4o about the current cockpit state.
No function-calling — this endpoint can never trigger an action.
"""
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.ai_chat_context import CONTEXT_BLOCKS
router = APIRouter(prefix="/api/ai-chat", tags=["ai-chat"])
class ChatMessageBody(BaseModel):
session_id: str
message: str
enabled_blocks: Optional[List[str]] = None
refresh_context: bool = False
class ClearBody(BaseModel):
session_id: str
@router.get("/blocks")
def list_context_blocks():
"""Static list of context block keys the frontend can offer as toggles."""
return {"blocks": CONTEXT_BLOCKS}
@router.post("/")
def send_message(body: ChatMessageBody):
from services.ai_chat import send_chat_message
if not body.message.strip():
raise HTTPException(400, "Message vide.")
try:
return send_chat_message(
session_id=body.session_id,
message=body.message.strip(),
enabled_blocks=body.enabled_blocks,
refresh_context=body.refresh_context,
)
except RuntimeError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/history")
def get_history(session_id: str):
from services.database import get_chat_messages
return {"messages": get_chat_messages(session_id)}
@router.post("/clear")
def clear_session(body: ClearBody):
from services.database import clear_chat_session
from services.ai_chat_context import clear_context_cache
clear_chat_session(body.session_id)
clear_context_cache(body.session_id)
return {"cleared": body.session_id}

View File

@@ -0,0 +1,83 @@
"""
Free-form, read-only chat with GPT-4o about the current cockpit state.
Deliberately has NO function-calling/tools wired up — a plain text-completion
call physically cannot trigger any action (no trade, no cycle, no DB write
beyond persisting the conversation itself). The system prompt also tells the
model explicitly not to claim it can act, so it doesn't mislead the user.
"""
import re
import time
from typing import Dict, List, Optional
from services.ai_analyzer import get_client
from services.ai_chat_context import assemble_context, CONTEXT_BLOCKS
SYSTEM_PROMPT_HEADER = """Tu es l'assistant IA integre au cockpit de trading OpenFin Intelligence.
Tu as acces ci-dessous a un instantane en lecture seule de la situation actuelle (portefeuille, risque geopolitique, regime macro, patterns, options/IV, indicateurs techniques, signaux ondelettes, watchlist, calendrier economique, rapports institutionnels, lecons accumulees, VaR/risque).
REGLES IMPORTANTES :
- Tu ne peux declencher AUCUNE action (pas de trade, pas de cycle, pas de modification) - tu es uniquement la pour discuter, expliquer et aider a comprendre la situation ou une idee (ex. un montage d'options, un signal ondelette, pourquoi un pattern a tel score).
- Si on te demande d'agir, rappelle clairement que tu ne peux qu'expliquer/discuter, pas executer.
- Reponds en francais, de facon concise et directe, en t'appuyant sur le contexte fourni. Si une donnee demandee n'est pas dans le contexte ci-dessous, dis-le plutot que d'inventer.
=== CONTEXTE ACTUEL ===
{context}
=== FIN DU CONTEXTE ===
"""
def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max_tokens: int = 1200) -> str:
"""Multi-turn variant of ai_analyzer._chat() — accepts a full message history
instead of a single system+user pair. Same client/retry/backoff logic, kept
independent so it never risks the well-tested cycle-facing _chat()."""
client = get_client()
if not client:
raise RuntimeError("OpenAI API key not configured")
kwargs = {
"model": model,
"messages": [{"role": "system", "content": system}] + messages,
"temperature": 0.4,
"max_tokens": max_tokens,
}
last_exc: Optional[Exception] = None
for attempt in range(4):
try:
resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message.content or ""
except Exception as e:
last_exc = e
err_str = str(e)
if "429" in err_str or "rate_limit" in err_str:
m = re.search(r"try again in ([\d.]+)s", err_str)
wait = float(m.group(1)) + 1.0 if m else 2 ** (attempt + 1) * 5.0
time.sleep(min(wait, 60.0))
continue
raise
raise last_exc # type: ignore[misc]
def send_chat_message(
session_id: str,
message: str,
enabled_blocks: Optional[List[str]] = None,
refresh_context: bool = False,
) -> Dict:
from services.database import save_chat_message, get_chat_messages
enabled = enabled_blocks or CONTEXT_BLOCKS
blocks = assemble_context(enabled, session_id, refresh=refresh_context)
context_text = "\n\n".join(blocks.values())
system = SYSTEM_PROMPT_HEADER.format(context=context_text)
history = get_chat_messages(session_id)
messages = [{"role": h["role"], "content": h["content"]} for h in history]
messages.append({"role": "user", "content": message})
save_chat_message(session_id, "user", message)
reply = _chat_messages(system, messages)
save_chat_message(session_id, "assistant", reply)
return {"reply": reply, "blocks_included": list(blocks.keys())}

View File

@@ -0,0 +1,207 @@
"""
Context assembly for the free-form AI chat widget. Every block below reuses an
existing, independent builder already used by the auto-cycle prompt (see
services/auto_cycle.py Step 1.9/2) — nothing here duplicates that logic, it just
re-packages the same read-only data for an interactive Q&A session instead of a
decision-making cycle. No block writes to the DB or triggers anything.
"""
import time
from typing import Dict, List
CONTEXT_BLOCKS = [
"portfolio", "geo_news", "macro", "patterns", "options_iv", "tech_indicators",
"wavelet_signals", "watchlist_quotes", "economic_calendar", "institutional",
"super_context", "var_risk",
]
_CACHE_TTL_SECONDS = 10 * 60
_context_cache: Dict[str, Dict] = {} # session_id -> {"blocks": {...}, "ts": float}
def _block_portfolio() -> str:
from services.portfolio_context import get_open_trades_with_moves, get_portfolio_concentration, build_portfolio_context_block
trades = get_open_trades_with_moves()
conc = get_portfolio_concentration(trades)
return build_portfolio_context_block(trades, conc)
def _block_geo_news() -> str:
from services.data_fetcher import fetch_geo_news
from services.geo_analyzer import compute_geo_risk_score
news = fetch_geo_news()
score = compute_geo_risk_score(news)
top = sorted(news, key=lambda n: -(n.get("impact_score") or 0))[:12]
lines = [f"## GEOPOLITICAL RISK\nScore: {score['score']}/100 ({score['level']})", "Top news:"]
for n in top:
lines.append(f"- [{round((n.get('impact_score') or 0) * 100)}] {n.get('title')}")
return "\n".join(lines)
def _block_macro() -> str:
from services.data_fetcher import get_macro_gauges, score_macro_scenarios
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
lines = [f"## MACRO REGIME\nDominant scenario: {scenarios.get('dominant')}"]
ranked = scenarios.get("ranked") or []
if ranked:
lines.append("Scenario scores: " + ", ".join(f"{k}={v}" for k, v in ranked[:5]))
lines.append("Key gauges:")
for gid, g in list(gauges.items())[:12]:
lines.append(f"- {g.get('label', gid)}: {g.get('value')} {g.get('unit', '')} ({g.get('change_pct')}%)")
return "\n".join(lines)
def _block_patterns() -> str:
from services.database import get_all_pattern_reliability_map
rel = get_all_pattern_reliability_map()
if not rel:
return "## PATTERN RELIABILITY\nNo data yet."
lines = ["## PATTERN RELIABILITY"]
for pid, stats in list(rel.items())[:15]:
lines.append(f"- {stats.get('pattern_id', pid)}: win_rate={stats.get('win_rate')}, trades={stats.get('trade_count')}")
return "\n".join(lines)
def _block_options_iv() -> str:
from services.database import get_instruments_watchlist
from services.iv_engine import get_iv_context_for_prompt
tickers = [w["ticker"] for w in get_instruments_watchlist()]
if not tickers:
return "## OPTIONS / IV\nNo watchlist instruments configured (Config > Watchlist)."
return "## OPTIONS / IV (watchlist)\n" + get_iv_context_for_prompt(tickers)
def _block_tech_indicators() -> str:
from services.database import get_instruments_watchlist
from services.technical_indicators import compute_indicators, format_indicators_for_prompt
tickers = [w["ticker"] for w in get_instruments_watchlist()][:8]
lines = ["## TECHNICAL INDICATORS (watchlist)"]
for t in tickers:
try:
block = format_indicators_for_prompt(compute_indicators(t, horizon_days=45))
except Exception:
block = ""
if block:
lines.append(f"### {t}\n{block}")
return "\n".join(lines) if len(lines) > 1 else "## TECHNICAL INDICATORS\nNo data available."
def _block_wavelet_signals() -> str:
from services.database import get_latest_wavelet_signals
signals = get_latest_wavelet_signals()
if not signals:
return "## WAVELET SIGNALS\nNo wavelet signal detected yet (computed each auto-cycle)."
lines = ["## WAVELET SIGNALS (watchlist, latest cycle scan)"]
for s in signals[:20]:
lines.append(f"- {s['ticker']}: band {s['band_label']} · {s['signal_kind']} · {s['direction']} @ {s.get('price_at_signal')}")
return "\n".join(lines)
def _block_watchlist_quotes() -> str:
from services.database import get_instruments_watchlist
from services.data_fetcher import get_quote
items = get_instruments_watchlist()
if not items:
return "## WATCHLIST\nEmpty — no instruments configured."
lines = ["## WATCHLIST QUOTES"]
for w in items:
q = get_quote(w["ticker"]) or {}
lines.append(f"- {w['ticker']} ({w.get('asset_class')}): {q.get('price')} ({q.get('change_pct')}%)")
return "\n".join(lines)
def _block_economic_calendar() -> str:
from services.ff_calendar import get_calendar
data = get_calendar(period="recent", limit=50)
events = [e for e in data.get("events", []) if e.get("impact") in ("high", "medium")]
if not events:
return "## ECONOMIC CALENDAR\nNo high/medium impact events in range."
lines = ["## ECONOMIC CALENDAR (high/medium impact, recent window)"]
for e in events[:15]:
lines.append(f"- {e['event_date']} {e.get('event_time') or ''} [{e['currency']}] {e['event_name']} ({e['impact']})")
return "\n".join(lines)
def _block_institutional() -> str:
from services.ai_analyzer import build_institutional_block
return "## INSTITUTIONAL REPORTS\n" + build_institutional_block(days=7)
def _block_super_context() -> str:
from services.database import get_latest_portfolio_lessons, get_latest_reasoning_state
lessons = get_latest_portfolio_lessons()
reasoning = get_latest_reasoning_state()
lines = ["## SUPER CONTEXT / LESSONS LEARNED"]
if lessons:
lines.append(f"Headline: {lessons.get('headline', '')}")
key_lessons = lessons.get("key_lessons") or []
if key_lessons:
lines.append("Key lessons: " + "; ".join(str(k) for k in key_lessons[:5]))
if lessons.get("risk_watch"):
lines.append(f"Risk watch: {lessons['risk_watch']}")
if reasoning and reasoning.get("narrative"):
lines.append(f"Reasoning state (v{reasoning.get('version')}): {reasoning['narrative'][:600]}")
if len(lines) == 1:
return "## SUPER CONTEXT\nNo data yet."
return "\n".join(lines)
def _block_var_risk() -> str:
from services.var_service import get_latest_var_snapshot
from services.portfolio_risk import analyze_simulation_portfolio, build_monitor_context
lines = ["## RISK / VaR"]
snap = get_latest_var_snapshot()
if snap:
lines.append(
f"VaR 95% hist: {snap.get('hist_var_1d_pct')}% · CVaR: {snap.get('hist_cvar_pct')}% · "
f"Monte Carlo x1.5: {snap.get('mc_var_1d_pct')}%"
)
else:
lines.append("No VaR snapshot yet.")
try:
risk = analyze_simulation_portfolio()
lines.append(build_monitor_context(risk))
except Exception:
pass
return "\n".join(lines)
_BLOCK_BUILDERS = {
"portfolio": _block_portfolio,
"geo_news": _block_geo_news,
"macro": _block_macro,
"patterns": _block_patterns,
"options_iv": _block_options_iv,
"tech_indicators": _block_tech_indicators,
"wavelet_signals": _block_wavelet_signals,
"watchlist_quotes": _block_watchlist_quotes,
"economic_calendar": _block_economic_calendar,
"institutional": _block_institutional,
"super_context": _block_super_context,
"var_risk": _block_var_risk,
}
def assemble_context(enabled_blocks: List[str], session_id: str, refresh: bool = False) -> Dict[str, str]:
"""Return {block_key: formatted_text} for every requested, known block.
Cached in-memory per session (same TTL-cache idiom as _macro_cache in
routers/market_data.py) so a back-and-forth conversation doesn't re-run every
builder (some hit yfinance) on every single message."""
cached = _context_cache.get(session_id)
if not refresh and cached and (time.time() - cached["ts"]) < _CACHE_TTL_SECONDS:
blocks = cached["blocks"]
else:
blocks = {}
for key in CONTEXT_BLOCKS:
try:
blocks[key] = _BLOCK_BUILDERS[key]()
except Exception as e:
blocks[key] = f"## {key.upper()}\n(unavailable: {e})"
_context_cache[session_id] = {"blocks": blocks, "ts": time.time()}
requested = [b for b in enabled_blocks if b in blocks] or list(blocks.keys())
return {k: blocks[k] for k in requested}
def clear_context_cache(session_id: str) -> None:
_context_cache.pop(session_id, None)

View File

@@ -135,6 +135,14 @@ def init_db():
price_at_signal REAL
)""",
"ALTER TABLE cycle_runs ADD COLUMN wavelet_signals_count INTEGER DEFAULT 0",
# AI Chat widget — persisted conversation turns, one growing thread per session_id
"""CREATE TABLE IF NOT EXISTS ai_chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)""",
]:
try:
c.execute(_sql)
@@ -146,6 +154,11 @@ def init_db():
except Exception:
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_chat_session_date ON ai_chat_messages(session_id, created_at)")
except Exception:
pass
# Specialist Reports — surprise index + text sentiment columns
try:
c.execute("ALTER TABLE specialist_reports ADD COLUMN consensus_estimate REAL")
@@ -3213,6 +3226,35 @@ def get_wavelet_signals_history(ticker: str, days: int = 30) -> List[Dict]:
return [dict(r) for r in rows]
# ── AI Chat widget — persisted conversation ────────────────────────────────────
def save_chat_message(session_id: str, role: str, content: str) -> None:
conn = get_conn()
conn.execute(
"INSERT INTO ai_chat_messages (session_id, role, content) VALUES (?, ?, ?)",
(session_id, role, content),
)
conn.commit()
conn.close()
def get_chat_messages(session_id: str, limit: int = 100) -> List[Dict]:
conn = get_conn()
rows = conn.execute(
"SELECT role, content, created_at FROM ai_chat_messages WHERE session_id=? ORDER BY id ASC LIMIT ?",
(session_id, limit),
).fetchall()
conn.close()
return [dict(r) for r in rows]
def clear_chat_session(session_id: str) -> None:
conn = get_conn()
conn.execute("DELETE FROM ai_chat_messages WHERE session_id=?", (session_id,))
conn.commit()
conn.close()
# ── System Logs ───────────────────────────────────────────────────────────────
def log_system_event(

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),
})