SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
41.9 KB · 870 lines tsx
Raw Blame History
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