feat: add AI chat for repports

This commit is contained in:
2026-04-28 07:19:49 +02:00
parent 087bcab16b
commit 490a364c00
10 changed files with 340 additions and 5 deletions

View File

@ -12,9 +12,21 @@ export interface Report {
created_at: string
}
export interface ReportMessage {
id: string
report_id: string
role: 'user' | 'assistant'
content: string
status: 'done' | 'generating' | 'error'
created_at: string
}
export const reportsApi = {
list: () => api.get<Report[]>('/reports'),
create: (data: { summary_id?: string; excerpts: string[]; question: string }) =>
api.post<Report>('/reports', data),
delete: (id: string) => api.delete<void>(`/reports/${id}`),
listMessages: (reportId: string) => api.get<ReportMessage[]>(`/reports/${reportId}/messages`),
createMessage: (reportId: string, content: string) =>
api.post<ReportMessage>(`/reports/${reportId}/messages`, { content }),
}

View File

@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle } from 'lucide-react'
import { reportsApi, type Report } from '@/api/reports'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Trash2, FileText, Clock, Loader2, XCircle, AlertCircle, Send } from 'lucide-react'
import { reportsApi, type Report, type ReportMessage } from '@/api/reports'
import { Card, CardContent } from '@/components/ui/card'
import { Markdown } from '@/components/ui/markdown'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge'
@ -21,6 +22,126 @@ function StatusBadge({ status }: { status: Report['status'] }) {
return null
}
function MessageBubble({ msg }: { msg: ReportMessage }) {
const isUser = msg.role === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}>
{msg.status === 'generating' ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-xs">Génération</span>
</div>
) : msg.status === 'error' ? (
<span className="text-destructive text-xs">{msg.content || 'Erreur lors de la génération.'}</span>
) : isUser ? (
<p>{msg.content}</p>
) : (
<Markdown content={msg.content} />
)}
</div>
</div>
)
}
function ReportChat({ report }: { report: Report }) {
const [messages, setMessages] = useState<ReportMessage[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const loadMessages = useCallback(async () => {
const msgs = await reportsApi.listMessages(report.id)
setMessages(msgs ?? [])
}, [report.id])
useEffect(() => { loadMessages() }, [loadMessages])
// Poll tant qu'un message assistant est en cours de génération
useEffect(() => {
const hasGenerating = messages.some(m => m.status === 'generating')
if (!hasGenerating) return
const interval = setInterval(loadMessages, 2000)
return () => clearInterval(interval)
}, [messages, loadMessages])
// Scroll vers le bas à chaque nouveau message
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
async function send() {
const content = input.trim()
if (!content || sending) return
setInput('')
setSending(true)
try {
// Optimistic: ajouter le message user immédiatement
const optimisticUser: ReportMessage = {
id: `tmp-${Date.now()}`,
report_id: report.id,
role: 'user',
content,
status: 'done',
created_at: new Date().toISOString(),
}
const optimisticAssistant: ReportMessage = {
id: `tmp-assistant-${Date.now()}`,
report_id: report.id,
role: 'assistant',
content: '',
status: 'generating',
created_at: new Date().toISOString(),
}
setMessages(prev => [...prev, optimisticUser, optimisticAssistant])
await reportsApi.createMessage(report.id, content)
await loadMessages()
} finally {
setSending(false)
}
}
const hasGenerating = messages.some(m => m.status === 'generating')
return (
<div className="border-t pt-3 space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Conversation</p>
{messages.length > 0 && (
<div className="space-y-2">
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
<div ref={bottomRef} />
</div>
)}
<div className="flex gap-2">
<Input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && send()}
placeholder="Posez une question sur cette réponse…"
disabled={hasGenerating || sending}
className="text-sm"
/>
<Button
size="sm"
onClick={send}
disabled={!input.trim() || hasGenerating || sending}
className="shrink-0"
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
)
}
export function Reports() {
const [reports, setReports] = useState<Report[]>([])
const [loading, setLoading] = useState(true)
@ -101,7 +222,7 @@ export function Reports() {
{/* Question */}
<p className="text-sm font-medium">{r.question}</p>
{/* Réponse */}
{/* Réponse initiale */}
{r.status === 'generating' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
@ -118,6 +239,9 @@ export function Reports() {
Erreur : {r.error_msg || 'Une erreur est survenue lors de la génération.'}
</div>
)}
{/* Chat de suivi (uniquement quand le rapport est terminé) */}
{r.status === 'done' && <ReportChat report={r} />}
</CardContent>
</Card>
))}

View File

@ -1 +1 @@
{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/api/admin.ts","./src/api/articles.ts","./src/api/assets.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/reports.ts","./src/api/summaries.ts","./src/components/layout/AppLayout.tsx","./src/components/layout/MobileNav.tsx","./src/components/layout/Sidebar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/markdown.tsx","./src/components/ui/select.tsx","./src/components/ui/spinner.tsx","./src/lib/auth.tsx","./src/lib/cn.ts","./src/lib/router.tsx","./src/pages/Dashboard.tsx","./src/pages/Feed.tsx","./src/pages/Login.tsx","./src/pages/Reports.tsx","./src/pages/Watchlist.tsx","./src/pages/admin/AIProviders.tsx","./src/pages/admin/AdminLayout.tsx","./src/pages/admin/AdminSettings.tsx","./src/pages/admin/AdminUsers.tsx","./src/pages/admin/Credentials.tsx","./src/pages/admin/Jobs.tsx","./src/pages/admin/Schedule.tsx","./src/pages/admin/Sources.tsx"],"version":"5.9.3"}