jarvis-local / index.html
PascalF53's picture
Sauvegarde JARVIS Local + guide d'installation
3fa98f2 verified
Raw History Blame Contribute Delete
36.1 kB
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>JARVIS · Console</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<style>
/* "Commandement": a SOC-style console. The orb is a radar (tasks are its
blips), the conversation a timestamped log, and the machine's state is
always on screen. */
:root {
--bg: #0a0d0d; --panel: #0f1414; --panel-2: #131a1a; --line: #1f2a29; --line-2: #2b3a38;
--green: #3df5a6; --green-d: #1c7a55; --amber: #ffc94a; --red: #ff5a5a; --blue: #58c6ff;
--text: #d6e4e1; --muted: #6b807c;
--mono: "IBM Plex Mono", monospace; --cond: "IBM Plex Sans Condensed", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--line); color: var(--text); font-family: var(--cond); overflow: hidden;
display: grid; grid-template-rows: 44px minmax(0, 1fr) 34px; grid-template-columns: 360px minmax(0, 1fr) 360px;
grid-template-areas: "top top top" "log stage side" "bot bot bot"; gap: 1px;
}
body > * { background: var(--bg); }
/* ---------------------------------------------- top status bar */
#top { grid-area: top; display: flex; align-items: center; gap: 26px; padding: 0 18px; font-family: var(--mono); font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; }
#top .brand { color: var(--green); font-weight: 500; letter-spacing: .18em; }
#top b { color: var(--text); font-weight: 400; }
#top .on b { color: var(--green); } #top .off b { color: var(--muted); } #top .err b { color: var(--red); }
#top .sp { flex: 1; }
#clock { color: var(--amber); }
.ph { display: flex; justify-content: space-between; align-items: center; height: 34px; flex: none; padding: 0 14px; border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); background: var(--panel); }
.ph b { color: var(--green); font-weight: 500; }
/* ---------------------------------------------- left: conversation log */
#log { grid-area: log; display: flex; flex-direction: column; min-height: 0; }
#lines { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; font-size: 14.5px; line-height: 1.45; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
#lines .empty { color: var(--muted); font-size: 13px; margin-top: auto; }
.ln { display: grid; grid-template-columns: 46px 1fr; gap: 8px; animation: fade .3s; }
.ln time { font-family: var(--mono); font-size: 11px; color: var(--muted); padding-top: 2px; }
.ln .who { font-family: var(--mono); font-size: 10.5px; letter-spacing: .12em; display: block; margin-bottom: 1px; }
.ln.you .who { color: var(--blue); } .ln.jv .who { color: var(--green); } .ln.sys .who { color: var(--amber); } .ln.err .who { color: var(--red); }
.ln.sys, .ln.err { color: var(--muted); font-size: 13px; }
.ln .tx { white-space: pre-wrap; word-break: break-word; }
.cursor::after { content: "▌"; color: var(--green); animation: blink .8s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } }
/* ---------------------------------------------- centre: radar + oscilloscope */
#stage { grid-area: stage; display: grid; grid-template-rows: minmax(0, 1fr) 110px; min-height: 0; }
#radarBox { position: relative; display: grid; place-items: center; min-height: 0; overflow: hidden; }
#radar { width: min(94%, 66vh); aspect-ratio: 1; cursor: pointer; }
#mode { position: absolute; top: 14px; left: 16px; font-family: var(--mono); font-size: 11px; letter-spacing: .2em; }
#mode span { padding: 3px 8px; border: 1px solid var(--line-2); color: var(--muted); }
#mode.idle span, #mode.listening span, #mode.speaking span { border-color: var(--green-d); color: var(--green); }
#mode.listening span { border-color: var(--blue); color: var(--blue); }
#mode.thinking span, #mode.transcribing span { border-color: var(--amber); color: var(--amber); }
#mode.muted span { border-color: var(--red); color: var(--red); }
#hint { position: absolute; bottom: 16px; left: 0; right: 0; text-align: center; font-family: var(--mono); font-size: 11px; letter-spacing: .3em; color: var(--muted); pointer-events: none; }
#hint b { color: var(--green); font-weight: 500; }
#scope { width: 100%; height: 110px; border-top: 1px solid var(--line); background: var(--panel); display: block; }
/* ---------------------------------------------- right: tasks, telemetry, cards */
#side { grid-area: side; display: flex; flex-direction: column; min-height: 0; }
#taskWrap { max-height: 34%; overflow-y: auto; flex: none; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
td { padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
td.st { font-family: var(--mono); font-size: 10.5px; width: 74px; padding-right: 0; white-space: nowrap; }
td.x { width: 28px; text-align: center; color: var(--muted); cursor: pointer; font-family: var(--mono); }
td.x:hover { color: var(--red); }
tr.task { cursor: pointer; }
tr.task:hover td { background: var(--panel); }
td small { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 2px; }
td small .cl { color: var(--blue); }
td .out { display: none; margin-top: 6px; font-family: var(--mono); font-size: 11px; color: #a9bdb9; white-space: pre-wrap; max-height: 160px; overflow: auto; }
tr.open td .out { display: block; }
.run { color: var(--amber); } .ok { color: var(--green); } .ko { color: var(--red); } .cx { color: var(--muted); }
#noTask { padding: 12px 14px; font-size: 13px; color: var(--muted); }
.meters { padding: 12px 14px; display: grid; gap: 9px; font-family: var(--mono); font-size: 11px; color: var(--muted); flex: none; }
.meter { display: grid; grid-template-columns: 64px 1fr 66px; align-items: center; gap: 10px; }
.meter .b { height: 6px; background: var(--panel-2); border: 1px solid var(--line-2); }
.meter .b i { display: block; height: 100%; width: 0; background: var(--green); transition: width .6s; }
.meter .v { color: var(--text); text-align: right; }
#cards { flex: 1; overflow-y: auto; padding: 4px 0 12px; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
.card { margin: 10px 14px 0; border: 1px solid var(--line-2); border-left: 3px solid var(--blue); background: var(--panel); padding: 10px 12px; animation: fade .4s; }
.card.result { border-left-color: var(--green); } .card.warning { border-left-color: var(--red); } .card.code { border-left-color: var(--amber); }
.card h4 { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--blue); margin-bottom: 6px; font-weight: 500; }
.card.result h4 { color: var(--green); } .card.warning h4 { color: var(--red); } .card.code h4 { color: var(--amber); }
.card h4 .x { cursor: pointer; color: var(--muted); letter-spacing: 0; }
.card h4 .x:hover { color: var(--red); }
.card .body { font-size: 13.5px; line-height: 1.5; word-break: break-word; }
.card .body p + p { margin-top: 6px; }
.card .body ul, .card .body ol { margin: 2px 0 2px 18px; }
.card .body code { font-family: var(--mono); font-size: 12px; color: var(--amber); }
.card .body pre { font-family: var(--mono); font-size: 11.5px; white-space: pre-wrap; color: #a9bdb9; }
.card .body table { border-collapse: collapse; margin: 4px 0; font-size: 12.5px; }
.card .body th, .card .body td { border: 1px solid var(--line-2); padding: 3px 6px; }
/* ---------------------------------------------- bottom bar */
#bot { grid-area: bot; display: flex; align-items: center; gap: 18px; padding: 0 18px; font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; }
#bot kbd { border: 1px solid var(--line-2); padding: 1px 6px; color: var(--text); font: inherit; }
#bot .sp { flex: 1; }
#mic { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); padding: 3px 6px; font: inherit; max-width: 300px; cursor: pointer; }
#mic:focus { outline: 1px solid var(--green-d); }
#msg.err { color: var(--red); }
/* ---------------------------------------------- report overlay */
#report { position: fixed; inset: 64px 380px 54px 380px; z-index: 20; display: none; background: var(--bg); border: 1px solid var(--line-2); border-top: 2px solid var(--green); box-shadow: 0 0 0 100vmax rgba(0,0,0,.55); padding: 20px 24px; overflow-y: auto; animation: fade .3s; }
#report.open { display: block; }
.rhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.rhead h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .16em; text-transform: uppercase; color: var(--green); font-weight: 500; }
.rhead .x { cursor: pointer; color: var(--muted); font-family: var(--mono); }
.rhead .x:hover { color: var(--red); }
#kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); margin-bottom: 16px; }
.kpi { background: var(--panel); padding: 12px 14px; }
.kpi .l { font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
.kpi .v { font-size: 26px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
.kpi .d { font-family: var(--mono); font-size: 12px; color: var(--green); } .kpi .d.neg { color: var(--red); }
#rchart, #rtable { margin-bottom: 16px; }
#rtable .gridjs-wrapper, #rtable .gridjs-table { background: transparent; box-shadow: none; border-radius: 0; }
#rtable th.gridjs-th { background: var(--panel); color: var(--green); border-color: var(--line-2); font-family: var(--mono); font-size: 11px; letter-spacing: .08em; }
#rtable td.gridjs-td, #rtable .gridjs-tr, #rtable .gridjs-tbody { background: transparent !important; color: var(--text); border-color: var(--line); font-size: 13px; }
#rtable .gridjs-tr:hover td { background: var(--panel) !important; }
#rtable .gridjs-search input, #rtable .gridjs-pagination, #rtable .gridjs-pagination button { background: transparent; color: var(--muted); border-color: var(--line-2); }
#rtable .gridjs-pagination button.gridjs-currentPage { background: var(--panel-2); color: var(--green); }
#rmd { font-size: 14px; line-height: 1.6; }
#rmd h1, #rmd h2, #rmd h3 { color: var(--green); margin: 12px 0 6px; font-size: 15px; }
#rmd table { border-collapse: collapse; margin: 8px 0; }
#rmd th, #rmd td { border: 1px solid var(--line-2); padding: 5px 10px; font-size: 13px; }
#rmd code { font-family: var(--mono); color: var(--amber); }
#rmd ul, #rmd ol { margin: 6px 0 6px 20px; }
</style>
</head>
<body>
<div id="top">
<span class="brand">■ JARVIS // CONSOLE</span>
<span id="conn" class="off">état <b>HORS LIGNE</b></span>
<span>llm <b id="llm">—</b></span>
<span>oreille <b id="ear">—</b></span>
<span>agent <b id="agent">—</b></span>
<span class="sp"></span>
<span id="clock">--:--:--</span>
</div>
<section id="log">
<div class="ph">journal de session <b id="cnt">0</b></div>
<div id="lines"><div class="empty">Clique le radar puis parle : la conversation s'inscrit ici.</div></div>
</section>
<section id="stage">
<div id="radarBox">
<div id="mode" class="off"><span>HORS LIGNE</span></div>
<canvas id="radar" title="Clique pour démarrer / arrêter"></canvas>
<div id="hint">clique le radar pour <b>initialiser</b></div>
</div>
<canvas id="scope"></canvas>
</section>
<aside id="side">
<div class="ph">tâches <b id="tc">0 active</b></div>
<div id="taskWrap"><table><tbody id="tasks"></tbody></table><div id="noTask">Aucune tâche. Demande-la à la voix.</div></div>
<div class="ph">télémétrie <b id="tot">—</b></div>
<div class="meters">
<div class="meter"><span>oreille</span><div class="b"><i id="m-stt"></i></div><span class="v" id="v-stt">—</span></div>
<div class="meter"><span>cerveau</span><div class="b"><i id="m-llm"></i></div><span class="v" id="v-llm">—</span></div>
<div class="meter"><span>voix</span><div class="b"><i id="m-tts"></i></div><span class="v" id="v-tts">—</span></div>
<div class="meter"><span>vram</span><div class="b"><i id="m-vram" style="background:var(--amber)"></i></div><span class="v" id="v-vram">—</span></div>
</div>
<div class="ph">affichage</div>
<div id="cards"></div>
</aside>
<div id="bot">
<select id="mic" title="Micro utilisé par JARVIS"><option value="">MIC · par défaut</option></select>
<span><kbd>Espace</kbd> couper le micro</span>
<span><kbd>Échap</kbd> interrompre</span>
<span class="sp"></span>
<span id="msg">100 % local · qwen3 · whisper · kokoro · silero</span>
</div>
<div id="report">
<div class="rhead"><h2 id="rtitle"></h2><span class="x" id="rclose">[ fermer ✕ ]</span></div>
<div id="kpis"></div>
<div id="rchart"></div>
<div id="rtable"></div>
<div id="rmd"></div>
</div>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
const TAU = Math.PI * 2;
let micStream = null, analyserIn = null, analyserOut = null;
let running = false, muted = false, mode = "off";
let ws = null, ac = null, micNode = null;
let playing = new Set(), playHead = 0;
setInterval(() => { $("clock").textContent = new Date().toLocaleTimeString("fr-FR"); }, 1000);
function esc(s) {
return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
const hhmm = () => new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
const secs = (v) => v.toFixed(2).replace(".", ",") + " s";
/* ---------------------------------------------------------- mode badge */
const MODES = {
off: "HORS LIGNE", idle: "VEILLE", listening: "ÉCOUTE", transcribing: "TRANSCRIPTION",
thinking: "ANALYSE", speaking: "RÉPONSE", muted: "MICRO COUPÉ",
};
function setMode(m) {
mode = m;
const shown = muted && running && (m === "idle" || m === "listening") ? "muted" : m;
$("mode").className = shown;
$("mode").firstChild.textContent = MODES[shown] || shown.toUpperCase();
}
function setConn(cls, label) { $("conn").className = cls; $("conn").innerHTML = `état <b>${label}</b>`; }
function message(text, isErr = false) { $("msg").textContent = text; $("msg").className = isErr ? "err" : ""; }
/* ---------------------------------------------------------- radar + oscilloscope */
const rc = $("radar"), r = rc.getContext("2d");
const sc = $("scope"), s = sc.getContext("2d");
function fit() { [rc, sc].forEach((c) => { const b = c.getBoundingClientRect(); c.width = b.width * devicePixelRatio; c.height = b.height * devicePixelRatio; }); }
addEventListener("resize", fit); fit();
const blips = new Map(); // task id -> {a, r, label, status}
let level = 0, t = 0;
const timeBuf = new Float32Array(1024);
function energy(an) {
if (!an) return 0;
const buf = new Uint8Array(an.frequencyBinCount);
an.getByteFrequencyData(buf);
let sum = 0; for (const v of buf) sum += v;
return sum / buf.length / 255;
}
function frame() {
requestAnimationFrame(frame);
t += 1 / 60;
const target = Math.min(1, Math.max(energy(analyserIn) * (muted ? 0 : 1), energy(analyserOut) * 1.3) * 2.2);
level += (target - level) * 0.25;
const w = rc.width, h = rc.height, cx = w / 2, cy = h / 2, R = Math.min(w, h) * 0.45, d = devicePixelRatio;
const thinking = mode === "thinking" || mode === "transcribing";
const G = !running ? "107,128,124" : thinking ? "255,201,74" : mode === "listening" ? "88,198,255" : muted ? "255,90,90" : "61,245,166";
r.clearRect(0, 0, w, h);
// rings, crosshair, bearing ticks
r.strokeStyle = "rgba(61,245,166,.15)"; r.lineWidth = d;
for (let i = 1; i <= 4; i++) { r.beginPath(); r.arc(cx, cy, R * i / 4, 0, TAU); r.stroke(); }
r.beginPath(); r.moveTo(cx - R, cy); r.lineTo(cx + R, cy); r.moveTo(cx, cy - R); r.lineTo(cx, cy + R); r.stroke();
for (let i = 0; i < 72; i++) {
const a = i / 72 * TAU, k = i % 6 === 0 ? 0.94 : 0.975;
r.beginPath(); r.moveTo(cx + R * k * Math.cos(a), cy + R * k * Math.sin(a)); r.lineTo(cx + R * Math.cos(a), cy + R * Math.sin(a)); r.stroke();
}
r.fillStyle = "rgba(107,128,124,.8)"; r.font = `${10 * d}px IBM Plex Mono`; r.textAlign = "center";
[["000", 0, -1], ["090", 1, 0], ["180", 0, 1], ["270", -1, 0]].forEach(([l, x, y]) => r.fillText(l, cx + x * R * 1.05, cy + y * R * 1.05 + 4 * d));
// sweep: slow when idle, fast while thinking, frozen when offline
const sweep = running ? t * (thinking ? 3.2 : 1.1) : -Math.PI / 2;
if (running && r.createConicGradient) {
const g = r.createConicGradient(sweep - 0.9, cx, cy);
g.addColorStop(0, `rgba(${G},0)`); g.addColorStop(0.14, `rgba(${G},.28)`); g.addColorStop(0.143, `rgba(${G},0)`);
r.fillStyle = g; r.beginPath(); r.arc(cx, cy, R, 0, TAU); r.fill();
}
// core = the voice (yours or JARVIS's)
const core = R * (0.15 + level * 0.14);
r.fillStyle = `rgba(${G},.12)`; r.beginPath(); r.arc(cx, cy, core * 1.9, 0, TAU); r.fill();
r.fillStyle = `rgba(${G},.9)`; r.beginPath(); r.arc(cx, cy, core * 0.55, 0, TAU); r.fill();
r.strokeStyle = `rgba(${G},.75)`; r.lineWidth = 1.5 * d; r.beginPath(); r.arc(cx, cy, core, 0, TAU); r.stroke();
// blips = tasks (amber running, green done, red error)
r.textAlign = "left";
for (const b of blips.values()) {
const x = cx + R * b.r * Math.cos(b.a), y = cy + R * b.r * Math.sin(b.a);
const age = ((sweep - b.a) % TAU + TAU) % TAU, glow = running ? Math.max(0.3, 1 - age / TAU) : 0.6;
const c = b.status === "running" ? "255,201,74" : b.status === "done" ? "61,245,166" : b.status === "error" ? "255,90,90" : "107,128,124";
r.fillStyle = `rgba(${c},${glow})`;
r.beginPath(); r.arc(x, y, (b.status === "running" ? 5 + Math.sin(t * 5) : 4.5) * d, 0, TAU); r.fill();
r.font = `${11 * d}px IBM Plex Mono`; r.fillText(b.label, x + 10 * d, y + 4 * d);
}
// oscilloscope: JARVIS when it speaks, otherwise the mic
const W = sc.width, H = sc.height, an = playing.size ? analyserOut : (muted ? null : analyserIn);
s.clearRect(0, 0, W, H);
s.strokeStyle = "rgba(61,245,166,.07)"; s.lineWidth = 1;
for (let x = 0; x < W; x += 40 * d) { s.beginPath(); s.moveTo(x, 0); s.lineTo(x, H); s.stroke(); }
s.beginPath(); s.moveTo(0, H / 2); s.lineTo(W, H / 2); s.stroke();
s.strokeStyle = an === analyserIn ? "#58c6ff" : `rgb(${G})`; s.lineWidth = 1.6 * d; s.beginPath();
if (an) {
an.getFloatTimeDomainData(timeBuf);
for (let i = 0; i < timeBuf.length; i++) {
const x = i / (timeBuf.length - 1) * W, y = H / 2 + Math.max(-1, Math.min(1, timeBuf[i] * 2.5)) * H * 0.45;
i ? s.lineTo(x, y) : s.moveTo(x, y);
}
} else { s.moveTo(0, H / 2); s.lineTo(W, H / 2); }
s.stroke();
}
frame();
/* ---------------------------------------------------------- conversation log */
let curJarvis = null; // the line JARVIS is currently writing into
function line(kind, who, text = "") {
const box = $("lines");
const empty = box.querySelector(".empty"); if (empty) empty.remove();
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
const el = document.createElement("div");
el.className = "ln " + kind;
el.innerHTML = `<time>${hhmm()}</time><div><span class="who">${who}</span><span class="tx"></span></div>`;
el.querySelector(".tx").textContent = text;
box.append(el);
while (box.children.length > 200) box.firstChild.remove();
$("cnt").textContent = box.children.length;
if (stick) box.scrollTop = box.scrollHeight;
return el.querySelector(".tx");
}
function appendJarvis(delta) {
if (!curJarvis) { curJarvis = line("jv", "JARVIS"); curJarvis.classList.add("cursor"); }
curJarvis.textContent += delta;
const box = $("lines"); box.scrollTop = box.scrollHeight;
}
function endJarvis() { if (curJarvis) curJarvis.classList.remove("cursor"); curJarvis = null; }
/* ---------------------------------------------------------- audio: mic in, speech out */
// Mic -> 16 kHz PCM16 frames over the WebSocket. The server runs the VAD,
// Whisper, the LLM and Kokoro, and streams back 24 kHz PCM16 speech.
const WORKLET = `
class MicDown extends AudioWorkletProcessor {
constructor() { super(); this.step = sampleRate / 16000; this.phase = 0;
this.acc = 0; this.cnt = 0; this.out = new Int16Array(1024); this.n = 0; }
process(inputs) {
const ch = inputs[0][0];
if (!ch) return true;
for (let i = 0; i < ch.length; i++) { // box-filter decimation
this.acc += ch[i]; this.cnt++; this.phase += 1;
if (this.phase >= this.step) {
this.phase -= this.step;
const v = Math.max(-1, Math.min(1, this.acc / this.cnt));
this.out[this.n++] = v * 32767; this.acc = 0; this.cnt = 0;
if (this.n === this.out.length) { this.port.postMessage(this.out.slice().buffer); this.n = 0; }
}
}
return true;
}
}
registerProcessor("mic-down", MicDown);`;
async function start() {
if (running) return stop();
$("hint").innerHTML = "<b>connexion…</b>";
try {
const deviceId = micSel.value;
micStream = await navigator.mediaDevices.getUserMedia({ audio: {
channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true,
...(deviceId ? { deviceId: { exact: deviceId } } : {}) } });
listMics(); // labels only become readable once the permission is granted
ac = new AudioContext();
analyserIn = ac.createAnalyser(); analyserIn.fftSize = 2048;
analyserOut = ac.createAnalyser(); analyserOut.fftSize = 2048;
analyserOut.connect(ac.destination);
const src = ac.createMediaStreamSource(micStream);
src.connect(analyserIn);
await ac.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: "text/javascript" })));
micNode = new AudioWorkletNode(ac, "mic-down");
src.connect(micNode);
// Chrome only runs nodes that reach the destination: route through a muted gain.
const sink = ac.createGain(); sink.gain.value = 0;
micNode.connect(sink).connect(ac.destination);
if (ac.state === "suspended") await ac.resume();
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
ws.binaryType = "arraybuffer";
ws.onmessage = (e) => typeof e.data === "string" ? handleEvent(JSON.parse(e.data)) : playPcm(e.data);
ws.onclose = () => {
if (!running) return;
stop();
setConn("err", "DÉCONNECTÉ");
message("serveur injoignable : start.bat est-il lancé ?", true);
line("err", "SYSTÈME", "connexion perdue avec le serveur");
};
await new Promise((ok, ko) => { ws.onopen = ok; ws.onerror = () => ko(new Error("serveur injoignable")); });
micNode.port.onmessage = (e) => { if (!muted && ws && ws.readyState === 1) ws.send(e.data); };
running = true;
setConn("on", "OPÉRATIONNEL");
setMode("idle");
$("hint").textContent = "";
message(micSel.selectedOptions[0]?.textContent.replace(/^MIC · /, "micro : ") || "");
line("sys", "SYSTÈME", "session ouverte");
} catch (err) {
stop();
setConn("err", "ERREUR");
message(err.message || String(err), true);
}
}
function stop() {
const was = running;
running = false;
if (ws) { ws.onclose = null; ws.close(); } ws = null;
if (micStream) micStream.getTracks().forEach((tr) => tr.stop());
stopAudio(false);
if (ac) ac.close(); ac = null; micNode = null;
analyserIn = analyserOut = null;
endJarvis();
setMode("off"); setConn("off", "HORS LIGNE");
$("hint").innerHTML = "clique le radar pour <b>initialiser</b>";
if (was) line("sys", "SYSTÈME", "session fermée");
}
rc.addEventListener("click", start);
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
/* speech playback: gapless queue of 24 kHz chunks */
function playPcm(buf) {
if (!ac) return;
const pcm = new Int16Array(buf);
const audio = ac.createBuffer(1, pcm.length, 24000);
const ch = audio.getChannelData(0);
for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
const node = ac.createBufferSource();
node.buffer = audio; node.connect(analyserOut);
if (!playing.size) send({ type: "playback", playing: true });
playHead = Math.max(playHead, ac.currentTime + 0.04);
node.start(playHead);
playHead += audio.duration;
playing.add(node);
node.onended = () => {
playing.delete(node);
if (!playing.size) send({ type: "playback", playing: false });
};
}
function stopAudio(notify = true) {
for (const n of playing) { n.onended = null; try { n.stop(); } catch (_) {} }
playing.clear(); playHead = 0;
if (notify) send({ type: "playback", playing: false });
}
/* microphone picker (choice remembered in this browser) */
const micSel = $("mic");
function savedMic() { try { return localStorage.getItem("jarvis.mic") || ""; } catch (_) { return ""; } }
async function listMics() {
const devices = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === "audioinput");
const want = micSel.value || savedMic();
micSel.innerHTML = '<option value="">MIC · par défaut</option>' + devices
.filter((d) => d.deviceId && d.deviceId !== "default" && d.deviceId !== "communications")
.map((d, i) => `<option value="${esc(d.deviceId)}">MIC · ${esc(d.label || `micro ${i + 1}`)}</option>`).join("");
micSel.value = [...micSel.options].some((o) => o.value === want) ? want : "";
}
micSel.addEventListener("change", async () => {
try { localStorage.setItem("jarvis.mic", micSel.value); } catch (_) {}
if (running) { stop(); await start(); } // reconnect on the new mic
});
navigator.mediaDevices.addEventListener("devicechange", listMics);
listMics();
/* keyboard: Space = mute mic, Escape = interrupt JARVIS / close the report */
addEventListener("keydown", (e) => {
if (e.target.closest("select, input, textarea")) return;
if (e.code === "Space") {
e.preventDefault();
if (!running) return start();
muted = !muted;
setMode(mode);
message(muted ? "micro coupé (Espace pour le réactiver)" : "micro actif", muted);
} else if (e.code === "Escape") {
if ($("report").classList.contains("open")) return $("report").classList.remove("open");
stopAudio(false); send({ type: "interrupt" }); endJarvis();
}
});
/* ---------------------------------------------------------- server events */
function handleEvent(ev) {
switch (ev.type) {
case "ready":
$("llm").textContent = (ev.model || "—").replace(/-instruct.*$/, "");
$("ear").textContent = ev.whisper || "—";
$("agent").textContent = (ev.agent === "ollama" ? "local" : "claude") + (ev.complex && ev.complex !== ev.agent ? " + " + (ev.complex === "anthropic" ? "claude" : "local") : "");
break;
case "state":
if (running) setMode(ev.state);
if (ev.state === "idle") endJarvis();
break;
case "user_text":
endJarvis();
line("you", "VOUS", ev.text);
break;
case "reply_start":
endJarvis();
break;
case "assistant_delta":
appendJarvis(ev.text);
break;
case "stop_audio":
stopAudio(false); endJarvis();
break;
case "metrics":
showMetrics(ev);
break;
case "task":
upsertTask(ev.task);
break;
case "ui": {
const a = ev.args || {};
if (ev.name === "display_card") addCard(a.title || "Info", a.content || "", a.kind || "info");
else if (ev.name === "display_report") {
try { showReport(a); } catch (err) { addCard("Rapport", String(err), "warning"); }
}
break;
}
case "error":
line("err", "ERREUR", ev.text || "");
break;
}
}
/* ---------------------------------------------------------- telemetry */
function meter(key, v, max) { $("m-" + key).style.width = Math.min(100, v / max * 100) + "%"; $("v-" + key).textContent = secs(v); }
function showMetrics(m) {
meter("stt", m.stt, 1); meter("llm", m.llm, 2); meter("tts", m.tts, 1);
$("tot").textContent = "1er son " + secs(m.total);
}
async function pollStatus() {
try {
const st = await fetch("/api/status").then((res) => res.json());
if (st.gpu) {
const p = st.gpu.used_mb / st.gpu.total_mb;
$("m-vram").style.width = p * 100 + "%";
$("m-vram").style.background = p > 0.95 ? "var(--red)" : "var(--amber)";
$("v-vram").textContent = `${(st.gpu.used_mb / 1024).toFixed(1)}/${Math.round(st.gpu.total_mb / 1024)} Go`;
} else { $("v-vram").textContent = "n/d"; }
} catch (_) { /* server down: the WebSocket handler reports it */ }
}
pollStatus(); setInterval(pollStatus, 5000);
/* ---------------------------------------------------------- tasks (table + radar blips) */
const STATUS = {
running: ["run", "● EN COURS"], done: ["ok", "✓ TERMINÉ"],
error: ["ko", "✕ ERREUR"], cancelled: ["cx", "– ANNULÉ"],
};
const timers = {};
function upsertTask(task) {
let tr = $(`task-${task.id}`);
if (!tr) {
$("noTask").style.display = "none";
tr = document.createElement("tr");
tr.className = "task"; tr.id = `task-${task.id}`;
const engine = task.backend === "anthropic" ? '<span class="cl">claude</span>' : "local";
tr.innerHTML = `<td class="st"></td><td>${esc(task.title)}<small>${engine} · ${hhmm()} · <span class="chrono">0 s</span></small><div class="out"></div></td><td class="x" title="annuler">✕</td>`;
tr.addEventListener("click", (e) => { if (!e.target.closest(".x")) tr.classList.toggle("open"); });
tr.querySelector(".x").addEventListener("click", () => cancelTask(task.id));
$("tasks").prepend(tr);
const t0 = (task.started || Date.now() / 1000) * 1000;
timers[task.id] = setInterval(() => { tr.querySelector(".chrono").textContent = Math.round((Date.now() - t0) / 1000) + " s"; }, 1000);
blips.set(task.id, { a: Math.random() * TAU, r: 0.45 + Math.random() * 0.4, label: task.title.split(" ")[0].toUpperCase().slice(0, 12), status: "running" });
line("sys", "SYSTÈME", `tâche lancée · ${task.title} (${task.backend === "anthropic" ? "Claude" : "modèle local"})`);
}
const [cls, label] = STATUS[task.status] || STATUS.error;
tr.querySelector(".st").className = "st " + cls;
tr.querySelector(".st").textContent = label;
const b = blips.get(task.id); if (b) b.status = task.status;
if (task.status !== "running") {
clearInterval(timers[task.id]);
const x = tr.querySelector(".x"); if (x) x.textContent = "";
tr.querySelector(".out").textContent = (task.output || "").slice(0, 3000);
if (!tr.dataset.ended) {
tr.dataset.ended = "1";
line("sys", "SYSTÈME", `tâche ${label.slice(2).toLowerCase()} · ${task.title}`);
// finished blips fade off the radar after a minute
setTimeout(() => blips.delete(task.id), 60000);
}
}
const n = [...blips.values()].filter((v) => v.status === "running").length;
$("tc").textContent = n + (n > 1 ? " actives" : " active");
}
async function cancelTask(taskId) {
try {
const res = await fetch(`/api/task/${taskId}/cancel`, { method: "POST" }).then((x) => x.json());
if (res.cancelled) upsertTask(await fetch(`/api/task/${res.cancelled}`).then((x) => x.json()));
else if (res.error) message(res.error, true);
} catch (err) { message(String(err), true); }
}
/* ---------------------------------------------------------- display cards */
function mdLite(text) {
// minimal markdown when the CDN libs are unavailable: **bold**, `code`, "- " bullets
let html = esc(text).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/`([^`]+)`/g, "<code>$1</code>");
let out = "", inList = false;
for (const l of html.split("\n")) {
if (l.startsWith("- ")) { if (!inList) { out += "<ul>"; inList = true; } out += `<li>${l.slice(2)}</li>`; }
else { if (inList) { out += "</ul>"; inList = false; } out += l + "<br>"; }
}
return out + (inList ? "</ul>" : "");
}
function mdRender(text) {
if (window.marked && window.DOMPurify) return DOMPurify.sanitize(marked.parse(text));
return mdLite(text);
}
function addCard(title, content, kind) {
const el = document.createElement("div");
el.className = `card ${kind}`;
el.innerHTML = `<h4><span>${esc(title)}</span><span class="x" title="fermer">✕</span></h4><div class="body">${mdRender(content)}</div>`;
el.querySelector(".x").addEventListener("click", () => el.remove());
$("cards").prepend(el);
while ($("cards").children.length > 8) $("cards").lastChild.remove();
}
/* ---------------------------------------------------------- report dashboard */
// Series palette checked for dark surfaces and colour-blind safety.
const SERIES_COLORS = ["#3df5a6", "#58c6ff", "#ffc94a"];
let _apex = null, _grid = null;
$("rclose").addEventListener("click", () => $("report").classList.remove("open"));
function showReport(rep) {
$("rtitle").textContent = rep.title || "Rapport";
const kpisEl = $("kpis");
kpisEl.innerHTML = "";
for (const k of (rep.kpis || []).slice(0, 4)) {
const d = k.delta ? `<div class="d ${String(k.delta).trim().startsWith("-") ? "neg" : ""}">${esc(k.delta)}</div>` : "";
kpisEl.insertAdjacentHTML("beforeend", `<div class="kpi"><div class="l">${esc(k.label)}</div><div class="v">${esc(k.value)}</div>${d}</div>`);
}
kpisEl.style.display = kpisEl.children.length ? "" : "none";
const chartEl = $("rchart");
if (_apex) { _apex.destroy(); _apex = null; }
chartEl.innerHTML = "";
const c = rep.chart;
if (c && window.ApexCharts && (c.series || []).length) {
const donut = c.type === "donut";
const nSeries = donut ? (c.categories || []).length : c.series.length;
const opts = {
chart: { type: c.type || "line", height: 280, background: "transparent", toolbar: { show: false },
foreColor: "#6b807c", fontFamily: "IBM Plex Sans Condensed, sans-serif" },
theme: { mode: "dark" }, colors: SERIES_COLORS,
stroke: { width: 2, curve: "straight" },
grid: { borderColor: "#1f2a29", strokeDashArray: 3 },
dataLabels: { enabled: false },
legend: { show: nSeries > 1, labels: { colors: "#d6e4e1" } },
tooltip: { theme: "dark" },
series: donut ? c.series[0].data : c.series,
};
if (donut) opts.labels = c.categories || [];
else opts.xaxis = { categories: c.categories || [] };
if (c.type === "area") opts.fill = { type: "gradient", gradient: { opacityFrom: 0.3, opacityTo: 0.02 } };
_apex = new ApexCharts(chartEl, opts);
_apex.render();
}
const tableEl = $("rtable");
if (_grid) { _grid.destroy(); _grid = null; }
tableEl.innerHTML = "";
const tb = rep.table;
if (tb && window.gridjs && (tb.rows || []).length) {
_grid = new gridjs.Grid({
columns: tb.columns || [], data: tb.rows, sort: true,
search: tb.rows.length > 8, pagination: tb.rows.length > 12 ? { limit: 10 } : false,
style: { table: { "font-family": "IBM Plex Sans Condensed, sans-serif" } },
});
_grid.render(tableEl);
}
$("rmd").innerHTML = rep.markdown ? mdRender(rep.markdown) : "";
$("report").classList.add("open");
line("sys", "SYSTÈME", `rapport affiché · ${rep.title || "Rapport"}`);
}
</script>
</body>
</html>