Download static/app.js from nvikou/prisme: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/spaces/nvikou/prisme/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/nvikou/prisme/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/nvikou/prisme/resolve/main/static/app.js
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) => ({ "&": "&", "<": "<", ">": ">" }[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(); | |
| })(); | |