feat: add AI chat for repports
This commit is contained in:
@ -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 }),
|
||||
}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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"}
|
||||
Reference in New Issue
Block a user