"use strict"; /* Kazeia-central — shell d'application (clinique-apaisé, sans build). */ const S = { initialized: false, unlocked: false, devices: [], selected: null, section: "flotte" }; // ---- helpers -------------------------------------------------------------- const $ = (s, r = document) => r.querySelector(s); function elFrom(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } async function api(path, opts) { const r = await fetch("/api" + path, opts); if (!r.ok) { let d = r.status; try { d = (await r.json()).detail || d; } catch {} const e = new Error(d); e.status = r.status; throw e; } return r.status === 204 ? null : r.json(); } function toast(msg, kind = "info", ms = 3800) { const t = elFrom(`
${msg}
`); $("#toasts").appendChild(t); setTimeout(() => t.remove(), ms); } const esc = (s) => (s == null ? "" : String(s)).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); const badge = (txt, cls = "") => `${esc(txt)}`; const dot = (cls) => ``; const fmtTs = (ms) => ms ? new Date(ms).toLocaleString("fr-FR") : "—"; const scopeBadge = (s) => badge(s || "?", { exclusive: "warn", global: "ok", pending: "" }[s] || ""); function deviceLabel(serial) { const d = S.devices.find(x => x.serial === serial); return d && d.label ? d.label : serial; } async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = true; btn.innerHTML = ``; try { return await fn(); } finally { btn.disabled = false; btn.textContent = o; } } // ---- sections ------------------------------------------------------------- const SECTIONS = [ { group: "🔧 Technique", domain: "tech" }, { id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte }, { id: "voix", ic: "🎙", label: "Voix", render: renderVoix }, { id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates }, { id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") }, { id: "audit", ic: "📋", label: "Audit", render: renderAudit }, { group: "⚕️ Médical / clinique", domain: "med" }, { id: "patients", ic: "👤", label: "Patients & profils", render: () => stub("Patients & fiche patient", "Dossier patient chiffré côté Kazeia-central (identité, contact, clinique) + profils on-device + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") }, { id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Cœur RGPD. Dépend de conversations_export côté app patiente.") }, { id: "rag", ic: "📚", label: "RAG thérapeutique", render: () => stub("Corpus RAG thérapeutique", "Authoring du corpus clinique + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") }, { id: "questionnaires", ic: "📝", label: "Questionnaires", render: () => stub("Questionnaires cliniques", "Passation d'échelles/questionnaires cliniques au patient et suivi des scores dans le temps. À concevoir avec toi.") }, ]; // ---- boot + shell --------------------------------------------------------- boot(); async function boot() { try { const st = await api("/lock-status"); S.initialized = st.initialized; S.unlocked = st.unlocked; } catch (e) { $("#app").innerHTML = `

serveur injoignable : ${esc(e.message)}

`; return; } if (!S.unlocked) return renderGate(); await renderShell(); } function renderGate() { const first = !S.initialized; $("#app").innerHTML = `

${first ? "Choisis un mot de passe opérateur (première fois)" : "Déverrouille le coffre opérateur"}

`; const go = async () => { try { await api("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: $("#pw").value }) }); S.unlocked = true; await renderShell(); } catch (e) { $("#gerr").textContent = e.message; } }; $("#gobtn").onclick = go; $("#pw").onkeydown = (e) => { if (e.key === "Enter") go(); }; } async function renderShell() { $("#app").innerHTML = `
Kazeia·central Tablette : 🔓 coffre ouvert
`; $("#sidebar").innerHTML = SECTIONS.map(s => s.group ? `` : ``).join(""); $("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); }); await loadDevices(); renderSection(); } async function loadDevices() { try { S.devices = await api("/devices"); } catch (e) { S.devices = []; toast("erreur adb : " + e.message, "err"); } if (S.devices.length && !S.selected) S.selected = S.devices[0].serial; const sel = $("#devsel"); if (sel) { sel.innerHTML = `` + S.devices.map(d => ``).join(""); sel.onchange = () => { S.selected = sel.value || null; renderSection(); }; } } function renderSection() { document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section)); const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS.find(s => s.render); const c = $("#content"); c.innerHTML = `
`; Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `
${esc(e.message)}
`; }); } function pageHead(title, sub, actions = "") { return `

${title}

${sub ? `
${sub}
` : ""}
${actions}
`; } function stub(title, desc) { return `${pageHead(title)}
🚧

${esc(desc)}

Section prévue par l'architecture — non encore implémentée.

