TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Application de chat : liste de conversations, fil streaming SSE, sélecteurs3// (cours / modèle / mode pédagogique / mode de connaissances), pièces jointes,4// citations cliquables, actions par message, raccourcis clavier.5import { useCallback, useEffect, useMemo, useRef, useState } from "react";6import { useRouter, useSearchParams } from "next/navigation";7import {8 Archive, ArrowUp, Bookmark, Check, ChevronDown, Copy, Flag, GitBranch, Menu,9 Paperclip, Pencil, PenLine, Pin, Plus, RefreshCw, Search, Settings2, Sparkles,10 ThumbsDown, ThumbsUp, Trash2, X,11} from "lucide-react";12import { ImmbotMark } from "@/components/logo";13import { Badge, Button, Input, Modal, Spinner, Textarea, cn } from "@/components/ui";14import { Markdown } from "./markdown";15import { CitationPanel, type Citation } from "./citation-panel";1617// ---------------- Types ----------------18type Conversation = {19 id: number; title: string; folder: string; pinned: number; archived: number;20 course_code: string | null; mode: string; knowledge_mode: string; model: string; updated_at: string;21};22type ToolTraceItem = { name: string; label: string; running?: boolean };23type Message = {24 id: number; role: "user" | "assistant"; content: string;25 citations: Citation[]; attachments: { id: number; filename: string; mime: string }[];26 model: string; feedback: number; saved: number; streaming?: boolean;27 toolTrace?: ToolTraceItem[];28};29type ModelInfo = {30 id: string; name: string; provider: string; supportsImages: boolean;31 isFree: boolean; costTier: string; favorite: boolean; description: string;32};33type Course = { code: string; title: string; color: string };34type Attachment = { id: number; filename: string; mime: string };3536const MODES = [37 { key: "ask", label: "Demander au cours", hint: "Réponse directe et citée" },38 { key: "tutor", label: "Tuteur", hint: "Explication progressive, une étape à la fois" },39 { key: "socratic", label: "Socratique", hint: "Vous guide par des questions" },40 { key: "simple", label: "Explique simplement", hint: "Vocabulaire accessible, exemples concrets" },41 { key: "professional", label: "Niveau professionnel", hint: "Terminologie de la pratique" },42 { key: "correction", label: "Corrige ma réponse", hint: "Rétroaction progressive sur votre travail" },43 { key: "exam-prep", label: "Préparation examen", hint: "Résumés, questions, simulations" },44 { key: "challenge", label: "Mode défi", hint: "Cas intégrés complexes" },45 { key: "targeted-review", label: "Révision ciblée", hint: "Sur vos faiblesses identifiées" },46] as const;4748const KNOWLEDGE_MODES = [49 { key: "course-only", label: "Cours uniquement", hint: "Matériel officiel seulement — mode par défaut" },50 { key: "course-tools", label: "Cours interactif", hint: "Le modèle explore lui-même séances et diapositives avec ses outils (visible en direct)" },51 { key: "course-plus", label: "Cours + général", hint: "Matériel cité, complété par le modèle" },52 { key: "general", label: "Général", hint: "Sans le matériel du cours (signalé)" },53] as const;5455const SUGGESTIONS_1003 = [56 "Quelle est la différence entre la valeur marchande et la valeur au rôle ?",57 "Explique-moi les ajustements séquentiels de la méthode de comparaison.",58 "Comment calcule-t-on le RNE d'un immeuble à revenus ?",59];60const SUGGESTIONS_1033 = [61 "Explique-moi la ventilation de la dépréciation physique.",62 "Comment calcule-t-on les intérêts intercalaires ?",63 "Quelles sont les 5 méthodes d'évaluation d'un terrain ?",64];6566const COST_LABEL: Record<string, { label: string; tone: "green" | "amber" | "red" }> = {67 "économique": { label: "Économique", tone: "green" },68 "modéré": { label: "Modéré", tone: "amber" },69 "coûteux": { label: "Coûteux", tone: "red" },70};7172export function ChatApp({ courses, initialConversationId }: { courses: Course[]; initialConversationId?: number }) {73 const router = useRouter();74 const searchParams = useSearchParams();7576 // ---------------- État ----------------77 const [conversations, setConversations] = useState<Conversation[]>([]);78 const [convId, setConvId] = useState<number | null>(initialConversationId ?? null);79 const [messages, setMessages] = useState<Message[]>([]);80 const [input, setInput] = useState("");81 const [sending, setSending] = useState(false);82 const [models, setModels] = useState<ModelInfo[]>([]);83 const [presets, setPresets] = useState<Record<string, { label: string; models: string[]; description: string }>>({});84 const [model, setModel] = useState<string>("");85 const [course, setCourse] = useState<string | null>(searchParams.get("course") ?? courses[0]?.code ?? null);86 const [mode, setMode] = useState<string>("ask");87 const [knowledgeMode, setKnowledgeMode] = useState<string>("course-only");88 const [attachments, setAttachments] = useState<Attachment[]>([]);89 const [uploading, setUploading] = useState(false);90 const [sidebarOpen, setSidebarOpen] = useState(false);91 const [settingsOpen, setSettingsOpen] = useState(false);92 const [modelPickerOpen, setModelPickerOpen] = useState(false);93 const [modelSearch, setModelSearch] = useState("");94 const [citation, setCitation] = useState<Citation | null>(null);95 const [convSearch, setConvSearch] = useState("");96 const [error, setError] = useState<string | null>(null);97 const [editingMessageId, setEditingMessageId] = useState<number | null>(null);98 const [renamingConv, setRenamingConv] = useState<Conversation | null>(null);99 const [renameValue, setRenameValue] = useState("");100101 const bottomRef = useRef<HTMLDivElement>(null);102 const textareaRef = useRef<HTMLTextAreaElement>(null);103 const fileInputRef = useRef<HTMLInputElement>(null);104 const abortRef = useRef<AbortController | null>(null);105106 const currentModel = useMemo(() => models.find((m) => m.id === model), [models, model]);107108 // ---------------- Chargements ----------------109 const loadConversations = useCallback(async (q?: string) => {110 const res = await fetch(`/api/conversations${q ? `?q=${encodeURIComponent(q)}` : ""}`);111 if (res.ok) setConversations((await res.json()).conversations);112 }, []);113114 useEffect(() => { loadConversations(); }, [loadConversations]);115116 useEffect(() => {117 fetch("/api/models")118 .then((r) => (r.ok ? r.json() : Promise.reject()))119 .then((d) => {120 setModels(d.models);121 setPresets(d.presets);122 const preferred = localStorage.getItem("immbot-model");123 if (preferred && d.models.some((m: ModelInfo) => m.id === preferred)) setModel(preferred);124 else {125 const rec: string[] = d.presets?.recommande?.models ?? [];126 const first = rec.find((id) => d.models.some((m: ModelInfo) => m.id === id)) ?? d.models[0]?.id ?? "";127 setModel(first);128 }129 })130 .catch(() => setError("Impossible de charger les modèles — vérifiez la clé OpenRouter."));131 }, []);132133 const loadConversation = useCallback(async (id: number) => {134 const res = await fetch(`/api/conversations/${id}`);135 if (!res.ok) return;136 const d = await res.json();137 setConvId(id);138 setMessages(139 d.messages.map((m: { id: number; role: string; content: string; citations: string; attachments: string; tool_trace?: string; model: string; feedback: number; saved: number }) => ({140 ...m,141 citations: JSON.parse(m.citations || "[]"),142 attachments: JSON.parse(m.attachments || "[]"),143 toolTrace: JSON.parse(m.tool_trace || "[]"),144 }))145 );146 if (d.conversation.course_code) setCourse(d.conversation.course_code);147 if (d.conversation.mode) setMode(d.conversation.mode);148 if (d.conversation.knowledge_mode) setKnowledgeMode(d.conversation.knowledge_mode);149 if (d.conversation.model) setModel((prev) => d.conversation.model || prev);150 setSidebarOpen(false);151 }, []);152153 useEffect(() => {154 if (initialConversationId) loadConversation(initialConversationId);155 }, [initialConversationId, loadConversation]);156157 // Préremplissage depuis un lien (ex. « Demander au chat » depuis les diapositives)158 useEffect(() => {159 const q = searchParams.get("q");160 if (q && !initialConversationId) {161 setInput(q);162 textareaRef.current?.focus();163 }164 // eslint-disable-next-line react-hooks/exhaustive-deps165 }, []);166167 useEffect(() => {168 bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });169 }, [messages.length, messages.at(-1)?.content?.length]);170171 // ---------------- Envoi (SSE) ----------------172 async function send(text: string, opts?: { regenerateOfMessageId?: number }) {173 if (!text.trim() || sending || !model) return;174 setError(null);175 setSending(true);176 const tempUser: Message = {177 id: -1, role: "user", content: text, citations: [], attachments: [...attachments], model, feedback: 0, saved: 0,178 };179 const tempAssistant: Message = {180 id: -2, role: "assistant", content: "", citations: [], attachments: [], model, feedback: 0, saved: 0, streaming: true,181 };182 if (opts?.regenerateOfMessageId) {183 setMessages((ms) => [...ms.filter((m) => m.id < opts.regenerateOfMessageId!), tempUser, tempAssistant]);184 } else {185 setMessages((ms) => [...ms, tempUser, tempAssistant]);186 }187 setInput("");188 const sentAttachments = attachments.map((a) => a.id);189 setAttachments([]);190191 const controller = new AbortController();192 abortRef.current = controller;193 try {194 const res = await fetch("/api/chat", {195 method: "POST",196 headers: { "Content-Type": "application/json" },197 signal: controller.signal,198 body: JSON.stringify({199 conversationId: convId ?? undefined,200 courseCode: knowledgeMode === "general" ? course : course,201 message: text,202 model,203 mode,204 knowledgeMode,205 attachmentIds: sentAttachments,206 regenerateOfMessageId: opts?.regenerateOfMessageId,207 }),208 });209 if (!res.ok || !res.body) {210 const d = await res.json().catch(() => ({}));211 throw new Error(d.error ?? `Erreur ${res.status}`);212 }213 const reader = res.body.getReader();214 const decoder = new TextDecoder();215 let buffer = "";216 let acc = "";217 while (true) {218 const { done, value } = await reader.read();219 if (done) break;220 buffer += decoder.decode(value, { stream: true });221 const events = buffer.split("\n\n");222 buffer = events.pop() ?? "";223 for (const ev of events) {224 const line = ev.trim();225 if (!line.startsWith("data:")) continue;226 const data = JSON.parse(line.slice(5));227 if (data.type === "meta") {228 if (!convId) {229 setConvId(data.conversationId);230 window.history.replaceState(null, "", `/chat/${data.conversationId}`);231 }232 setMessages((ms) => ms.map((m) => (m.id === -1 ? { ...m, id: data.userMessageId } : m)));233 } else if (data.type === "tool") {234 setMessages((ms) =>235 ms.map((m) =>236 m.id === -2237 ? {238 ...m,239 toolTrace: [240 ...(m.toolTrace ?? []).map((t) => ({ ...t, running: false })),241 { name: data.name, label: data.label, running: true },242 ],243 }244 : m245 )246 );247 } else if (data.type === "delta") {248 acc += data.text;249 setMessages((ms) => ms.map((m) => (m.id === -2 ? { ...m, content: acc, toolTrace: m.toolTrace?.map((t) => ({ ...t, running: false })) } : m)));250 } else if (data.type === "error") {251 setError(data.message);252 } else if (data.type === "done") {253 setMessages((ms) =>254 ms.map((m) =>255 m.id === -2256 ? {257 ...m,258 id: data.messageId,259 content: data.content,260 citations: data.citations ?? [],261 toolTrace: (data.toolTrace ?? m.toolTrace ?? []).map((t: ToolTraceItem) => ({ ...t, running: false })),262 streaming: false,263 }264 : m265 )266 );267 }268 }269 }270 loadConversations();271 } catch (e) {272 if ((e as Error).name !== "AbortError") {273 setError(e instanceof Error ? e.message : "Erreur d'envoi.");274 setMessages((ms) => ms.filter((m) => m.id !== -2 || m.content));275 }276 } finally {277 setMessages((ms) => ms.map((m) => ({ ...m, streaming: false })));278 setSending(false);279 abortRef.current = null;280 textareaRef.current?.focus();281 }282 }283284 function stopStreaming() {285 abortRef.current?.abort();286 }287288 // ---------------- Actions ----------------289 async function newConversation() {290 setConvId(null);291 setMessages([]);292 setAttachments([]);293 window.history.replaceState(null, "", "/chat");294 textareaRef.current?.focus();295 }296297 async function patchConversation(id: number, patch: Record<string, unknown>) {298 await fetch(`/api/conversations/${id}`, {299 method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),300 });301 loadConversations();302 }303304 async function deleteConversation(id: number) {305 if (!confirm("Supprimer définitivement cette conversation ?")) return;306 await fetch(`/api/conversations/${id}`, { method: "DELETE" });307 if (convId === id) newConversation();308 loadConversations();309 }310311 async function branchFrom(messageId: number) {312 if (!convId) return;313 const res = await fetch(`/api/conversations/${convId}/branch`, {314 method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ upToMessageId: messageId }),315 });316 if (res.ok) {317 const d = await res.json();318 await loadConversation(d.conversationId);319 loadConversations();320 }321 }322323 async function messageAction(id: number, patch: Record<string, unknown>) {324 await fetch(`/api/messages/${id}`, {325 method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),326 });327 }328329 async function uploadFiles(files: FileList | null) {330 if (!files?.length) return;331 setUploading(true);332 setError(null);333 try {334 for (const file of Array.from(files).slice(0, 6 - attachments.length)) {335 const fd = new FormData();336 fd.append("file", file);337 if (convId) fd.append("conversationId", String(convId));338 const res = await fetch("/api/uploads", { method: "POST", body: fd });339 const d = await res.json();340 if (!res.ok) throw new Error(d.error ?? "Erreur de téléversement");341 setAttachments((a) => [...a, d.upload]);342 }343 } catch (e) {344 setError(e instanceof Error ? e.message : "Erreur de téléversement.");345 } finally {346 setUploading(false);347 if (fileInputRef.current) fileInputRef.current.value = "";348 }349 }350351 function copyText(text: string) {352 navigator.clipboard?.writeText(text);353 }354355 function exportConversation() {356 const md = messages357 .map((m) => `**${m.role === "user" ? "Moi" : "Immbot AI"}** :\n\n${m.content}`)358 .join("\n\n---\n\n");359 const blob = new Blob([md], { type: "text/markdown" });360 const a = document.createElement("a");361 a.href = URL.createObjectURL(blob);362 a.download = `immbot-conversation-${convId ?? "nouvelle"}.md`;363 a.click();364 URL.revokeObjectURL(a.href);365 }366367 // Raccourcis clavier368 useEffect(() => {369 const onKey = (e: KeyboardEvent) => {370 if (e.key === "Escape" && sending) stopStreaming();371 };372 window.addEventListener("keydown", onKey);373 return () => window.removeEventListener("keydown", onKey);374 }, [sending]);375376 const filteredModels = useMemo(() => {377 const q = modelSearch.toLowerCase();378 return models.filter((m) => !q || m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q));379 }, [models, modelSearch]);380381 const lastAssistantWithSources = messages.filter((m) => m.role === "assistant" && m.citations.length > 0).at(-1);382383 // ---------------- Rendu ----------------384 return (385 <div className="flex h-[calc(100dvh-4rem)] md:h-dvh overflow-hidden relative">386 {/* Liste des conversations */}387 <div388 className={cn(389 "absolute md:relative z-30 h-full w-72 bg-sidebar border-r border-app flex flex-col transition-transform md:translate-x-0",390 sidebarOpen ? "translate-x-0" : "-translate-x-full"391 )}392 >393 <div className="p-3 space-y-2.5 border-b border-app">394 <Button onClick={newConversation} className="w-full justify-center" size="sm">395 <Plus size={15} /> Nouvelle conversation396 </Button>397 <div className="relative">398 <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />399 <Input400 value={convSearch}401 onChange={(e) => { setConvSearch(e.target.value); loadConversations(e.target.value); }}402 placeholder="Rechercher…"403 className="h-8.5 pl-8.5 text-[13px]"404 />405 </div>406 </div>407 <div className="flex-1 overflow-y-auto p-2 space-y-0.5">408 {conversations.length === 0 && (409 <p className="text-[12.5px] text-muted text-center py-8 px-4">410 Aucune conversation. Posez votre première question !411 </p>412 )}413 {conversations.map((c) => (414 <div415 key={c.id}416 className={cn(417 "group flex items-center gap-1.5 rounded-lg px-2.5 py-2 cursor-pointer transition-colors",418 convId === c.id ? "bg-brand-100 dark:bg-brand-900/60" : "hover:bg-surface-2 dark:hover:bg-brand-900/30"419 )}420 onClick={() => loadConversation(c.id)}421 >422 {c.pinned ? <Pin size={12} className="text-gold-500 shrink-0" /> : null}423 <div className="min-w-0 flex-1">424 <p className="text-[13px] font-medium text-fg truncate">{c.title}</p>425 <p className="text-[11px] text-muted">{c.course_code ?? "Général"}</p>426 </div>427 <div className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>428 <button title="Renommer" aria-label="Renommer" className="p-1 text-muted hover:text-fg" onClick={() => { setRenamingConv(c); setRenameValue(c.title); }}>429 <PenLine size={13} />430 </button>431 <button title={c.pinned ? "Désépingler" : "Épingler"} aria-label="Épingler" className="p-1 text-muted hover:text-fg" onClick={() => patchConversation(c.id, { pinned: !c.pinned })}>432 <Pin size={13} />433 </button>434 <button title="Archiver" aria-label="Archiver" className="p-1 text-muted hover:text-fg" onClick={() => patchConversation(c.id, { archived: true })}>435 <Archive size={13} />436 </button>437 <button title="Supprimer" aria-label="Supprimer" className="p-1 text-muted hover:text-red-500" onClick={() => deleteConversation(c.id)}>438 <Trash2 size={13} />439 </button>440 </div>441 </div>442 ))}443 </div>444 </div>445 {sidebarOpen && <div className="absolute inset-0 z-20 bg-black/30 md:hidden" onClick={() => setSidebarOpen(false)} />}446447 {/* Fil principal */}448 <div className="flex-1 flex flex-col min-w-0">449 {/* Barre d'outils — compacte sur mobile (réglages en feuille basse), complète sur desktop */}450 <div className="border-b border-app bg-card px-2.5 sm:px-4 h-12 flex items-center gap-2">451 <button className="md:hidden p-2 -ml-0.5 text-muted hover:text-fg rounded-md" onClick={() => setSidebarOpen(true)} aria-label="Conversations">452 <Menu size={19} />453 </button>454 {/* Cours — segments nets */}455 <div className="flex items-center rounded-lg border border-app overflow-hidden shrink-0" role="group" aria-label="Cours">456 {courses.map((c) => (457 <button458 key={c.code}459 onClick={() => setCourse(c.code)}460 aria-pressed={course === c.code}461 className={cn(462 "h-8 px-2.5 sm:px-3 text-[12.5px] font-semibold transition-colors",463 course === c.code ? "bg-brand-700 text-white" : "bg-card text-muted hover:text-fg"464 )}465 >466 {c.code.replace("IMM", "")}467 </button>468 ))}469 </div>470 {/* Modèle */}471 <button472 onClick={() => setModelPickerOpen(true)}473 className="h-8 inline-flex items-center gap-1.5 rounded-lg border border-app bg-card px-2.5 text-[12.5px] font-medium text-fg hover:border-brand-400 min-w-0 max-w-36 sm:max-w-60"474 >475 <Sparkles size={13} className="text-gold-500 shrink-0" />476 <span className="truncate">{currentModel?.name.replace(/^.*?:\s*/, "") ?? "Modèle…"}</span>477 <ChevronDown size={12} className="text-muted shrink-0" />478 </button>479 {/* Mode + connaissances : inline ≥ md seulement */}480 <select481 value={mode}482 onChange={(e) => setMode(e.target.value)}483 aria-label="Mode pédagogique"484 title={MODES.find((m) => m.key === mode)?.hint}485 className="hidden md:block h-8 rounded-lg border border-app bg-card text-[12.5px] font-medium text-fg px-2 outline-none focus:border-brand-400"486 >487 {MODES.map((m) => (488 <option key={m.key} value={m.key}>{m.label}</option>489 ))}490 </select>491 <select492 value={knowledgeMode}493 onChange={(e) => setKnowledgeMode(e.target.value)}494 aria-label="Source des connaissances"495 title={KNOWLEDGE_MODES.find((m) => m.key === knowledgeMode)?.hint}496 className="hidden md:block h-8 rounded-lg border border-app bg-card text-[12.5px] font-medium text-fg px-2 outline-none focus:border-brand-400"497 >498 {KNOWLEDGE_MODES.map((m) => (499 <option key={m.key} value={m.key}>{m.label}</option>500 ))}501 </select>502 <div className="ml-auto flex items-center gap-1 shrink-0">503 {currentModel && (504 <span className="hidden lg:block">505 <Badge tone={COST_LABEL[currentModel.costTier]?.tone ?? "neutral"}>506 {currentModel.isFree ? "Gratuit" : COST_LABEL[currentModel.costTier]?.label}507 </Badge>508 </span>509 )}510 {messages.length > 0 && (511 <Button variant="ghost" size="sm" onClick={exportConversation} title="Exporter en Markdown" className="hidden sm:inline-flex">512 Exporter513 </Button>514 )}515 {/* Réglages (mobile) */}516 <button517 onClick={() => setSettingsOpen(true)}518 aria-label="Réglages de la conversation"519 className="md:hidden relative p-2 text-muted hover:text-fg rounded-md"520 >521 <Settings2 size={18} />522 {(mode !== "ask" || knowledgeMode !== "course-only") && (523 <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-gold-500" aria-hidden />524 )}525 </button>526 </div>527 </div>528529 {/* Feuille de réglages mobile */}530 <Modal open={settingsOpen} onClose={() => setSettingsOpen(false)} title="Réglages de la conversation">531 <div className="space-y-5">532 <div>533 <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mb-2">Mode pédagogique</p>534 <div className="grid grid-cols-1 gap-1.5">535 {MODES.map((m) => (536 <button537 key={m.key}538 onClick={() => { setMode(m.key); }}539 aria-pressed={mode === m.key}540 className={cn(541 "flex items-start justify-between gap-3 px-3.5 py-2.5 rounded-lg border text-left transition-colors",542 mode === m.key ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40" : "border-app hover:border-brand-300"543 )}544 >545 <span>546 <span className="block text-[13.5px] font-semibold text-fg">{m.label}</span>547 <span className="block text-[12px] text-muted mt-0.5">{m.hint}</span>548 </span>549 {mode === m.key && <Check size={16} className="text-brand-600 shrink-0 mt-1" />}550 </button>551 ))}552 </div>553 </div>554 <div>555 <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mb-2">Source des connaissances</p>556 <div className="grid grid-cols-1 gap-1.5">557 {KNOWLEDGE_MODES.map((m) => (558 <button559 key={m.key}560 onClick={() => setKnowledgeMode(m.key)}561 aria-pressed={knowledgeMode === m.key}562 className={cn(563 "flex items-start justify-between gap-3 px-3.5 py-2.5 rounded-lg border text-left transition-colors",564 knowledgeMode === m.key ? "border-brand-500 bg-brand-50 dark:bg-brand-900/40" : "border-app hover:border-brand-300"565 )}566 >567 <span>568 <span className="block text-[13.5px] font-semibold text-fg">{m.label}</span>569 <span className="block text-[12px] text-muted mt-0.5">{m.hint}</span>570 </span>571 {knowledgeMode === m.key && <Check size={16} className="text-brand-600 shrink-0 mt-1" />}572 </button>573 ))}574 </div>575 </div>576 {messages.length > 0 && (577 <Button variant="secondary" className="w-full justify-center" onClick={() => { exportConversation(); setSettingsOpen(false); }}>578 Exporter la conversation (.md)579 </Button>580 )}581 <Button className="w-full justify-center" onClick={() => setSettingsOpen(false)}>Terminé</Button>582 </div>583 </Modal>584585 {/* Messages */}586 <div className="flex-1 overflow-y-auto">587 <div className="max-w-3xl mx-auto px-3 sm:px-5 py-6 space-y-6">588 {messages.length === 0 && (589 <div className="flex flex-col items-center text-center pt-10 sm:pt-16 animate-fade-up">590 <ImmbotMark size={52} />591 <h2 className="mt-4 text-lg sm:text-xl font-bold text-fg tracking-tight">592 Posez une question sur {course ?? "vos cours"}593 </h2>594 <p className="text-[13px] text-muted mt-1.5 max-w-sm">595 Réponses fondées sur le matériel officiel, avec les diapositives citées.596 </p>597 <div className="mt-6 grid gap-2 w-full max-w-md">598 {(course === "IMM1033" ? SUGGESTIONS_1033 : SUGGESTIONS_1003).map((s) => (599 <button600 key={s}601 onClick={() => { setInput(s); textareaRef.current?.focus(); }}602 className="text-left text-[13.5px] text-fg bg-card border border-app rounded-lg px-4 py-3 hover:border-brand-400 hover:shadow-sm transition-all"603 >604 {s}605 </button>606 ))}607 </div>608 </div>609 )}610 {messages.map((m) =>611 m.role === "user" ? (612 <div key={m.id} className="flex justify-end group">613 <div className="max-w-[88%] sm:max-w-xl">614 <div className="bg-brand-600 text-white rounded-2xl rounded-br-md px-4 py-2.5 text-[14.5px] whitespace-pre-wrap break-words">615 {m.content}616 {m.attachments.length > 0 && (617 <div className="mt-2 flex flex-wrap gap-1.5">618 {m.attachments.map((a) => (619 <span key={a.id} className="inline-flex items-center gap-1 bg-white/15 rounded-md px-2 py-0.5 text-[11.5px]">620 <Paperclip size={10} /> {a.filename}621 </span>622 ))}623 </div>624 )}625 </div>626 <div className="hidden group-hover:flex justify-end gap-1 mt-1">627 <button title="Modifier et relancer" className="p-1 text-muted hover:text-fg" onClick={() => { setEditingMessageId(m.id); setInput(m.content); textareaRef.current?.focus(); }}>628 <Pencil size={13} />629 </button>630 <button title="Copier" className="p-1 text-muted hover:text-fg" onClick={() => copyText(m.content)}>631 <Copy size={13} />632 </button>633 </div>634 </div>635 </div>636 ) : (637 <div key={m.id} className="flex gap-3 group">638 <ImmbotMark size={26} className="shrink-0 mt-0.5 hidden sm:block" />639 <div className="min-w-0 flex-1">640 {(m.toolTrace?.length ?? 0) > 0 && (641 <div className="mb-2.5 space-y-1" aria-label="Consultations du matériel de cours">642 {m.toolTrace!.map((t, ti) => (643 <div644 key={ti}645 className={cn(646 "inline-flex items-center gap-2 mr-1.5 px-2.5 py-1 rounded-lg border text-[12px] font-medium",647 t.running648 ? "border-brand-300 bg-brand-50 dark:bg-brand-900/40 text-brand-700 dark:text-brand-200"649 : "border-app bg-surface-1 dark:bg-brand-950/40 text-muted"650 )}651 >652 {t.running ? <Spinner className="h-3 w-3" /> : <Search size={11} className="opacity-70" />}653 {t.label}654 </div>655 ))}656 </div>657 )}658 <Markdown659 content={m.content || (m.streaming ? "" : "*Réponse vide.*")}660 streaming={m.streaming}661 onCitationClick={(index) => {662 const c = m.citations.find((x) => x.index === index);663 if (c) setCitation(c);664 }}665 />666 {m.citations.length > 0 && !m.streaming && (667 <div className="mt-3 flex flex-wrap items-center gap-1.5">668 <span className="text-[11.5px] text-muted font-medium">Sources :</span>669 {m.citations.map((c) => (670 <button key={c.tag} className="citation-chip" onClick={() => setCitation(c)} title={c.refLabel}>671 {c.tag}672 </button>673 ))}674 </div>675 )}676 {!m.streaming && m.content && (677 <div className="flex items-center gap-0.5 mt-2 opacity-0 group-hover:opacity-100 transition-opacity">678 <button title="Copier" className="p-1.5 text-muted hover:text-fg rounded-md" onClick={() => copyText(m.content)}>679 <Copy size={14} />680 </button>681 <button title="Régénérer" className="p-1.5 text-muted hover:text-fg rounded-md" disabled={sending}682 onClick={() => {683 const prevUser = [...messages].reverse().find((x) => x.role === "user" && x.id < m.id);684 if (prevUser) send(prevUser.content, { regenerateOfMessageId: prevUser.id });685 }}>686 <RefreshCw size={14} />687 </button>688 <button title="Créer une branche à partir d'ici" className="p-1.5 text-muted hover:text-fg rounded-md" onClick={() => branchFrom(m.id)}>689 <GitBranch size={14} />690 </button>691 <button title="Sauvegarder dans la bibliothèque" className="p-1.5 text-muted hover:text-gold-500 rounded-md"692 onClick={(e) => { messageAction(m.id, { save: true }); (e.currentTarget as HTMLButtonElement).classList.add("text-gold-500"); }}>693 <Bookmark size={14} />694 </button>695 <span className="w-px h-4 bg-app mx-1" />696 <button title="Réponse utile" className={cn("p-1.5 rounded-md", m.feedback === 1 ? "text-emerald-500" : "text-muted hover:text-emerald-500")}697 onClick={() => { messageAction(m.id, { feedback: m.feedback === 1 ? 0 : 1 }); setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, feedback: x.feedback === 1 ? 0 : 1 } : x))); }}>698 <ThumbsUp size={14} />699 </button>700 <button title="Réponse à revoir" className={cn("p-1.5 rounded-md", m.feedback === -1 ? "text-red-500" : "text-muted hover:text-red-500")}701 onClick={() => { messageAction(m.id, { feedback: m.feedback === -1 ? 0 : -1 }); setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, feedback: x.feedback === -1 ? 0 : -1 } : x))); }}>702 <ThumbsDown size={14} />703 </button>704 <button title="Signaler une erreur au professeur" className="p-1.5 text-muted hover:text-amber-500 rounded-md"705 onClick={() => { const reason = prompt("Décrivez brièvement le problème :"); if (reason !== null) messageAction(m.id, { flag: true, flagReason: reason }); }}>706 <Flag size={14} />707 </button>708 </div>709 )}710 </div>711 </div>712 )713 )}714 {error && (715 <div className="text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/25 rounded-xl px-4 py-3" role="alert">716 {error}717 </div>718 )}719 <div ref={bottomRef} />720 </div>721 </div>722723 {/* Composeur */}724 <div className="border-t border-app bg-card/70 backdrop-blur px-3 sm:px-5 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">725 <div className="max-w-3xl mx-auto">726 {attachments.length > 0 && (727 <div className="flex flex-wrap gap-1.5 mb-2">728 {attachments.map((a) => (729 <span key={a.id} className="inline-flex items-center gap-1.5 bg-surface-2 dark:bg-brand-900/50 rounded-lg px-2.5 py-1 text-[12px] text-fg">730 <Paperclip size={11} /> {a.filename}731 <button onClick={() => setAttachments((x) => x.filter((y) => y.id !== a.id))} aria-label="Retirer" className="text-muted hover:text-fg"><X size={12} /></button>732 </span>733 ))}734 </div>735 )}736 {editingMessageId && (737 <div className="flex items-center justify-between text-[12px] text-amber-700 dark:text-amber-400 bg-amber-500/10 rounded-lg px-3 py-1.5 mb-2">738 <span>Modification d'une question — l'envoi remplacera la suite de la conversation.</span>739 <button onClick={() => { setEditingMessageId(null); setInput(""); }} className="font-medium hover:underline">Annuler</button>740 </div>741 )}742 <div className="flex items-end gap-2 bg-card border border-app rounded-2xl p-2 shadow-sm focus-within:border-brand-400 focus-within:ring-2 focus-within:ring-brand-500/20 transition-shadow">743 <input ref={fileInputRef} type="file" multiple hidden accept=".pdf,.docx,.xlsx,.csv,.txt,.md,.png,.jpg,.jpeg,.webp" onChange={(e) => uploadFiles(e.target.files)} />744 <button745 onClick={() => fileInputRef.current?.click()}746 disabled={uploading || attachments.length >= 6}747 title="Joindre un fichier (PDF, DOCX, XLSX, CSV, image…)"748 aria-label="Joindre un fichier"749 className="p-2.5 text-muted hover:text-fg rounded-xl disabled:opacity-50 shrink-0"750 >751 {uploading ? <Spinner /> : <Paperclip size={17} />}752 </button>753 <Textarea754 ref={textareaRef}755 value={input}756 onChange={(e) => setInput(e.target.value)}757 onKeyDown={(e) => {758 if (e.key === "Enter" && !e.shiftKey) {759 e.preventDefault();760 if (editingMessageId) {761 send(input, { regenerateOfMessageId: editingMessageId });762 setEditingMessageId(null);763 } else send(input);764 }765 }}766 placeholder={`Votre question sur ${course ?? "le cours"}…`}767 rows={Math.min(6, Math.max(1, input.split("\n").length))}768 className="border-0 bg-transparent focus:ring-0 px-1 py-2 text-[14.5px]"769 aria-label="Votre question"770 />771 {sending ? (772 <Button variant="secondary" size="icon" onClick={stopStreaming} title="Arrêter (Échap)" className="shrink-0">773 <span className="w-3 h-3 bg-fg rounded-[3px]" />774 </Button>775 ) : (776 <Button777 size="icon"778 disabled={!input.trim() || !model}779 onClick={() => {780 if (editingMessageId) {781 send(input, { regenerateOfMessageId: editingMessageId });782 setEditingMessageId(null);783 } else send(input);784 }}785 title="Envoyer"786 className="shrink-0"787 >788 <ArrowUp size={17} />789 </Button>790 )}791 </div>792 <p className="text-[11px] text-muted text-center mt-1.5 truncate">793 Immbot AI peut se tromper — vérifiez les sources citées.794 </p>795 </div>796 </div>797 </div>798799 {/* Sélecteur de modèle */}800 <Modal open={modelPickerOpen} onClose={() => setModelPickerOpen(false)} title="Choisir un modèle" wide>801 <div className="space-y-4">802 <div>803 <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mb-2">Préréglages</p>804 <div className="grid sm:grid-cols-2 gap-2">805 {Object.entries(presets).map(([key, p]) => {806 const available = p.models.find((id) => models.some((m) => m.id === id));807 if (!available) return null;808 return (809 <button810 key={key}811 onClick={() => { setModel(available); localStorage.setItem("immbot-model", available); setModelPickerOpen(false); }}812 className="text-left border border-app rounded-xl px-3.5 py-2.5 hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"813 >814 <p className="text-[13px] font-semibold text-fg">{p.label}</p>815 <p className="text-[11.5px] text-muted mt-0.5">{p.description}</p>816 </button>817 );818 })}819 </div>820 </div>821 <div>822 <Input value={modelSearch} onChange={(e) => setModelSearch(e.target.value)} placeholder={`Rechercher parmi ${models.length} modèles…`} />823 <div className="mt-2 max-h-72 overflow-y-auto space-y-1">824 {filteredModels.slice(0, 60).map((m) => (825 <button826 key={m.id}827 onClick={() => { setModel(m.id); localStorage.setItem("immbot-model", m.id); setModelPickerOpen(false); }}828 className={cn(829 "w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-left hover:bg-surface-2 dark:hover:bg-brand-900/30 transition-colors",830 model === m.id && "bg-brand-100 dark:bg-brand-900/50"831 )}832 >833 {model === m.id ? <Check size={14} className="text-brand-500 shrink-0" /> : <span className="w-3.5 shrink-0" />}834 <div className="min-w-0 flex-1">835 <p className="text-[13px] font-medium text-fg truncate">{m.name}</p>836 <p className="text-[11px] text-muted truncate">{m.id}</p>837 </div>838 <div className="flex gap-1 shrink-0">839 {m.supportsImages && <Badge tone="brand">Vision</Badge>}840 {m.isFree ? <Badge tone="green">Gratuit</Badge> : <Badge tone={COST_LABEL[m.costTier]?.tone ?? "neutral"}>{COST_LABEL[m.costTier]?.label}</Badge>}841 </div>842 </button>843 ))}844 {filteredModels.length === 0 && <p className="text-sm text-muted text-center py-6">Aucun modèle trouvé.</p>}845 </div>846 </div>847 </div>848 </Modal>849850 {/* Renommage */}851 <Modal open={!!renamingConv} onClose={() => setRenamingConv(null)} title="Renommer la conversation">852 <form853 onSubmit={(e) => {854 e.preventDefault();855 if (renamingConv) patchConversation(renamingConv.id, { title: renameValue });856 setRenamingConv(null);857 }}858 className="space-y-3"859 >860 <Input value={renameValue} onChange={(e) => setRenameValue(e.target.value)} autoFocus maxLength={200} />861 <Button type="submit" className="w-full justify-center">Renommer</Button>862 </form>863 </Modal>864865 <CitationPanel citation={citation} onClose={() => setCitation(null)} />866 {lastAssistantWithSources ? null : null}867 </div>868 );869}870