/* Prisme — interface. Aucune dépendance hormis marked + DOMPurify (optionnels). */ (() => { "use strict"; const $ = (s, el = document) => el.querySelector(s); const TYPE_LABEL = { AUDIO: "Audio", VIDEO: "Vidéo", IMAGE: "Image", TEXT: "Texte" }; const fmtSec = (s) => `${Number(s).toLocaleString("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`; const fmtInt = (n) => Number(n).toLocaleString("fr-FR"); const state = { history: [], busy: false, controller: null, effort: "medium", polling: null, knownJobs: new Set(), total: 0, }; const el = { side: $("#side"), scrim: $("#scrim"), thread: $("#thread"), scroll: $("#scroll"), hero: $("#hero"), input: $("#input"), composer: $("#composer"), send: $("#send"), model: $("#model"), filter: $("#filter"), effort: $("#effort"), drop: $("#drop"), fileInput: $("#fileInput"), interval: $("#interval"), jobs: $("#jobs"), files: $("#files"), emptyFiles: $("#emptyFiles"), totalCount: $("#totalCount"), modalities: $("#modalities"), banner: $("#groqBanner"), modelsInfo: $("#modelsInfo"), heroSub: $("#heroSub"), }; /* ---------- Markdown sûr ---------- */ function renderMarkdown(text) { if (window.marked && window.DOMPurify) { const html = window.marked.parse(text, { gfm: true, breaks: false }); return window.DOMPurify.sanitize(html); } const esc = text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); return esc.split(/\n{2,}/).map((p) => `

${p.replace(/\n/g, "
")}

`).join(""); } function decorate(container, sourcesByN) { container.querySelectorAll("table").forEach((t) => { if (!t.parentElement.classList.contains("table-wrap")) { const w = document.createElement("div"); w.className = "table-wrap"; t.replaceWith(w); w.appendChild(t); } }); const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { acceptNode: (n) => (n.parentElement.closest("code, pre, .cite") ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT), }); const nodes = []; while (walker.nextNode()) if (/\[\d{1,2}\]/.test(walker.currentNode.nodeValue)) nodes.push(walker.currentNode); nodes.forEach((node) => { const frag = document.createDocumentFragment(); node.nodeValue.split(/(\[\d{1,2}\])/).forEach((part) => { const m = part.match(/^\[(\d{1,2})\]$/); const src = m && sourcesByN[m[1]]; if (!src) { frag.appendChild(document.createTextNode(part)); return; } const b = document.createElement("button"); b.type = "button"; b.className = "cite"; b.dataset.type = src.type; b.dataset.n = src.n; b.textContent = src.n; b.title = `${TYPE_LABEL[src.type]} : ${src.reference}`; frag.appendChild(b); }); node.replaceWith(frag); }); } /* ---------- Défilement ---------- */ const nearBottom = () => el.scroll.scrollHeight - el.scroll.scrollTop - el.scroll.clientHeight < 140; const toBottom = (force) => { if (force || nearBottom()) el.scroll.scrollTop = el.scroll.scrollHeight; }; /* ---------- Messages ---------- */ function addUser(text) { el.hero.hidden = true; const a = document.createElement("article"); a.className = "msg user"; const b = document.createElement("div"); b.className = "bubble"; b.textContent = text; a.appendChild(b); el.thread.appendChild(a); toBottom(true); } function addAssistant() { const node = $("#tpl-assistant").content.firstElementChild.cloneNode(true); el.thread.appendChild(node); toBottom(true); const ui = { node, status: $(".status", node), statusText: $(".status-text", node), thinking: $(".thinking", node), thinkHead: $(".thinking-head", node), thinkLabel: $(".thinking-label", node), thinkTime: $(".thinking-time", node), thinkText: $(".thinking-text", node), answer: $(".answer", node), sources: $(".sources", node), sourceList: $(".source-list", node), sourcesLabel: $(".sources-label", node), sourcesDots: $(".sources-dots", node), meta: $(".meta", node), metaModel: $(".meta-model", node), metaTokens: $(".meta-tokens", node), metaTime: $(".meta-time", node), reasoning: "", content: "", sourcesByN: {}, t0: performance.now(), tThink: null, timer: null, raf: 0, }; ui.thinkHead.addEventListener("click", () => { ui.thinkHead.setAttribute("aria-expanded", ui.thinkHead.getAttribute("aria-expanded") === "true" ? "false" : "true"); }); $(".copy", node).addEventListener("click", async (e) => { try { await navigator.clipboard.writeText(ui.content); e.target.textContent = "Copié"; setTimeout(() => (e.target.textContent = "Copier"), 1500); } catch (_) {} }); node.addEventListener("click", (e) => { const c = e.target.closest(".cite"); if (!c) return; ui.sources.open = true; const row = ui.sourceList.querySelector(`[data-n="${c.dataset.n}"]`); if (row) { row.classList.add("open", "flash"); row.scrollIntoView({ behavior: "smooth", block: "center" }); setTimeout(() => row.classList.remove("flash"), 1400); } }); return ui; } function renderSources(ui, sources) { ui.sourcesByN = Object.fromEntries(sources.map((s) => [String(s.n), s])); if (!sources.length) return; ui.sources.hidden = false; ui.sourcesLabel.textContent = `${sources.length} extrait${sources.length > 1 ? "s" : ""} consulté${sources.length > 1 ? "s" : ""}`; ui.sourcesDots.innerHTML = sources.map((s) => ``).join(""); const max = Math.max(...sources.map((s) => s.score), 0.0001); ui.sourceList.innerHTML = ""; sources.forEach((s) => { const li = document.createElement("li"); li.className = "source"; li.dataset.n = s.n; li.dataset.type = s.type; li.innerHTML = `
`; $(".num", li).textContent = s.n; $(".ref span", li).textContent = s.file; $(".ref small", li).textContent = s.location ? `${TYPE_LABEL[s.type]}, ${s.location}` : TYPE_LABEL[s.type]; $(".score", li).title = `Similarité : ${s.score.toFixed(3)}`; $(".score i", li).style.width = `${Math.round((s.score / max) * 100)}%`; $(".excerpt", li).textContent = s.text; $("button", li).addEventListener("click", () => { const open = li.classList.toggle("open"); $("button", li).setAttribute("aria-expanded", open); }); ui.sourceList.appendChild(li); }); } function paintAnswer(ui, final = false) { cancelAnimationFrame(ui.raf); ui.raf = requestAnimationFrame(() => { ui.answer.innerHTML = renderMarkdown(ui.content); decorate(ui.answer, ui.sourcesByN); ui.answer.classList.toggle("streaming", !final); toBottom(); }); } function startThinking(ui) { ui.status.hidden = true; ui.thinking.hidden = false; ui.thinking.classList.add("live"); ui.tThink = performance.now(); ui.timer = setInterval(() => (ui.thinkTime.textContent = fmtSec((performance.now() - ui.tThink) / 1000)), 100); } function endThinking(ui, seconds) { if (!ui.tThink) return; clearInterval(ui.timer); ui.timer = null; const s = seconds ?? (performance.now() - ui.tThink) / 1000; ui.thinking.classList.remove("live"); ui.thinking.classList.add("done"); ui.thinkLabel.textContent = `Réflexion pendant ${fmtSec(s)}`; ui.thinkTime.textContent = ""; ui.thinkHead.setAttribute("aria-expanded", "false"); ui.tThink = null; } function showError(ui, message) { ui.status.hidden = true; const p = document.createElement("p"); p.className = "error"; p.textContent = message; ui.answer.after(p); } /* ---------- Envoi d'une question (flux SSE) ---------- */ async function ask(question) { if (state.busy || !question.trim()) return; addUser(question); const ui = addAssistant(); setBusy(true); state.controller = new AbortController(); const body = { question, history: state.history.slice(-6), model: el.model.value, effort: state.effort, source_filter: el.filter.value || null, }; let usage = null, elapsed = null, model = el.model.value; try { const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: state.controller.signal, }); if (!res.ok || !res.body) throw new Error(`Le serveur a répondu ${res.status}.`); const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { const raw = buf.slice(0, idx); buf = buf.slice(idx + 2); const line = raw.split("\n").find((l) => l.startsWith("data:")); if (!line) continue; const evt = JSON.parse(line.slice(5)); switch (evt.type) { case "sources": renderSources(ui, evt.sources); ui.statusText.textContent = evt.sources.length ? `${evt.sources.length} extraits trouvés, lecture en cours…` : "Aucun extrait trouvé"; break; case "reasoning": if (!ui.tThink && !ui.thinking.classList.contains("done")) startThinking(ui); ui.reasoning += evt.text; ui.thinkText.textContent = ui.reasoning; ui.thinkText.scrollTop = ui.thinkText.scrollHeight; ui.thinkText.classList.toggle("clip", ui.thinkText.scrollHeight > ui.thinkText.clientHeight + 4); toBottom(); break; case "reasoning_done": endThinking(ui, evt.seconds); break; case "content": ui.status.hidden = true; if (ui.tThink) endThinking(ui); ui.content += evt.text; paintAnswer(ui); break; case "error": showError(ui, evt.message); break; case "done": usage = evt.usage; elapsed = evt.elapsed; model = evt.model || model; break; } } } } catch (err) { if (err.name !== "AbortError") showError(ui, `Connexion interrompue : ${err.message}`); else if (!ui.content) showError(ui, "Réponse arrêtée."); } finally { if (ui.tThink) endThinking(ui); ui.status.hidden = true; paintAnswer(ui, true); if (ui.content) { ui.meta.hidden = false; ui.metaModel.textContent = model.replace("openai/", ""); if (usage) ui.metaTokens.textContent = `${fmtInt(usage.prompt_tokens || 0)} tokens lus, ${fmtInt(usage.completion_tokens || 0)} générés`; ui.metaTime.textContent = fmtSec(elapsed ?? (performance.now() - ui.t0) / 1000); state.history.push({ role: "user", content: question }, { role: "assistant", content: ui.content }); } setBusy(false); } } function setBusy(b) { state.busy = b; el.composer.classList.toggle("busy", b); el.send.setAttribute("aria-label", b ? "Arrêter" : "Envoyer"); updateSend(); } const updateSend = () => { el.send.disabled = !state.busy && !el.input.value.trim(); }; /* ---------- Zone de saisie ---------- */ const autosize = () => { el.input.style.height = "auto"; el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`; }; el.input.addEventListener("input", () => { autosize(); updateSend(); }); el.input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); el.composer.requestSubmit(); } }); el.composer.addEventListener("submit", (e) => { e.preventDefault(); if (state.busy) { state.controller?.abort(); return; } const q = el.input.value.trim(); if (!q) return; el.input.value = ""; autosize(); ask(q); }); $("#suggestions").addEventListener("click", (e) => { const b = e.target.closest("button"); if (b) ask(b.textContent); }); el.effort.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; state.effort = b.dataset.v; el.effort.querySelectorAll("button").forEach((x) => x.setAttribute("aria-checked", x === b ? "true" : "false")); }); $("#newChat").addEventListener("click", () => { if (state.busy) state.controller?.abort(); state.history = []; el.thread.querySelectorAll(".msg").forEach((m) => m.remove()); el.hero.hidden = false; el.input.focus(); }); /* ---------- Thème et panneau mobile ---------- */ $("#themeBtn").addEventListener("click", () => { const t = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = t; try { localStorage.setItem("prisme-theme", t); } catch (_) {} }); const openSide = (o) => { el.side.classList.toggle("open", o); el.scrim.hidden = !o; }; $("#openSide").addEventListener("click", () => openSide(true)); $("#closeSide").addEventListener("click", () => openSide(false)); el.scrim.addEventListener("click", () => openSide(false)); /* ---------- Téléversement ---------- */ function upload(fileList) { const files = [...fileList]; if (!files.length) return; const fd = new FormData(); files.forEach((f) => fd.append("files", f)); fd.append("frame_interval", el.interval.value); const card = jobCard({ id: "upload", files: files.map((f) => f.name), status: "running", stage: "Envoi des fichiers", progress: 0 }); const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/upload"); xhr.upload.onprogress = (e) => { if (e.lengthComputable) updateJob(card, { stage: "Envoi des fichiers", progress: e.loaded / e.total, status: "running" }); }; xhr.onload = () => { card.remove(); let data = {}; try { data = JSON.parse(xhr.responseText); } catch (_) {} if (xhr.status >= 400) { flashJobError(data.detail || "Envoi refusé."); return; } (data.rejected || []).forEach(flashJobError); refresh(); startPolling(); }; xhr.onerror = () => { card.remove(); flashJobError("Envoi interrompu. Vérifie ta connexion."); }; xhr.send(fd); } el.fileInput.addEventListener("change", () => { upload(el.fileInput.files); el.fileInput.value = ""; }); el.drop.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.fileInput.click(); } }); ["dragenter", "dragover"].forEach((t) => el.drop.addEventListener(t, (e) => { e.preventDefault(); el.drop.classList.add("over"); })); ["dragleave", "drop"].forEach((t) => el.drop.addEventListener(t, (e) => { e.preventDefault(); el.drop.classList.remove("over"); })); el.drop.addEventListener("drop", (e) => upload(e.dataTransfer.files)); function jobCard(job) { const d = document.createElement("div"); d.className = "job"; d.dataset.id = job.id; d.innerHTML = `
`; $(".job-name", d).textContent = job.files.length > 1 ? `${job.files.length} fichiers` : job.files[0]; $(".job-name", d).title = job.files.join(", "); el.jobs.prepend(d); updateJob(d, job); return d; } function updateJob(d, job) { const pct = Math.round((job.progress || 0) * 100); $(".job-pct", d).textContent = `${pct} %`; $(".bar i", d).style.width = `${pct}%`; const cur = job.current && job.files && job.files.length > 1 ? `${job.current} : ` : ""; let stage = `${cur}${job.stage || ""}`; if (job.status === "done") { const n = Object.values(job.added || {}).reduce((a, b) => a + b, 0); stage = `Terminé : ${fmtInt(n)} extrait${n > 1 ? "s" : ""} ajouté${n > 1 ? "s" : ""}`; } $(".job-stage", d).textContent = stage; d.classList.toggle("done", job.status === "done"); d.querySelectorAll(".job-err").forEach((x) => x.remove()); (job.errors || []).forEach((m) => { const p = document.createElement("div"); p.className = "job-err"; p.textContent = m; d.appendChild(p); }); } function flashJobError(msg) { const d = document.createElement("div"); d.className = "job"; d.innerHTML = `
`; $(".job-err", d).textContent = msg; el.jobs.prepend(d); setTimeout(() => d.remove(), 9000); } /* ---------- État de la base ---------- */ async function refresh() { let s; try { s = await (await fetch("/api/status")).json(); } catch (_) { return false; } el.banner.hidden = s.groq_ready; if (!el.model.options.length) { s.models.chat.forEach((m) => el.model.add(new Option(m.replace("openai/", ""), m, false, m === s.models.default_chat))); el.modelsInfo.textContent = `${s.models.asr.replace("whisper-", "Whisper ")} et ${s.models.vision.split("/").pop()} via Groq`; } const st = s.store; state.total = st.total; el.totalCount.textContent = st.total ? `${fmtInt(st.total)} extraits` : ""; el.modalities.querySelectorAll("li").forEach((li) => ($("b", li).textContent = fmtInt(st.by_type[li.dataset.type] || 0))); el.files.innerHTML = ""; st.files.forEach((f) => { const li = document.createElement("li"); li.innerHTML = ``; $(".ftypes", li).innerHTML = f.types.map((t) => ``).join(""); $(".fname", li).textContent = f.name; $(".fname", li).title = f.name; $(".fcount", li).textContent = fmtInt(f.chunks); el.files.appendChild(li); }); el.emptyFiles.hidden = st.files.length > 0; el.heroSub.textContent = st.total ? `${fmtInt(st.total)} extraits indexés à partir de ${st.files.length} fichier${st.files.length > 1 ? "s" : ""}. Chaque réponse cite l'extrait exact sur lequel elle s'appuie.` : "Commence par ajouter des fichiers dans le panneau de gauche. Chaque réponse cite l'extrait exact sur lequel elle s'appuie."; const live = new Set(); s.jobs.forEach((j) => { live.add(j.id); const d = el.jobs.querySelector(`.job[data-id="${j.id}"]`) || jobCard(j); updateJob(d, j); }); el.jobs.querySelectorAll(".job[data-id]").forEach((d) => { if (d.dataset.id !== "upload" && !live.has(d.dataset.id)) d.remove(); }); return s.jobs.some((j) => j.status === "queued" || j.status === "running"); } function startPolling() { clearTimeout(state.polling); const tick = async () => { const busy = await refresh(); state.polling = setTimeout(tick, busy ? 1500 : 15000); }; tick(); } $("#resetBtn").addEventListener("click", async () => { if (!confirm("Vider la base ? Les fichiers et l'index seront supprimés.")) return; const r = await fetch("/api/reset", { method: "POST" }); if (!r.ok) { const d = await r.json().catch(() => ({})); flashJobError(d.message || "Impossible de vider la base."); } refresh(); }); updateSend(); startPolling(); })();