prisme / static /app.js
nvikou's picture
Déploiement de Prisme
cf18691 verified
Raw History Blame Contribute Delete
19.2 kB
/* 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
return esc.split(/\n{2,}/).map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`).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) => `<i data-type="${s.type}"></i>`).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 = `<button type="button" aria-expanded="false"><span class="num"></span><span class="ref"><span></span><small></small></span><span class="score" title=""><i></i></span></button><div class="excerpt"></div>`;
$(".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 = `<div class="job-head"><span class="job-name"></span><span class="job-pct"></span></div><div class="job-stage"></div><div class="bar"><i></i></div>`;
$(".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 = `<div class="job-err"></div>`;
$(".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 = `<span class="ftypes"></span><span class="fname"></span><span class="fcount"></span>`;
$(".ftypes", li).innerHTML = f.types.map((t) => `<i data-type="${t}" title="${TYPE_LABEL[t]}"></i>`).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();
})();