`; } // ---- FLOTTE --------------------------------------------------------------- async function renderFlotte(c) { const rep = await api("/fleet/overview").catch(e => ({ error: e.message })); const rows = Array.isArray(rep) ? rep : []; const head = pageHead("Flotte", `${S.devices.length} tablette(s)`, ``); const table = rep.error ? `
${esc(rep.error)}
` : rows.length ? ` ${rows.map(r => { const o = r.result || {}; const sel = r.serial === S.selected; return ` ${r.ok === false ? `` : ` `}`; }).join("")}
tabletteétatRAMMAJRAGprofilssessionscrashes
${esc(r.label || r.serial)}
${esc(r.serial)}
${esc(r.error)}${dot(o.online ? "ok" : "off")} ${o.online ? "en ligne" : "hors-ligne"} ${o.mem_available_mb ?? "—"} Mo ${esc(o.update_phase || "—")}${o.app_update_available ? " ⬆" : ""} ${dot(o.rag_ready ? "ok" : "idle")} ${o.rag_docs ?? 0} ${o.profiles ?? 0}${o.sessions ?? 0} ${o.crashes ? `${o.crashes}` : 0}
` : `
aucune tablette branchée
`; c.innerHTML = head + `
${table}
`; if (S.selected) renderDeviceDetail($("#devdetail"), S.selected); } function selectDevice(serial) { S.selected = serial; const sel = $("#devsel"); if (sel) sel.value = serial; renderSection(); } async function renderDeviceDetail(host, serial) { host.innerHTML = `

Détail · ${esc(deviceLabel(serial))}

`; const get = (p, fn) => api(`/devices/${serial}/${p}`).then(fn).catch(e => `

${p}

${esc(e.message)}
`); const cards = await Promise.all([ get("state", s => card("État runtime", kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))), get("updates", u => card("Mises à jour", kv("phase", u.phase) + kv("MAJ dispo", u.app_update_available ? "oui" : "non") + kv("version", u.app_version_name || "—"))), get("rag/status", r => card("RAG", kv("état", (r.ready ? "prêt" : "non prêt")) + kv("modèle", r.model || "—") + kv("docs", r.doc_count ?? 0))), get("profiles", ps => card("Profils (" + ps.length + ")", ps.length ? `${ps.map(p => ``).join("")}
nomvoixPINtours
${esc(p.display_name || p.id)}${esc(p.voice_id || "—")}${p.has_pin ? "🔒" : "—"}${p.turn_count ?? 0}
` : `aucun`, "full")), get("conversations", cs => card("Conversations (" + cs.length + ")", cs.length ? `${cs.map(x => ``).join("")}
profilsessiontoursdébut
${esc(x.profile_id)}${esc(x.session_id)}${x.turn_count ?? 0}${fmtTs(x.started_at)}
` : `aucune session`, "full")), get("crashes", cr => card("Crashes (" + cr.length + ")", cr.length ? cr.map(x => kv(x.component || "?", x.message || "")).join("") : `aucun`)), ]); $("#ddgrid").innerHTML = cards.join(""); } const card = (title, body, cls = "") => `

${title}

