Download index.html from PascalF53/jarvis-local: direct link, hf CLI and curl.
- Browser
- Download file 36.1 kB
-
https://huggingface.co/PascalF53/jarvis-local/resolve/main/index.html
- Command line
-
hf download hf://PascalF53/jarvis-local/index.html
-
curl -L -o index.html https://huggingface.co/PascalF53/jarvis-local/resolve/main/index.html
36.1 kB
| <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 ; color: var(--text); border-color: var(--line); font-size: 13px; } | |
| #rtable .gridjs-tr:hover td { background: var(--panel) ; } | |
| #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> | |
| ; | |
| 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| } | |
| 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> | |