"use client"; // Application de chat : liste de conversations, fil streaming SSE, sélecteurs // (cours / modèle / mode pédagogique / mode de connaissances), pièces jointes, // citations cliquables, actions par message, raccourcis clavier. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Archive, ArrowUp, Bookmark, Check, ChevronDown, Copy, Flag, GitBranch, Menu, Paperclip, Pencil, PenLine, Pin, Plus, RefreshCw, Search, Settings2, Sparkles, ThumbsDown, ThumbsUp, Trash2, X, } from "lucide-react"; import { ImmbotMark } from "@/components/logo"; import { Badge, Button, Input, Modal, Spinner, Textarea, cn } from "@/components/ui"; import { Markdown } from "./markdown"; import { CitationPanel, type Citation } from "./citation-panel"; // ---------------- Types ---------------- type Conversation = { id: number; title: string; folder: string; pinned: number; archived: number; course_code: string | null; mode: string; knowledge_mode: string; model: string; updated_at: string; }; type ToolTraceItem = { name: string; label: string; running?: boolean }; type Message = { id: number; role: "user" | "assistant"; content: string; citations: Citation[]; attachments: { id: number; filename: string; mime: string }[]; model: string; feedback: number; saved: number; streaming?: boolean; toolTrace?: ToolTraceItem[]; }; type ModelInfo = { id: string; name: string; provider: string; supportsImages: boolean; isFree: boolean; costTier: string; favorite: boolean; description: string; }; type Course = { code: string; title: string; color: string }; type Attachment = { id: number; filename: string; mime: string }; const MODES = [ { key: "ask", label: "Demander au cours", hint: "Réponse directe et citée" }, { key: "tutor", label: "Tuteur", hint: "Explication progressive, une étape à la fois" }, { key: "socratic", label: "Socratique", hint: "Vous guide par des questions" }, { key: "simple", label: "Explique simplement", hint: "Vocabulaire accessible, exemples concrets" }, { key: "professional", label: "Niveau professionnel", hint: "Terminologie de la pratique" }, { key: "correction", label: "Corrige ma réponse", hint: "Rétroaction progressive sur votre travail" }, { key: "exam-prep", label: "Préparation examen", hint: "Résumés, questions, simulations" }, { key: "challenge", label: "Mode défi", hint: "Cas intégrés complexes" }, { key: "targeted-review", label: "Révision ciblée", hint: "Sur vos faiblesses identifiées" }, ] as const; const KNOWLEDGE_MODES = [ { key: "course-only", label: "Cours uniquement", hint: "Matériel officiel seulement — mode par défaut" }, { key: "course-tools", label: "Cours interactif", hint: "Le modèle explore lui-même séances et diapositives avec ses outils (visible en direct)" }, { key: "course-plus", label: "Cours + général", hint: "Matériel cité, complété par le modèle" }, { key: "general", label: "Général", hint: "Sans le matériel du cours (signalé)" }, ] as const; const SUGGESTIONS_1003 = [ "Quelle est la différence entre la valeur marchande et la valeur au rôle ?", "Explique-moi les ajustements séquentiels de la méthode de comparaison.", "Comment calcule-t-on le RNE d'un immeuble à revenus ?", ]; const SUGGESTIONS_1033 = [ "Explique-moi la ventilation de la dépréciation physique.", "Comment calcule-t-on les intérêts intercalaires ?", "Quelles sont les 5 méthodes d'évaluation d'un terrain ?", ]; const COST_LABEL: Record = { "économique": { label: "Économique", tone: "green" }, "modéré": { label: "Modéré", tone: "amber" }, "coûteux": { label: "Coûteux", tone: "red" }, }; export function ChatApp({ courses, initialConversationId }: { courses: Course[]; initialConversationId?: number }) { const router = useRouter(); const searchParams = useSearchParams(); // ---------------- État ---------------- const [conversations, setConversations] = useState([]); const [convId, setConvId] = useState(initialConversationId ?? null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [models, setModels] = useState([]); const [presets, setPresets] = useState>({}); const [model, setModel] = useState(""); const [course, setCourse] = useState(searchParams.get("course") ?? courses[0]?.code ?? null); const [mode, setMode] = useState("ask"); const [knowledgeMode, setKnowledgeMode] = useState("course-only"); const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [modelSearch, setModelSearch] = useState(""); const [citation, setCitation] = useState(null); const [convSearch, setConvSearch] = useState(""); const [error, setError] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [renamingConv, setRenamingConv] = useState(null); const [renameValue, setRenameValue] = useState(""); const bottomRef = useRef(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); const abortRef = useRef(null); const currentModel = useMemo(() => models.find((m) => m.id === model), [models, model]); // ---------------- Chargements ---------------- const loadConversations = useCallback(async (q?: string) => { const res = await fetch(`/api/conversations${q ? `?q=${encodeURIComponent(q)}` : ""}`); if (res.ok) setConversations((await res.json()).conversations); }, []); useEffect(() => { loadConversations(); }, [loadConversations]); useEffect(() => { fetch("/api/models") .then((r) => (r.ok ? r.json() : Promise.reject())) .then((d) => { setModels(d.models); setPresets(d.presets); const preferred = localStorage.getItem("immbot-model"); if (preferred && d.models.some((m: ModelInfo) => m.id === preferred)) setModel(preferred); else { const rec: string[] = d.presets?.recommande?.models ?? []; const first = rec.find((id) => d.models.some((m: ModelInfo) => m.id === id)) ?? d.models[0]?.id ?? ""; setModel(first); } }) .catch(() => setError("Impossible de charger les modèles — vérifiez la clé OpenRouter.")); }, []); const loadConversation = useCallback(async (id: number) => { const res = await fetch(`/api/conversations/${id}`); if (!res.ok) return; const d = await res.json(); setConvId(id); setMessages( d.messages.map((m: { id: number; role: string; content: string; citations: string; attachments: string; tool_trace?: string; model: string; feedback: number; saved: number }) => ({ ...m, citations: JSON.parse(m.citations || "[]"), attachments: JSON.parse(m.attachments || "[]"), toolTrace: JSON.parse(m.tool_trace || "[]"), })) ); if (d.conversation.course_code) setCourse(d.conversation.course_code); if (d.conversation.mode) setMode(d.conversation.mode); if (d.conversation.knowledge_mode) setKnowledgeMode(d.conversation.knowledge_mode); if (d.conversation.model) setModel((prev) => d.conversation.model || prev); setSidebarOpen(false); }, []); useEffect(() => { if (initialConversationId) loadConversation(initialConversationId); }, [initialConversationId, loadConversation]); // Préremplissage depuis un lien (ex. « Demander au chat » depuis les diapositives) useEffect(() => { const q = searchParams.get("q"); if (q && !initialConversationId) { setInput(q); textareaRef.current?.focus(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [messages.length, messages.at(-1)?.content?.length]); // ---------------- Envoi (SSE) ---------------- async function send(text: string, opts?: { regenerateOfMessageId?: number }) { if (!text.trim() || sending || !model) return; setError(null); setSending(true); const tempUser: Message = { id: -1, role: "user", content: text, citations: [], attachments: [...attachments], model, feedback: 0, saved: 0, }; const tempAssistant: Message = { id: -2, role: "assistant", content: "", citations: [], attachments: [], model, feedback: 0, saved: 0, streaming: true, }; if (opts?.regenerateOfMessageId) { setMessages((ms) => [...ms.filter((m) => m.id < opts.regenerateOfMessageId!), tempUser, tempAssistant]); } else { setMessages((ms) => [...ms, tempUser, tempAssistant]); } setInput(""); const sentAttachments = attachments.map((a) => a.id); setAttachments([]); const controller = new AbortController(); abortRef.current = controller; try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal, body: JSON.stringify({ conversationId: convId ?? undefined, courseCode: knowledgeMode === "general" ? course : course, message: text, model, mode, knowledgeMode, attachmentIds: sentAttachments, regenerateOfMessageId: opts?.regenerateOfMessageId, }), }); if (!res.ok || !res.body) { const d = await res.json().catch(() => ({})); throw new Error(d.error ?? `Erreur ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let acc = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const ev of events) { const line = ev.trim(); if (!line.startsWith("data:")) continue; const data = JSON.parse(line.slice(5)); if (data.type === "meta") { if (!convId) { setConvId(data.conversationId); window.history.replaceState(null, "", `/chat/${data.conversationId}`); } setMessages((ms) => ms.map((m) => (m.id === -1 ? { ...m, id: data.userMessageId } : m))); } else if (data.type === "tool") { setMessages((ms) => ms.map((m) => m.id === -2 ? { ...m, toolTrace: [ ...(m.toolTrace ?? []).map((t) => ({ ...t, running: false })), { name: data.name, label: data.label, running: true }, ], } : m ) ); } else if (data.type === "delta") { acc += data.text; setMessages((ms) => ms.map((m) => (m.id === -2 ? { ...m, content: acc, toolTrace: m.toolTrace?.map((t) => ({ ...t, running: false })) } : m))); } else if (data.type === "error") { setError(data.message); } else if (data.type === "done") { setMessages((ms) => ms.map((m) => m.id === -2 ? { ...m, id: data.messageId, content: data.content, citations: data.citations ?? [], toolTrace: (data.toolTrace ?? m.toolTrace ?? []).map((t: ToolTraceItem) => ({ ...t, running: false })), streaming: false, } : m ) ); } } } loadConversations(); } catch (e) { if ((e as Error).name !== "AbortError") { setError(e instanceof Error ? e.message : "Erreur d'envoi."); setMessages((ms) => ms.filter((m) => m.id !== -2 || m.content)); } } finally { setMessages((ms) => ms.map((m) => ({ ...m, streaming: false }))); setSending(false); abortRef.current = null; textareaRef.current?.focus(); } } function stopStreaming() { abortRef.current?.abort(); } // ---------------- Actions ---------------- async function newConversation() { setConvId(null); setMessages([]); setAttachments([]); window.history.replaceState(null, "", "/chat"); textareaRef.current?.focus(); } async function patchConversation(id: number, patch: Record) { await fetch(`/api/conversations/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); loadConversations(); } async function deleteConversation(id: number) { if (!confirm("Supprimer définitivement cette conversation ?")) return; await fetch(`/api/conversations/${id}`, { method: "DELETE" }); if (convId === id) newConversation(); loadConversations(); } async function branchFrom(messageId: number) { if (!convId) return; const res = await fetch(`/api/conversations/${convId}/branch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ upToMessageId: messageId }), }); if (res.ok) { const d = await res.json(); await loadConversation(d.conversationId); loadConversations(); } } async function messageAction(id: number, patch: Record) { await fetch(`/api/messages/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); } async function uploadFiles(files: FileList | null) { if (!files?.length) return; setUploading(true); setError(null); try { for (const file of Array.from(files).slice(0, 6 - attachments.length)) { const fd = new FormData(); fd.append("file", file); if (convId) fd.append("conversationId", String(convId)); const res = await fetch("/api/uploads", { method: "POST", body: fd }); const d = await res.json(); if (!res.ok) throw new Error(d.error ?? "Erreur de téléversement"); setAttachments((a) => [...a, d.upload]); } } catch (e) { setError(e instanceof Error ? e.message : "Erreur de téléversement."); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } } function copyText(text: string) { navigator.clipboard?.writeText(text); } function exportConversation() { const md = messages .map((m) => `**${m.role === "user" ? "Moi" : "Immbot AI"}** :\n\n${m.content}`) .join("\n\n---\n\n"); const blob = new Blob([md], { type: "text/markdown" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `immbot-conversation-${convId ?? "nouvelle"}.md`; a.click(); URL.revokeObjectURL(a.href); } // Raccourcis clavier useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && sending) stopStreaming(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [sending]); const filteredModels = useMemo(() => { const q = modelSearch.toLowerCase(); return models.filter((m) => !q || m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)); }, [models, modelSearch]); const lastAssistantWithSources = messages.filter((m) => m.role === "assistant" && m.citations.length > 0).at(-1); // ---------------- Rendu ---------------- return (
{/* Liste des conversations */}
{ setConvSearch(e.target.value); loadConversations(e.target.value); }} placeholder="Rechercher…" className="h-8.5 pl-8.5 text-[13px]" />
{conversations.length === 0 && (

Aucune conversation. Posez votre première question !

)} {conversations.map((c) => (
loadConversation(c.id)} > {c.pinned ? : null}

{c.title}

{c.course_code ?? "Général"}

e.stopPropagation()}>
))}
{sidebarOpen &&
setSidebarOpen(false)} />} {/* Fil principal */}
{/* Barre d'outils — compacte sur mobile (réglages en feuille basse), complète sur desktop */}
{/* Cours — segments nets */}
{courses.map((c) => ( ))}
{/* Modèle */} {/* Mode + connaissances : inline ≥ md seulement */}
{currentModel && ( {currentModel.isFree ? "Gratuit" : COST_LABEL[currentModel.costTier]?.label} )} {messages.length > 0 && ( )} {/* Réglages (mobile) */}
{/* Feuille de réglages mobile */} setSettingsOpen(false)} title="Réglages de la conversation">

Mode pédagogique

{MODES.map((m) => ( ))}

Source des connaissances

{KNOWLEDGE_MODES.map((m) => ( ))}
{messages.length > 0 && ( )}
{/* Messages */}
{messages.length === 0 && (

Posez une question sur {course ?? "vos cours"}

Réponses fondées sur le matériel officiel, avec les diapositives citées.

{(course === "IMM1033" ? SUGGESTIONS_1033 : SUGGESTIONS_1003).map((s) => ( ))}
)} {messages.map((m) => m.role === "user" ? (
{m.content} {m.attachments.length > 0 && (
{m.attachments.map((a) => ( {a.filename} ))}
)}
) : (
{(m.toolTrace?.length ?? 0) > 0 && (
{m.toolTrace!.map((t, ti) => (
{t.running ? : } {t.label}
))}
)} { const c = m.citations.find((x) => x.index === index); if (c) setCitation(c); }} /> {m.citations.length > 0 && !m.streaming && (
Sources : {m.citations.map((c) => ( ))}
)} {!m.streaming && m.content && (
)}
) )} {error && (
{error}
)}
{/* Composeur */}
{attachments.length > 0 && (
{attachments.map((a) => ( {a.filename} ))}
)} {editingMessageId && (
Modification d'une question — l'envoi remplacera la suite de la conversation.
)}
uploadFiles(e.target.files)} />