${body}
`; const kv = (k, v) => `
${k}${v ?? "—"}
`; // ---- MISES À JOUR --------------------------------------------------------- async function renderUpdates(c) { const rep = await api("/fleet/overview").catch(e => ({ error: e.message })); const rows = Array.isArray(rep) ? rep : []; c.innerHTML = pageHead("Mises à jour", "OTA piloté par tablette", ` `) + `
${rep.error ? `
${esc(rep.error)}
` : ` ${rows.map(r => { const o = r.result || {}; return ``; }).join("")}
tablettephaseMAJ dispoversion
${esc(r.label || r.serial)}${esc(o.update_phase || "—")}${o.app_update_available ? badge("disponible", "warn") : "—"}${esc(o.app_version_name || "—")}
`}
`; $("#chk").onclick = (e) => withBusy(e.target, async () => { const r = await api("/fleet/update-check", { method: "POST" }); toast(`${r.filter(x => x.ok).length}/${r.length} ont accepté la vérification`, "ok"); }); $("#inst").onclick = (e) => { if (!confirm("Lancer l'installation OTA sur tout le parc ?")) return; withBusy(e.target, async () => { const r = await api("/fleet/update-install", { method: "POST" }); toast(`installation lancée sur ${r.filter(x => x.ok).length}/${r.length}`, "ok"); }); }; } // ---- AUDIT ---------------------------------------------------------------- async function renderAudit(c) { const log = await api("/audit").catch(e => ({ error: e.message })); const rows = Array.isArray(log) ? log : []; c.innerHTML = pageHead("Audit", "piste d'accès opérateur (§8)") + `
${log.error ? `
${esc(log.error)}
` : rows.length ? ` ${rows.map(a => ``).join("")}
dateacteuractioncible
${fmtTs(a.ts)}${esc(a.actor || "—")}${badge(a.action, "primary")}${esc(a.target || "—")}
` : `
aucune entrée
`}
`; } // ---- VOIX ----------------------------------------------------------------- async function renderVoix(c) { const serial = S.selected; const reqs = [api("/voices/catalog").catch(e => ({ error: e.message })), api("/voices/autosync").catch(e => ({ error: e.message }))]; if (serial) reqs.push(api(`/voices/${serial}`).catch(e => ({ error: e.message })), api(`/voices/${serial}/admin`).catch(e => ({ error: e.message }))); const [cat, auto, tablet, admin] = await Promise.all(reqs); const cv = Array.isArray(cat) ? cat : []; const catCard = card(`Catalogue voix — flotte (${cv.length})`, cat.error ? `
${esc(cat.error)}
` : cv.length ? ` ${cv.map(v => ``).join("")}
voixportéeverroudéployée sur
${esc(v.name || v.voice_id)}
${esc(v.voice_id)}
${scopeBadge(v.scope)} ${v.locked_profile_id ? "🔒 " + esc(v.owner_name || v.locked_profile_id) : `généraliste`} ${(v.deployed_serials || []).map(deviceLabel).map(esc).join(", ") || "—"} ${v.locked_profile_id ? `` : ``} ${v.has_wav ? `` : ""}
` : `
catalogue vide — enregistre des voix via l'app admin puis synchronise
`, "full"); const autoCard = card("Déclencheur automatique", auto.error ? `
${esc(auto.error)}
` : `
état${dot(auto.enabled ? "ok" : "idle")} ${auto.enabled ? "armé" : "désarmé"}
`); let cards = catCard + autoCard; if (!serial) { cards += card("Par tablette", `
Choisis une tablette (en haut) pour voir ce qui y est chargé et les voix chargeables dessus.
`, "full"); } else { const av = Array.isArray(admin) ? admin : []; const adminCard = card(`Voix admin à enrôler — ${esc(deviceLabel(serial))} (${av.length})`, admin.error ? `
${esc(admin.error)}
` : (av.length ? `${av.map(v => ``).join("")}
nomportéepropriétairestatut
${esc(v.name || v.voice_id)}${scopeBadge(v.scope)}${esc(v.owner_name || v.owner_profile_id || "—")}${esc(v.status)}
` : `
aucune voix admin en attente
`) + `
`, "full"); const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || []; const tabCard = card(`${esc(deviceLabel(serial))} — chargées (${loaded.length}) · chargeables (${loadable.filter(v => v.deployable).length})`, tablet && tablet.error ? `
${esc(tablet.error)}
` : ` ${loaded.map(v => ``).join("")} ${loadable.map(v => ``).join("")}
voixportéeétat
${dot("ok")} ${esc(v.name || v.voice_id)}${v.in_catalog === false ? ` (hors catalogue)` : ""}${v.scope ? scopeBadge(v.scope) : "—"}chargée${v.in_catalog !== false ? `` : ""}
${esc(v.name || v.voice_id)}${scopeBadge(v.scope)}${v.deployable ? "chargeable" : esc(v.reason || "—")}${v.deployable ? `` : ""}
`, "full"); cards += adminCard + tabCard; } c.innerHTML = pageHead("Voix", "catalogue de flotte · enrôlement OmniVoice") + `
${cards}
`; } // ---- actions voix --------------------------------------------------------- async function toggleAutosync(on) { try { await api("/voices/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: on, delete_source: $("#auto_del") && $("#auto_del").checked || false }) }); toast(on ? "auto-enrôlement armé" : "désarmé", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } async function syncAdmin(btn, serial) { const del = $("#sync_del") && $("#sync_del").checked || false; await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/admin/sync`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete_source: del }) }); toast(`${r.filter(x => x.ok && x.deployed).length}/${r.length} voix déployée(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } async function deployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/deploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); r.deployed ? toast("voix chargée", "ok") : toast("non chargée : " + (r.reason || ""), "err"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } async function undeployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { await api(`/voices/${serial}/undeploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); toast("voix retirée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ?`)) return; await withBusy(btn, async () => { try { const r = await api(`/voices/catalog/${vid}/reenroll`, { method: "POST" }); toast("ré-enrôlé · re-déployé : " + ((r.redeployed || []).join(", ") || "—"), "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); } async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } } // ---- store ---------------------------------------------------------------- async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }