1/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */2(() => {3 const $ = (id) => document.getElementById(id);4 const messagesEl = $('messages');5 const inputEl = $('input');6 const sendBtn = $('sendBtn');7 const stopBtn = $('stopBtn');8 const projectSel = $('projectSel');910 marked.setOptions({ breaks: true, mangle: false, headerIds: false });1112 // ---------- état ----------13 const store = JSON.parse(localStorage.getItem('adminka') || '{}');14 const settings = {15 model: store.model || 'fable',16 permMode: store.permMode || 'default',17 projectId: store.projectId || null,18 };19 function saveStore() { localStorage.setItem('adminka', JSON.stringify(settings)); }2021 let ws = null, wsRetry = 1000, wsWanted = true;22 let currentChat = null; // publicChat du serveur23 let isRunning = false;24 let live = null, liveThink = null;25 let curView = 'chat';26 let ecoData = null;27 const toolCards = new Map();28 const permCards = new Map();2930 const MODEL_LABEL = { fable: 'Fable 5', opus: 'Opus 5', sonnet: 'Sonnet 5', haiku: 'Haiku' };31 const PERM_LABEL = { default: 'normal', acceptEdits: 'acceptEdits', plan: 'plan', bypass: '⚠ bypass' };3233 // ---------- utilitaires ----------34 function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }35 function el(tag, cls, html) {36 const e = document.createElement(tag);37 if (cls) e.className = cls;38 if (html !== undefined) e.innerHTML = html;39 return e;40 }41 function fmt$(v) { return (v || 0) < 0.005 ? '<0,01 $' : (v).toFixed(2).replace('.', ',') + ' $'; }42 function fmtTok(v) { return v > 1000 ? Math.round(v / 1000) + 'k' : String(v || 0); }43 function timeAgo(ts) {44 if (!ts) return '—';45 const s = (Date.now() - ts) / 1000;46 if (s < 90) return 'à l’instant';47 if (s < 3600) return Math.round(s / 60) + ' min';48 if (s < 86400) return Math.round(s / 3600) + ' h';49 return Math.round(s / 86400) + ' j';50 }51 function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }5253 // ---------- navigation ----------54 const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'settings'];55 function switchView(name) {56 curView = name;57 for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);58 document.querySelectorAll('.nav-btn').forEach((b) => {59 b.classList.toggle('active', b.dataset.view === name);60 if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');61 });62 if (name === 'sessions') loadSessions();63 if (name === 'prompts') loadPrompts();64 if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }65 if (name === 'social') loadSocial();66 if (name === 'settings') refreshSettingsView();67 }68 document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });6970 // ---------- Écran du nœud (noVNC embarqué) ----------71 let _rfb = null;72 (function bindVNC(){73 const connect = document.getElementById('vncConnect');74 const disc = document.getElementById('vncDisconnect');75 const statusEl = document.getElementById('vncStatus');76 const panel = document.getElementById('vncPanel');77 if (!connect) return;78 connect.onclick = async () => {79 if (_rfb) { statusEl.textContent = 'déjà connecté'; return; }80 statusEl.textContent = 'connexion…';81 try {82 const pw = await (await fetch('/api/social/vncpw')).json();83 const RFB = window.KA_RFB; if (!RFB) { statusEl.textContent = 'noVNC pas encore chargé — rafraîchis la page'; return; }84 const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws';85 panel.style.display = 'block';86 _rfb = new RFB(panel, url, { credentials: { password: pw.password } });87 _rfb.scaleViewport = true;88 _rfb.resizeSession = false;89 _rfb.background = '#0d0d0d';90 _rfb.addEventListener('connect', () => { statusEl.textContent = '✅ connecté — tu peux cliquer dans l\'écran'; });91 _rfb.addEventListener('disconnect', (e) => { statusEl.textContent = 'déconnecté'; _rfb = null; panel.style.display='none'; });92 _rfb.addEventListener('credentialsrequired', () => { _rfb.sendCredentials({ password: pw.password }); });93 } catch (e) { statusEl.textContent = 'échec : ' + e.message; }94 };95 disc.onclick = () => { if (_rfb) { try { _rfb.disconnect(); } catch {} _rfb = null; } panel.style.display='none'; statusEl.textContent='déconnecté'; };96 })();9798 // ---------- Social ----------99 let socDraft = null;100 async function loadSocial() {101 try {102 const r = await fetch('/api/social/state'); const d = await r.json();103 renderSocialState(d.state); renderSocialLog(d.log || []);104 } catch {}105 }106 function renderSocialState(st) {107 if (!st) return;108 const tog = $('socAutoToggle'); if (tog) tog.checked = !!st.auto;109 const nx = $('socNext');110 if (nx) {111 if (st.auto && st.nextAt) nx.textContent = 'Prochaine publication ~ ' + new Date(st.nextAt).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) + ' (toutes les ' + st.intervalMin + ' min)';112 else nx.textContent = st.auto ? 'Automatique activé' : 'Automatique désactivé';113 }114 }115 function renderSocialLog(log) {116 const el = $('socLog'); if (!el) return;117 if (!log.length) { el.innerHTML = '<div class="soc-sub">Aucun événement.</div>'; return; }118 el.innerHTML = log.map(function (e) {119 const t = new Date(e.ts).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });120 let msg = e.kind;121 if (e.kind === 'published') msg = '📣 Publié — ' + (e.site || e.trigger || '') + (e.headline ? ' · ' + e.headline : '');122 else if (e.kind === 'cycle_error') msg = '⚠️ Erreur — ' + (e.error || '');123 else if (e.kind === 'cycle_skip') msg = '⏸️ Ignoré — ' + (e.reason || '');124 else if (e.kind === 'auto_on') msg = '▶️ Automatique activé';125 else if (e.kind === 'auto_off') msg = '⏹️ Automatique désactivé';126 else if (e.kind === 'cycle_start') msg = '… cycle démarré (' + (e.trigger || '') + ')';127 return '<div class="soc-logrow"><span class="soc-logt">' + t + '</span> ' + esc(msg) + '</div>';128 }).join('');129 }130 function bindSocial() {131 const tog = $('socAutoToggle');132 if (tog) tog.onchange = async () => {133 const r = await fetch('/api/social/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on: tog.checked }) });134 const d = await r.json(); renderSocialState(d.state);135 banner(tog.checked ? 'good' : 'muted', tog.checked ? 'Publication automatique activée' : 'Publication automatique désactivée');136 };137 const gen = $('socGenerate');138 if (gen) gen.onclick = doGenerate;139 const rg = $('socRegen'); if (rg) rg.onclick = doGenerate;140 const pub = $('socPublish');141 if (pub) pub.onclick = async () => {142 if (!socDraft) return;143 pub.disabled = true; pub.textContent = 'Publication…';144 try {145 const r = await fetch('/api/social/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: $('socCaption').value, image: socDraft.image.split('/').pop() }) });146 const d = await r.json();147 if (d.ok) { banner('good', '📣 Post publié sur la Page Groupe-KA'); $('socDraft').classList.add('hidden'); socDraft = null; loadSocial(); }148 else banner('bad', 'Échec : ' + (d.error || '?'));149 } catch (e) { banner('bad', 'Échec : ' + e.message); }150 pub.disabled = false; pub.textContent = '📣 Publier ce post';151 };152 const run = $('socRunNow');153 if (run) run.onclick = async () => {154 run.disabled = true; run.textContent = 'Publication…';155 try {156 const r = await fetch('/api/social/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });157 const d = await r.json();158 banner(d.ok ? 'good' : 'bad', d.ok ? ('📣 Publié : ' + (d.insight ? d.insight.site : '')) : ('Échec : ' + (d.error || d.reason || '?')));159 loadSocial();160 } catch (e) { banner('bad', 'Échec : ' + e.message); }161 run.disabled = false; run.textContent = '⚡ Publier maintenant';162 };163 const chk = $('socCheckLogin');164 if (chk) chk.onclick = async () => {165 const el = $('socLogin'); el.textContent = 'Vérification…';166 try { const r = await fetch('/api/social/login'); const d = await r.json();167 el.textContent = d.login === 'LOGGED_IN' ? '✅ Facebook connecté (Simon-Pierre)' : (d.login === 'LOGIN_PAGE' ? '⚠️ Facebook déconnecté — reconnecter Safari sur le nœud' : ('État : ' + (d.login || d.error)));168 } catch (e) { el.textContent = 'Erreur : ' + e.message; }169 };170 }171 let socReelDraft = null;172 (function bindReel(){173 const gen = document.getElementById('socReelGen');174 if (gen) gen.onclick = async () => {175 const prev = gen.textContent; gen.disabled = true; gen.textContent = 'Génération vidéo…';176 try {177 const r = await fetch('/api/social/reel/generate', { method:'POST', headers:{'Content-Type':'application/json'},178 body: JSON.stringify({ site: document.getElementById('socReelSite').value, prompt: document.getElementById('socReelPrompt').value }) });179 const d = await r.json();180 if (d.draft) { socReelDraft = d.draft;181 document.getElementById('socReelVid').src = d.draft.videoUrl + '?t=' + Date.now();182 document.getElementById('socReelCaption').value = d.draft.caption;183 document.getElementById('socReelDl').href = d.draft.videoUrl;184 document.getElementById('socReelDraft').classList.remove('hidden');185 } else banner('bad', 'Échec : ' + (d.error||'?'));186 } catch(e){ banner('bad','Échec : '+e.message); }187 gen.disabled = false; gen.textContent = prev;188 };189 const pub = document.getElementById('socReelPublish');190 if (pub) pub.onclick = async () => {191 if (!socReelDraft) return;192 pub.disabled = true; pub.textContent = 'Publication…';193 banner('muted', '🎬 Publication du reel — termine le « Suivant » sur l\'écran distant du nœud si demandé', null, 9000);194 try {195 const r = await fetch('/api/social/reel/publish', { method:'POST', headers:{'Content-Type':'application/json'},196 body: JSON.stringify({ caption: document.getElementById('socReelCaption').value, video: socReelDraft.video.split('/').pop() }) });197 const d = await r.json();198 banner(d.ok?'good':'bad', d.ok?'📣 Reel publié':'Échec : '+(d.error||'?'));199 if (d.ok) { document.getElementById('socReelDraft').classList.add('hidden'); socReelDraft=null; loadSocial(); }200 } catch(e){ banner('bad','Échec : '+e.message); }201 pub.disabled = false; pub.textContent = '📣 Publier (assisté)';202 };203 })();204205 async function doGenerate() {206 const gen = $('socGenerate'); const prev = gen.textContent;207 gen.disabled = true; gen.textContent = 'Génération…';208 try {209 const r = await fetch('/api/social/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ site: $('socSite').value, prompt: $('socPrompt').value }) });210 const d = await r.json();211 if (d.draft) {212 socDraft = d.draft;213 $('socImg').src = d.draft.imageUrl + '?t=' + Date.now();214 $('socCaption').value = d.draft.caption;215 $('socDraft').classList.remove('hidden');216 } else banner('bad', 'Échec : ' + (d.error || '?'));217 } catch (e) { banner('bad', 'Échec : ' + e.message); }218 gen.disabled = false; gen.textContent = prev;219 }220221 bindSocial();222 function navBadge(view) {223 if (curView === view) return;224 const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);225 if (b) b.classList.remove('hidden');226 }227228 // ---------- bannières ----------229 function banner(kind, html, onclick, ttl = 6000) {230 const b = el('div', 'banner ' + kind, html);231 if (onclick) b.onclick = () => { onclick(); b.remove(); };232 $('banners').appendChild(b);233 if (navigator.vibrate) navigator.vibrate(60);234 setTimeout(() => b.remove(), ttl);235 }236237 // ---------- scroll / messages ----------238 function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }239 function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }240 function addMsg(node) {241 $('welcome').classList.add('hidden');242 const wasNear = nearBottom();243 const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node);244 scrollBottom(wasNear);245 }246 function renderMd(container, text) {247 container.innerHTML = marked.parse(text || '');248 container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });249 }250 // bouton copier sur tous les blocs de code (délégué, marche pour tout <pre>)251 messagesEl.addEventListener('click', (e) => {252 const pre = e.target.closest('pre');253 if (!pre || e.target.closest('button') || pre.dataset.copying) return;254 if (window.getSelection().toString()) return;255 navigator.clipboard.writeText(pre.textContent).then(() => {256 pre.dataset.copying = '1';257 const tag = el('span', 'copied-tag', 'copié ✓');258 pre.appendChild(tag);259 setTimeout(() => { tag.remove(); delete pre.dataset.copying; }, 1200);260 }).catch(() => {});261 });262 function truncPre(text, limit = 3000) {263 const pre = el('pre');264 const full = String(text ?? '');265 if (full.length <= limit) { pre.textContent = full; return pre; }266 pre.textContent = full.slice(0, limit);267 const wrap = el('div');268 const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');269 btn.onclick = () => { pre.textContent = full; btn.remove(); };270 wrap.append(pre, btn);271 return wrap;272 }273274 function workingEl() { return messagesEl.querySelector('.working'); }275 let workTimer = null, workStart = 0;276 function setRunning(r) {277 isRunning = r;278 sendBtn.classList.toggle('hidden', r);279 stopBtn.classList.toggle('hidden', !r);280 let w = workingEl();281 if (r && !w) {282 $('welcome').classList.add('hidden');283 w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span><b class="work-time"></b>');284 messagesEl.appendChild(w);285 scrollBottom(true);286 workStart = Date.now();287 clearInterval(workTimer);288 workTimer = setInterval(() => {289 const t = w.querySelector('.work-time');290 if (!t) { clearInterval(workTimer); return; }291 const s = Math.floor((Date.now() - workStart) / 1000);292 t.textContent = s >= 60 ? Math.floor(s / 60) + ' min ' + (s % 60) + ' s' : s + ' s';293 }, 1000);294 } else if (!r && w) { w.remove(); clearInterval(workTimer); }295 if (!r) {296 finalizeLive(); finalizeThink();297 flushQueued();298 }299 }300 const WORK_VERB = { Bash: 'exécute', Read: 'lit', Edit: 'modifie', MultiEdit: 'modifie', Write: 'écrit', Glob: 'cherche', Grep: 'cherche dans', LS: 'liste', Task: 'délègue', WebFetch: 'récupère', WebSearch: 'recherche', TodoWrite: 'planifie', ExitPlanMode: 'propose un plan' };301 function setWorkingLabel(name, sub) {302 const w = workingEl(); if (!w) return;303 const span = w.querySelector('span'); if (!span) return;304 const verb = WORK_VERB[name] || 'utilise ' + name;305 const detail = sub ? ' ' + sub.slice(0, 46) : '';306 span.textContent = `Claude ${verb}${detail}…`;307 }308 // file d'attente : un message tapé pendant l'exécution part dès que Claude a fini309 let queuedPrompt = null;310 function flushQueued() {311 if (!queuedPrompt) return;312 const q = queuedPrompt; queuedPrompt = null;313 const n = messagesEl.querySelector('.queued-notice'); if (n) n.remove();314 doSend(q);315 }316317 // ---------- streaming live ----------318 function ensureLive() {319 if (live) return live;320 const w = workingEl(); if (w) { const s = w.querySelector('span'); if (s) s.textContent = 'Claude rédige…'; }321 const wrap = el('div', 'msg-claude');322 const md = el('div', 'md');323 wrap.appendChild(md);324 addMsg(wrap);325 live = { el: wrap, textEl: md, text: '' };326 return live;327 }328 function finalizeLive() { if (live) { live.el.remove(); live = null; } }329 function ensureThink() {330 if (liveThink) return liveThink;331 const box = el('div', 'thinking');332 addMsg(box);333 liveThink = { el: box, text: '' };334 return liveThink;335 }336 function finalizeThink() {337 if (!liveThink) return;338 const d = el('details', 'thinking-box', '<summary>réflexion</summary>');339 const inner = el('div', 'thinking'); inner.textContent = liveThink.text;340 d.appendChild(inner);341 liveThink.el.replaceWith(d);342 liveThink = null;343 }344345 // ---------- cartes outils ----------346 const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };347 const TOOL_TINT = { Bash: 'bash', Edit: 'edit', MultiEdit: 'edit', Write: 'edit', NotebookEdit: 'edit', Read: 'read', Glob: 'read', Grep: 'read', LS: 'read', NotebookRead: 'read', WebFetch: 'web', WebSearch: 'web' };348 function toolSubtitle(name, input) {349 input = input || {};350 if (name === 'Bash') return input.command || '';351 if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/');352 if (input.pattern) return input.pattern;353 if (input.url) return input.url;354 if (name === 'Task') return input.description || '';355 if (name === 'TodoWrite') return 'liste de tâches';356 return '';357 }358 function diffBlock(oldStr, newStr) {359 const d = el('div', 'diff');360 for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l)));361 for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l)));362 return d;363 }364 function toolBody(name, input) {365 const body = el('div', 'tool-body');366 input = input || {};367 if (name === 'Bash') {368 body.appendChild(el('div', 'label', 'commande'));369 body.appendChild(truncPre(input.command));370 if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description)));371 } else if (name === 'Edit') {372 body.appendChild(diffBlock(input.old_string, input.new_string));373 } else if (name === 'MultiEdit') {374 for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string));375 } else if (name === 'Write') {376 body.appendChild(el('div', 'label', 'contenu'));377 const d = el('div', 'diff');378 const lines = String(input.content || '').split('\n');379 for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l)));380 if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus'));381 body.appendChild(d);382 } else if (name === 'TodoWrite') {383 const ul = el('ul', 'todos');384 for (const t of input.todos || []) {385 const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';386 ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));387 }388 body.appendChild(ul);389 } else if (name === 'Task') {390 body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));391 } else if (name === 'ExitPlanMode') {392 const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);393 } else {394 body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));395 }396 return body;397 }398 // outils dont on ouvre la carte par défaut (on veut VOIR ce qui est fait)399 const OPEN_BY_DEFAULT = new Set(['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'ExitPlanMode']);400 function addToolCard(block) {401 finalizeLive(); finalizeThink();402 const name = block.name || '?';403 const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name;404 const card = el('details', 'tool');405 if (OPEN_BY_DEFAULT.has(name)) card.open = true;406 const sum = el('summary');407 sum.innerHTML = `<span class="t-icon ${TOOL_TINT[name] || ''}">${TOOL_ICONS[name] || '⚙️'}</span><span class="t-name">${esc(shortName)}</span><span class="t-sub">${esc(toolSubtitle(name, block.input))}</span><span class="t-state pending">● en cours</span>`;408 card.appendChild(sum);409 card.appendChild(toolBody(name, block.input));410 addMsg(card);411 toolCards.set(block.id, { card, name, stateEl: sum.querySelector('.t-state'), subEl: sum.querySelector('.t-sub'), bodyEl: card.querySelector('.tool-body') });412 // met à jour l'indicateur « en cours » avec l'action réelle413 setWorkingLabel(name, toolSubtitle(name, block.input));414 }415 function attachToolResult(block) {416 const t = toolCards.get(block.tool_use_id);417 if (!t) return;418 t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok');419 t.stateEl.textContent = block.is_error ? '✗ erreur' : '✓';420 let text = '';421 if (typeof block.content === 'string') text = block.content;422 else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');423 text = text.trim();424 if (text) {425 // les cartes repliées (Read/Grep/…) montrent un aperçu du résultat dans l'en-tête426 if (!t.card.open && t.subEl) {427 const firstLine = text.split('\n').find(l => l.trim()) || '';428 t.subEl.textContent = firstLine.slice(0, 80);429 }430 t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));431 t.bodyEl.appendChild(truncPre(text));432 } else {433 t.bodyEl.appendChild(el('div', 'meta-line', block.is_error ? 'erreur (vide)' : 'terminé sans sortie'));434 }435 }436437 // ---------- permissions ----------438 function addPermCard(ev) {439 finalizeLive(); finalizeThink();440 const card = el('div', 'perm');441 const isPlan = ev.tool === 'ExitPlanMode';442 card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;443 if (isPlan && ev.input?.plan) {444 const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);445 } else {446 const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);447 }448 const actions = el('div', 'perm-actions');449 const deny = el('button', 'perm-deny', 'Refuser');450 const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');451 actions.append(deny, allow);452 if (!isPlan) {453 const always = el('button', 'perm-always', 'Toujours<br><small>cet outil</small>');454 always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });455 actions.insertBefore(always, allow);456 // « Tout autoriser » : le serveur approuve TOUTES les permissions de la457 // session — la tâche va au bout même téléphone rangé458 const all = el('button', 'perm-allall', '🔓 Tout autoriser — finir la tâche sans redemander');459 all.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, allowAll: true });460 card.appendChild(actions);461 card.appendChild(all);462 } else {463 card.appendChild(actions);464 }465 deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });466 allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });467 addMsg(card);468 permCards.set(ev.requestId, card);469 scrollBottom(true);470 if (navigator.vibrate) navigator.vibrate([60, 60, 60]);471 }472 function resolvePermCard(ev) {473 const card = permCards.get(ev.requestId);474 if (!card) return;475 const actions = card.querySelector('.perm-actions');476 if (actions) actions.remove();477 const allBtn = card.querySelector('.perm-allall');478 if (allBtn) allBtn.remove();479 card.appendChild(el('div', 'perm-resolved',480 ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));481 }482483 // ---------- rendu des événements ----------484 function renderEvent(ev) {485 if (ev.type === 'user_prompt') {486 finalizeLive(); finalizeThink();487 const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b);488 return;489 }490 if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }491 if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }492 if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }493 if (ev.type === 'permission_request') { addPermCard(ev); return; }494 if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }495 if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }496 if (ev.type === 'claude') { renderClaude(ev.event); return; }497 }498499 function renderClaude(ev) {500 if (ev.type === 'system') {501 if (ev.subtype === 'init') {502 addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));503 } else if (ev.subtype === 'compact_boundary') {504 addMsg(el('div', 'meta-line', '⇣ contexte compacté'));505 } else if (ev.subtype && ev.subtype !== 'hook') {506 addMsg(el('div', 'meta-line', esc('⌁ ' + ev.subtype)));507 }508 return;509 }510 if (ev.type === 'stream_event' && ev.event) {511 const se = ev.event;512 if (se.type === 'content_block_delta' && se.delta) {513 if (se.delta.type === 'text_delta') {514 const l = ensureLive();515 l.text += se.delta.text;516 renderMd(l.textEl, l.text);517 scrollBottom();518 } else if (se.delta.type === 'thinking_delta') {519 const t = ensureThink();520 t.text += se.delta.thinking;521 t.el.textContent = t.text.slice(-600);522 scrollBottom();523 }524 }525 return;526 }527 if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {528 for (const block of ev.message.content) {529 if (block.type === 'text' && block.text && block.text.trim()) {530 finalizeLive(); finalizeThink();531 const wrap = el('div', 'msg-claude');532 const md = el('div', 'md');533 renderMd(md, block.text);534 wrap.appendChild(md);535 addMsg(wrap);536 } else if (block.type === 'tool_use') {537 addToolCard(block);538 } else if (block.type === 'thinking' && block.thinking) {539 if (liveThink) { liveThink.el.remove(); liveThink = null; }540 const d = el('details', 'thinking-box', '<summary>réflexion</summary>');541 const inner = el('div', 'thinking'); inner.textContent = block.thinking;542 d.appendChild(inner);543 addMsg(d);544 }545 }546 return;547 }548 if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {549 for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);550 return;551 }552 if (ev.type === 'result') {553 finalizeLive(); finalizeThink();554 const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : '';555 const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : '';556 const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : '';557 addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns)));558 if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result))));559 return;560 }561 }562563 // ---------- méta de session (chips + bandeau) ----------564 function refreshChips() {565 const model = currentChat ? currentChat.model : settings.model;566 const perm = currentChat ? currentChat.permMode : settings.permMode;567 $('chipModel').textContent = MODEL_LABEL[model] || model;568 $('chipPerm').textContent = PERM_LABEL[perm] || perm;569 $('chipPerm').classList.toggle('warn', perm === 'bypass');570 $('chipModel').classList.add('orange');571 projectSel.classList.toggle('all-mode', !currentChat && projectSel.value === 'ALL');572 const meta = $('sessMeta');573 if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {574 meta.classList.remove('hidden');575 $('metaModel').textContent = (currentChat.autoAllowAll ? '🔓 ' : '') + (currentChat.lastModel || MODEL_LABEL[model]);576 $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';577 $('metaCost').textContent = fmt$(currentChat.totalCost);578 $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok (' + Math.min(99, Math.round(currentChat.contextTokens / 2000)) + '%)' : '';579 } else meta.classList.add('hidden');580 }581582 // ---------- WebSocket ----------583 function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }584 function setConn(state) {585 const pill = $('connPill');586 pill.className = 'conn-pill ' + state;587 $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';588 }589 function connect() {590 if (!wsWanted) return;591 setConn('mid');592 const proto = location.protocol === 'https:' ? 'wss' : 'ws';593 ws = new WebSocket(`${proto}://${location.host}/ws`);594 ws.onopen = () => {595 setConn('on');596 wsRetry = 1000;597 if (currentChat) send({ type: 'open', chatId: currentChat.id });598 if (curView === 'eco') send({ type: 'eco_sub' });599 };600 ws.onclose = () => {601 setConn('off');602 setTimeout(connect, wsRetry);603 wsRetry = Math.min(wsRetry * 1.6, 20000);604 };605 ws.onmessage = (e) => {606 let msg;607 try { msg = JSON.parse(e.data); } catch { return; }608 handleServer(msg);609 };610 }611 function rememberChat(id) { settings.lastChatId = id; saveStore(); }612 function handleServer(msg) {613 if (msg.type === 'batch_created') {614 banner('good', `🌐 Lancé sur ${msg.count}/${msg.total} apps — suis-les dans Sessions`, () => switchView('sessions'), 6000);615 switchView('sessions');616 return;617 }618 if (msg.type === 'chat_created') { currentChat = msg.chat; rememberChat(msg.chat.id); refreshChips(); return; }619 if (msg.type === 'chat_meta') {620 if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }621 return;622 }623 if (msg.type === 'history') {624 currentChat = msg.chat;625 rememberChat(msg.chat.id);626 clearConversation(true);627 for (const ev of msg.events) { try { renderEvent(ev); } catch {} }628 setRunning(msg.running);629 for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;630 refreshChips();631 scrollBottom(true);632 return;633 }634 if (msg.type === 'notify') {635 if (msg.kind === 'done') {636 if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {637 banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));638 navBadge('chat'); navBadge('sessions');639 }640 } else if (msg.kind === 'perm') {641 if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {642 banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);643 navBadge('chat');644 }645 }646 return;647 }648 if (msg.type === 'eco_alert') {649 if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);650 else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));651 navBadge('eco');652 if (curView === 'eco') loadEco();653 return;654 }655 if (msg.type === 'eco') {656 if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);657 return;658 }659 renderEvent(msg);660 }661662 function clearConversation(keepChat) {663 messagesEl.innerHTML = '';664 toolCards.clear(); permCards.clear();665 live = null; liveThink = null;666 if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');667 }668 function openChat(chatId) {669 switchView('chat');670 currentChat = { id: chatId };671 clearConversation(true);672 send({ type: 'open', chatId });673 }674675 // ---------- composer ----------676 function doSend(text) {677 send({678 type: 'start',679 chatId: currentChat ? currentChat.id : null,680 project: projectSel.value,681 model: currentChat ? currentChat.model : settings.model,682 permMode: currentChat ? currentChat.permMode : settings.permMode,683 prompt: text,684 });685 }686 function sendPrompt() {687 const text = inputEl.value.trim();688 if (!text) return;689 // commandes slash locales690 if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }691 if (text === '/cost') {692 if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));693 inputEl.value = ''; autoGrow(); return;694 }695 if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }696 inputEl.value = '';697 autoGrow();698 hideSlash();699 // diffusion à toutes les apps KA700 if (!currentChat && projectSel.value === 'ALL') { broadcastToAll(text); return; }701 if (isRunning) {702 // Claude travaille : le message part automatiquement dès la fin du tour703 queuedPrompt = queuedPrompt ? queuedPrompt + '\n' + text : text;704 let n = messagesEl.querySelector('.queued-notice');705 if (!n) { n = el('div', 'notice-line queued-notice'); messagesEl.appendChild(n); }706 n.textContent = '⏳ en attente de la fin du tour : « ' + queuedPrompt.slice(0, 80) + ' »';707 scrollBottom(true);708 return;709 }710 doSend(text);711 }712 function broadcastToAll(text) {713 if (!confirm('Lancer ce prompt sur TOUTES les apps du Groupe KA en parallèle (une session par app) ?')) return;714 send({ type: 'start_all', prompt: text, model: settings.model, permMode: settings.permMode });715 }716 sendBtn.onclick = sendPrompt;717 stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };718 inputEl.addEventListener('keydown', (e) => {719 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }720 });721 function autoGrow() {722 inputEl.style.height = 'auto';723 inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';724 }725 inputEl.addEventListener('input', () => {726 autoGrow();727 if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();728 });729 inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));730731 function showSlash() {732 const h = $('slashHint');733 h.classList.remove('hidden');734 if (!h.childElementCount) {735 for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {736 const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);737 b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };738 h.appendChild(b);739 }740 }741 }742 function hideSlash() { $('slashHint').classList.add('hidden'); }743744 document.querySelectorAll('.hint').forEach((h) => {745 h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };746 });747748 // ---------- upgradeur de prompt (Opus 4.8 + savoir Groupe KA) ----------749 const upgradeBtn = $('upgradeBtn');750 let upgrading = false;751 upgradeBtn.onclick = async () => {752 const raw = inputEl.value.trim();753 if (!raw || upgrading) return;754 upgrading = true;755 upgradeBtn.classList.add('busy');756 const original = inputEl.value;757 inputEl.disabled = true;758 inputEl.value = '✨ amélioration du prompt avec Opus 4.8…';759 try {760 const r = await fetch('/api/upgrade-prompt', {761 method: 'POST',762 headers: { 'Content-Type': 'application/json' },763 body: JSON.stringify({ prompt: raw, project: projectSel.value }),764 });765 const d = await r.json();766 if (r.ok && d.upgraded) {767 inputEl.value = d.upgraded;768 if (navigator.vibrate) navigator.vibrate(40);769 } else {770 inputEl.value = original;771 banner('bad', '✗ Upgrade impossible : ' + esc(d.error || 'erreur'), null, 5000);772 }773 } catch (e) {774 inputEl.value = original;775 banner('bad', '✗ Upgrade impossible (réseau)', null, 5000);776 } finally {777 inputEl.disabled = false;778 upgrading = false;779 upgradeBtn.classList.remove('busy');780 autoGrow();781 inputEl.focus();782 }783 };784785 // ---------- chips ----------786 $('chipModel').onclick = () => {787 const order = ['fable', 'opus', 'sonnet', 'haiku'];788 const cur = currentChat ? currentChat.model : settings.model;789 const next = order[(order.indexOf(cur) + 1) % order.length];790 applyOpts({ model: next });791 };792 $('chipPerm').onclick = () => {793 const order = ['default', 'acceptEdits', 'plan', 'bypass'];794 const cur = currentChat ? currentChat.permMode : settings.permMode;795 const next = order[(order.indexOf(cur) + 1) % 4];796 if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;797 applyOpts({ permMode: next });798 };799 function applyOpts(opts) {800 if (currentChat) {801 Object.assign(currentChat, opts);802 send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });803 }804 if (opts.model) settings.model = opts.model;805 if (opts.permMode) settings.permMode = opts.permMode;806 saveStore();807 refreshChips();808 refreshSettingsView();809 }810 $('chipMore').onclick = () => {811 const c = currentChat;812 openSheet(`813 <div class="sheet-h"><h3>Session</h3></div>814 <div class="sheet-sec">815 <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>816 <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>817 <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>818 <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>819 <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>820 </div>821 <div class="sheet-sec">822 <div class="set-actions">823 <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>824 <button class="btn-ghost" id="shCompact">/compact</button>825 <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>826 </div>827 </div>`);828 $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };829 $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };830 $('shNew').onclick = () => { closeSheet(); newChat(); };831 };832 function newChat() {833 currentChat = null;834 queuedPrompt = null;835 settings.lastChatId = null; saveStore();836 clearConversation();837 setRunning(false);838 refreshChips();839 switchView('chat');840 }841842 // ---------- sessions ----------843 async function loadSessions() {844 const r = await fetch('/api/chats');845 if (!r.ok) return;846 const { chats } = await r.json();847 const list = $('sessList');848 list.innerHTML = '';849 if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }850 const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };851 for (const c of chats) {852 const card = el('div', 'sess-card');853 card.innerHTML = `854 <div class="sess-top">855 <span class="sess-title">${esc(c.title)}</span>856 <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>857 </div>858 <div class="sess-info">859 ${c.batch ? '<span class="batch-tag">🌐 diffusion</span>' : ''}860 <span>${esc(c.projectName || '')}</span>861 <span>${MODEL_LABEL[c.model] || c.model}</span>862 <span>${fmt$(c.totalCost)}</span>863 <span>${timeAgo(c.updated)}</span>864 </div>`;865 const actions = el('div', 'sess-actions');866 const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');867 open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };868 actions.appendChild(open);869 if (c.state === 'idle') {870 const del = el('button', 'del', 'Supprimer');871 del.onclick = async (e) => {872 e.stopPropagation();873 if (!confirm('Supprimer cette session ?')) return;874 await fetch('/api/chats/' + c.id, { method: 'DELETE' });875 card.remove();876 if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }877 };878 actions.appendChild(del);879 }880 card.appendChild(actions);881 card.onclick = () => openChat(c.id);882 list.appendChild(card);883 }884 }885 $('newSessBtn').onclick = newChat;886887 // ---------- Prompt Bank ----------888 function projectOptionsHTML(selected) {889 return [...projectSel.options].map(o =>890 `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');891 }892 async function loadPrompts() {893 const r = await fetch('/api/prompts');894 if (!r.ok) return;895 const { prompts } = await r.json();896 const list = $('promptList');897 list.innerHTML = '';898 if (!prompts.length) { list.appendChild(el('div', 'sess-empty', 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; }899 for (const pr of prompts) {900 const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';901 const card = el('div', 'sess-card');902 card.innerHTML = `903 <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div>904 <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>905 <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`;906 const actions = el('div', 'sess-actions');907 const sendB = el('button', 'p-send', 'Envoyer →');908 const editB = el('button', '', '✎ Éditer');909 const upB = el('button', '', '✨ Améliorer');910 const delB = el('button', 'del', 'Suppr.');911 actions.append(sendB, upB, editB, delB);912 card.appendChild(actions);913 sendB.onclick = () => openSendSheet(pr);914 editB.onclick = () => openPromptEditor(pr);915 delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); card.remove(); };916 upB.onclick = async () => {917 upB.textContent = '✨ …'; upB.disabled = true;918 try {919 const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: pr.text, project: pr.project }) });920 const d = await rr.json();921 if (rr.ok && d.upgraded) {922 await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: d.upgraded }) });923 banner('good', '✨ Prompt amélioré et enregistré', null, 3000);924 loadPrompts();925 } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);926 } catch { banner('bad', '✗ réseau', null, 4000); }927 upB.textContent = '✨ Améliorer'; upB.disabled = false;928 };929 list.appendChild(card);930 }931 }932 $('newPromptBtn').onclick = () => openPromptEditor(null);933934 function openPromptEditor(pr) {935 const isNew = !pr;936 openSheet(`937 <div class="sheet-h"><h3>${isNew ? 'Nouveau prompt' : 'Éditer le prompt'}</h3></div>938 <div class="sheet-sec">939 <input id="peTitle" class="pe-input" placeholder="Titre (optionnel)" value="${esc(pr?.title || '')}">940 <textarea id="peText" class="pe-textarea" placeholder="Écris ton prompt ici…">${esc(pr?.text || '')}</textarea>941 <div class="set-label" style="margin-top:10px">App par défaut</div>942 <select id="peProject" class="pill-select" style="max-width:100%;width:100%">943 <option value="">— app au choix (demandé à l’envoi) —</option>944 ${projectOptionsHTML(pr?.project || '')}945 </select>946 <div class="set-actions" style="margin-top:12px">947 <button class="btn-ghost" id="peUpgrade">✨ Améliorer (Opus 4.8)</button>948 <button class="btn-cta small" id="peSave">Enregistrer</button>949 </div>950 </div>`);951 $('peUpgrade').onclick = async () => {952 const t = $('peText').value.trim(); if (!t) return;953 const b = $('peUpgrade'); b.textContent = '✨ amélioration…'; b.disabled = true;954 try {955 const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: t, project: $('peProject').value }) });956 const d = await rr.json();957 if (rr.ok && d.upgraded) { $('peText').value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); }958 else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);959 } catch { banner('bad', '✗ réseau', null, 4000); }960 b.textContent = '✨ Améliorer (Opus 4.8)'; b.disabled = false;961 };962 $('peSave').onclick = async () => {963 const text = $('peText').value.trim(); if (!text) { banner('bad', 'Le prompt est vide', null, 3000); return; }964 const payload = { title: $('peTitle').value, text, project: $('peProject').value || null };965 if (isNew) await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });966 else await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });967 closeSheet(); loadPrompts();968 };969 }970971 function openSendSheet(pr) {972 openSheet(`973 <div class="sheet-h"><h3>Envoyer à Claude Code</h3></div>974 <div class="sheet-sec">975 <div class="prompt-preview" style="max-height:180px;overflow:auto">${esc(pr.text)}</div>976 </div>977 <div class="sheet-sec">978 <div class="set-label">App cible (nœud)</div>979 <select id="sendProject" class="pill-select" style="max-width:100%;width:100%">${projectOptionsHTML(pr.project || projectSel.value)}</select>980 <div class="set-label" style="margin-top:10px">Modèle · permissions</div>981 <div class="kv"><span>${MODEL_LABEL[settings.model] || settings.model} · ${PERM_LABEL[settings.permMode] || settings.permMode}</span><span class="mono">(réglages par défaut)</span></div>982 <button class="btn-cta" style="width:100%;margin-top:12px" id="sendGo">🚀 Lancer sur Claude Code</button>983 </div>`);984 $('sendGo').onclick = () => {985 const proj = $('sendProject').value;986 closeSheet();987 sendPromptToApp(pr.text, proj);988 };989 }990991 function sendPromptToApp(text, projectId) {992 if (projectId === 'ALL') { broadcastToAll(text); return; }993 currentChat = null;994 queuedPrompt = null;995 clearConversation();996 setRunning(false);997 for (const o of projectSel.options) if (o.value === projectId) projectSel.value = o.value;998 switchView('chat');999 refreshChips();1000 send({ type: 'start', chatId: null, project: projectId, model: settings.model, permMode: settings.permMode, prompt: text });1001 banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);1002 }10031004 // incidents globaux (bouton en tête de l'onglet Écosystème)1005 $('incBtn').onclick = async () => {1006 const r = await fetch('/api/eco/incidents');1007 if (!r.ok) return;1008 const { incidents } = await r.json();1009 const label = (app) => (ecoData?.sites.find(s => s.app === app)?.label) || app;1010 openSheet(`1011 <div class="sheet-h"><h3>Incidents</h3></div>1012 <div class="sheet-sec">1013 <h4>Historique — 50 derniers</h4>1014 ${incidents.length ? incidents.map(i => `1015 <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1016 <span><b>${esc(label(i.site))}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1017 ${i.ended ? ' → rétabli en ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — <b style="color:var(--bad)">EN COURS</b>'} · ${esc(i.reason || '')}</span></div>`).join('')1018 : '<div style="color:var(--muted)">Aucun incident enregistré 🎉</div>'}1019 </div>`);1020 };10211022 // ---------- écosystème ----------1023 function siteStatus(s) {1024 if (s.incident || (s.last && !s.last.ok)) return 'down';1025 if (s.last && s.last.ms > 4000) return 'slow';1026 return s.last ? 'up' : 'unknown';1027 }1028 const ST = {1029 up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },1030 slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },1031 down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },1032 unknown: { cls: 'slow', ico: '▲', lbl: '…' },1033 };1034 function sparkline(spark, w = 120, h = 30) {1035 if (!spark || spark.length < 2) return '<svg class="spark"></svg>';1036 const ms = spark.map(p => p.ok ? p.ms : null);1037 const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;1038 const step = w / (spark.length - 1);1039 let d = '', pen = false;1040 spark.forEach((p, i) => {1041 if (!p.ok) { pen = false; return; }1042 const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);1043 d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);1044 pen = true;1045 });1046 const fails = spark.map((p, i) => !p.ok ? `<rect x="${(i * step - 1.5).toFixed(1)}" y="${h - 7}" width="3" height="5" fill="var(--bad)"/>` : '').join('');1047 return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>${fails}</svg>`;1048 }1049 async function loadEco() {1050 const [r, rn] = await Promise.all([fetch('/api/eco/summary'), fetch('/api/eco/nodes')]);1051 if (!r.ok) return;1052 ecoData = await r.json();1053 renderEco();1054 if (rn.ok) renderNodes(await rn.json());1055 }1056 function nbar(label, val, pct) {1057 const c = pct > 88 ? 'var(--bad)' : pct > 70 ? 'var(--warn)' : 'var(--good)';1058 return `<div class="nbar"><div class="nbar-l"><span>${label}</span><b>${val}</b></div>1059 <div class="nbar-track"><div class="nbar-fill" style="width:${Math.max(2, Math.min(100, pct))}%;background:${c}"></div></div></div>`;1060 }1061 function renderNodes(data) {1062 const grid = $('ecoNodes');1063 if (!grid) return;1064 grid.innerHTML = '';1065 for (const n of data.nodes) {1066 const L = n.last;1067 const memPct = L && L.mem_total ? Math.round(L.mem_used / L.mem_total * 100) : null;1068 const upApps = n.apps.filter(a => a.status === 'online').length;1069 const badApp = n.apps.some(a => a.status && a.status !== 'online');1070 const card = el('div', 'node-card' + (badApp ? ' warn' : ''));1071 card.innerHTML = `1072 <div class="node-top"><span class="node-name">${esc(n.node)}</span>1073 <span class="node-apps${badApp ? ' bad' : ''}">${upApps}/${n.apps.length} apps</span></div>1074 ${nbar('charge', L ? L.load1.toFixed(1) : '—', L ? Math.min(100, L.load1 / 32 * 100) : 0)}1075 ${nbar('RAM', memPct !== null ? memPct + '%' : '—', memPct || 0)}1076 ${nbar('disque', L ? L.disk_pct + '%' : '—', L ? L.disk_pct : 0)}`;1077 grid.appendChild(card);1078 }1079 }1080 function applyChecks(items) {1081 if (!ecoData) return;1082 for (const it of items) {1083 const s = ecoData.sites.find(x => x.app === it.site);1084 if (!s) continue;1085 s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };1086 s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });1087 if (s.spark.length > 40) s.spark.shift();1088 }1089 renderEco();1090 }1091 function trendBadge(t) {1092 if (t == null) return '';1093 if (t <= -8) return `<span class="trend down" title="latence en baisse">▼ ${Math.abs(t)}%</span>`;1094 if (t >= 8) return `<span class="trend up" title="latence en hausse">▲ ${t}%</span>`;1095 return `<span class="trend flat">≈</span>`;1096 }1097 function statusBar(sd) {1098 const total = (sd['2xx'] + sd['3xx'] + sd['4xx'] + sd['5xx'] + sd.err) || 1;1099 const seg = (n, cls) => n ? `<i class="${cls}" style="width:${(n / total * 100).toFixed(1)}%" title="${cls} : ${n}"></i>` : '';1100 return `<div class="statusbar">${seg(sd['2xx'], 's2')}${seg(sd['3xx'], 's3')}${seg(sd['4xx'], 's4')}${seg(sd['5xx'], 's5')}${seg(sd.err, 'se')}</div>`;1101 }1102 function renderEco() {1103 const sites = ecoData.sites;1104 const g = ecoData.global || {};1105 $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });1106 // bandeau KPI du command center1107 $('ecoOverview').innerHTML = `1108 <div class="kpi ${g.down ? 'bad' : 'ok'}"><div class="v">${g.up}<span class="tot">/${g.total}</span></div><div class="l">en ligne</div></div>1109 <div class="kpi"><div class="v">${g.uptime24 ?? '—'}<small>%</small></div><div class="l">uptime 24h</div></div>1110 <div class="kpi"><div class="v">${g.avgLatency}<small>ms</small></div><div class="l">latence moy.</div></div>1111 <div class="kpi"><div class="v">${(g.checks24 / 1000).toFixed(1)}<small>k</small></div><div class="l">checks 24h</div></div>1112 <div class="kpi ${g.fails24 ? 'bad' : ''}"><div class="v">${g.fails24}</div><div class="l">échecs 24h</div></div>1113 <div class="kpi ${g.openIncidents ? 'bad' : ''}"><div class="v">${g.openIncidents}</div><div class="l">incidents</div></div>`;1114 const grid = $('ecoGrid');1115 grid.innerHTML = '';1116 const order = { down: 0, slow: 1, unknown: 2, up: 3 };1117 for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)] || (b.last?.ms || 0) - (a.last?.ms || 0))) {1118 const st = ST[siteStatus(s)];1119 const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));1120 card.innerHTML = `1121 <div class="site-top">1122 <img class="site-logo" src="/icons/${s.app}" alt="" loading="lazy" onerror="this.style.display='none'">1123 <span class="site-name">${esc(s.label)}</span>1124 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>1125 </div>1126 <div class="site-mid">1127 <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small> ${trendBadge(s.trend)}</div>1128 ${sparkline(s.spark)}1129 </div>1130 ${statusBar(s.status24 || { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 })}1131 <div class="site-metrics">1132 <div class="m"><b>${s.uptime24 ?? '—'}%</b><span>uptime</span></div>1133 <div class="m"><b>${s.p95_24 ?? '—'}</b><span>p95 ms</span></div>1134 <div class="m"><b>${s.p99_24 ?? '—'}</b><span>p99 ms</span></div>1135 <div class="m"><b>${((s.checks24 || 0) / 1000).toFixed(1)}k</b><span>checks</span></div>1136 </div>1137 <div class="site-foot">1138 <span>${esc(s.node)}</span>1139 ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">pm2 <b>${esc(s.proc.status)}</b></span>` : ''}1140 ${s.proc?.last_commit ? `<span>commit <b>${timeAgo(s.proc.last_commit)}</b></span>` : ''}1141 ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}1142 </div>`;1143 card.onclick = () => openSiteSheet(s.app);1144 grid.appendChild(card);1145 }1146 }11471148 async function openSiteSheet(app) {1149 const r = await fetch('/api/eco/site/' + app);1150 if (!r.ok) return;1151 const d = await r.json();1152 const s = ecoData.sites.find(x => x.app === app) || {};1153 const st = ST[siteStatus(s)];1154 const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;1155 const L = d.lat24 || {}; const C = d.counts24 || {}; const IS = d.incidentStats || {}; const SD = d.status24 || {};1156 openSheet(`1157 <div class="sheet-h">1158 <img class="site-logo big" src="/icons/${d.app}" alt="" onerror="this.style.display='none'">1159 <h3>${esc(d.label)}</h3>1160 <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>1161 </div>1162 <div class="sheet-sec">1163 <h4>Disponibilité</h4>1164 <div class="tile-row">1165 <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>1166 <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>1167 <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>1168 </div>1169 <div style="margin-top:10px">${uptimeDays(d.days)}</div>1170 </div>1171 <div class="sheet-sec">1172 <h4>Latence — percentiles 24 h ${trendBadge(d.trend)}</h4>1173 ${latencyChart(d.series)}1174 <div class="pgrid">1175 <div class="pc"><b>${L.min ?? '—'}</b><span>min</span></div>1176 <div class="pc"><b>${L.p50 ?? '—'}</b><span>p50</span></div>1177 <div class="pc"><b>${L.p90 ?? '—'}</b><span>p90</span></div>1178 <div class="pc hot"><b>${L.p95 ?? '—'}</b><span>p95</span></div>1179 <div class="pc hot"><b>${L.p99 ?? '—'}</b><span>p99</span></div>1180 <div class="pc"><b>${L.max ?? '—'}</b><span>max</span></div>1181 </div>1182 </div>1183 <div class="sheet-sec">1184 <h4>Codes HTTP & fiabilité — 24 h</h4>1185 ${statusBar(SD)}1186 <div class="legend">1187 <span><i class="sw s2"></i>2xx ${SD['2xx'] || 0}</span>1188 <span><i class="sw s3"></i>3xx ${SD['3xx'] || 0}</span>1189 <span><i class="sw s4"></i>4xx ${SD['4xx'] || 0}</span>1190 <span><i class="sw s5"></i>5xx ${SD['5xx'] || 0}</span>1191 <span><i class="sw se"></i>err ${SD.err || 0}</span>1192 </div>1193 <div class="tile-row" style="margin-top:10px">1194 <div class="tile"><div class="v">${C.total ?? '—'}</div><div class="l">checks</div></div>1195 <div class="tile"><div class="v ${C.fail ? 'red' : ''}">${C.fail ?? '—'}</div><div class="l">échecs</div></div>1196 <div class="tile"><div class="v">${C.avgBytes ? Math.round(C.avgBytes / 1024) + 'k' : '—'}</div><div class="l">taille moy</div></div>1197 </div>1198 </div>1199 <div class="sheet-sec">1200 <h4>Incidents — 30 j</h4>1201 <div class="tile-row">1202 <div class="tile"><div class="v ${IS.count30 ? 'red' : ''}">${IS.count30 ?? 0}</div><div class="l">incidents</div></div>1203 <div class="tile"><div class="v">${IS.mttrMin != null ? IS.mttrMin + ' min' : '—'}</div><div class="l">MTTR</div></div>1204 <div class="tile"><div class="v">${IS.longestMin ? IS.longestMin + ' min' : '—'}</div><div class="l">pire arrêt</div></div>1205 </div>1206 </div>1207 <div class="sheet-sec">1208 <h4>Service</h4>1209 <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>1210 <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>1211 <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>1212 <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>1213 <div class="kv"><span>Charge nœud</span><b>${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}</b></div>1214 <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>1215 <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>1216 </div>1217 ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `1218 <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1219 <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1220 ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}1221 ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `1222 <div class="inc-item"><span class="inc-dot">✗</span><span>${new Date(e.ts).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} · ${e.code ? 'HTTP ' + e.code : esc(e.err || '')}</span></div>`).join('')}</div>` : ''}1223 <div class="sheet-sec">1224 <h4>Actions d'administration</h4>1225 <div class="admin-actions">1226 <button class="btn-ghost" id="aRestart">↻ Redémarrer PM2</button>1227 <button class="btn-ghost" id="aLogs">📜 Logs récents</button>1228 <button class="btn-ghost" id="aCommits">⌥ Git & commits</button>1229 <a class="btn-ghost" href="https://${esc(d.domain)}" target="_blank" rel="noopener">↗ Ouvrir le site</a>1230 </div>1231 <div id="aOut" class="admin-out hidden"></div>1232 </div>1233 <button class="btn-cta" style="width:100%" id="shChat">💬 Demander à Claude Code →</button>1234 `);1235 $('shChat').onclick = () => {1236 closeSheet();1237 newChat();1238 for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;1239 };1240 const out = () => $('aOut');1241 const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; };1242 $('aRestart').onclick = async () => {1243 if (!confirm(`Redémarrer ${d.label} (pm2 restart) sur ${d.node} ?`)) return;1244 showOut('<div class="admin-load">redémarrage…</div>');1245 const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) });1246 const j = await r.json();1247 showOut(`<div class="${j.ok ? 'admin-ok' : 'admin-err'}">${j.ok ? '✓ redémarré' : '✗ échec'} — statut : ${esc(j.output || '')}</div>`);1248 if (navigator.vibrate) navigator.vibrate(60);1249 };1250 $('aLogs').onclick = async () => {1251 showOut('<div class="admin-load">chargement des logs…</div>');1252 const r = await fetch('/api/admin/logs/' + d.app);1253 const j = await r.json();1254 showOut(`<pre>${esc((j.logs || '').slice(-4000))}</pre>`);1255 };1256 $('aCommits').onclick = async () => {1257 showOut('<div class="admin-load">…</div>');1258 const r = await fetch('/api/admin/commits/' + d.app);1259 const j = await r.json();1260 showOut(`<pre>${esc(j.text || '')}</pre>`);1261 };1262 attachChartTips();1263 }12641265 function uptimeDays(days) {1266 if (!days) return '';1267 const cells = days.map(dd => {1268 const u = dd.uptime;1269 const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';1270 return `<div title="${new Date(dd.day).toLocaleDateString('fr-CA')} — ${u === null ? 'aucune donnée' : u + '%'}" style="flex:1;height:22px;border-radius:5px;background:${c};border:1.5px solid var(--line)"></div>`;1271 }).join('');1272 return `<div style="display:flex;gap:4px">${cells}</div><div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:3px"><span>il y a 14 j</span><span>aujourd'hui</span></div>`;1273 }12741275 function latencyChart(series) {1276 const W = 340, H = 130, PL = 38, PB = 18, PT = 8;1277 if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';1278 const ok = series.filter(p => p.ok);1279 const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;1280 const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);1281 const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);1282 let d = '', pen = false;1283 const pts = [];1284 for (const p of series) {1285 if (!p.ok) { pen = false; continue; }1286 const px = x(p.t), py = y(p.ms);1287 d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);1288 pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });1289 pen = true;1290 }1291 const failMarks = series.filter(p => !p.ok).map(p =>1292 `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');1293 const gridLines = [0.25, 0.5, 0.75, 1].map(f => {1294 const gy = y(max * f / 1.15);1295 return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>1296 <text x="${PL - 5}" y="${gy + 3.5}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${Math.round(max * f / 1.15)}</text>`;1297 }).join('');1298 const lastPt = pts[pts.length - 1];1299 const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>1300 <text x="${Math.min(lastPt.x, W - 40)}" y="${Math.max(lastPt.y - 8, 10)}" font-size="10" font-weight="800" fill="var(--ink)" font-family="var(--mono)">${lastPt.ms} ms</text>` : '';1301 const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);1302 const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';1303 return `<div style="position:relative">1304 <svg class="chart lat-chart" viewBox="0 0 ${W} ${H}" data-pts='${JSON.stringify(pts.map(p => ({ x: +p.x.toFixed(1), y: +p.y.toFixed(1), ms: p.ms, t: p.t })))}'>1305 ${gridLines}1306 <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>1307 ${failMarks}${lastLbl}1308 <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>1309 <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>1310 </svg></div>`;1311 }1312 function attachChartTips() {1313 document.querySelectorAll('.lat-chart').forEach((svg) => {1314 const pts = JSON.parse(svg.dataset.pts || '[]');1315 if (!pts.length) return;1316 const wrap = svg.parentElement;1317 let tip = null;1318 const show = (clientX) => {1319 const rect = svg.getBoundingClientRect();1320 const vx = (clientX - rect.left) / rect.width * 340;1321 let best = pts[0];1322 for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;1323 if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }1324 tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;1325 tip.style.left = (best.x / 340 * rect.width) + 'px';1326 tip.style.top = (best.y / 130 * rect.height) + 'px';1327 };1328 svg.addEventListener('pointerdown', (e) => show(e.clientX));1329 svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });1330 svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });1331 });1332 }13331334 // rafraîchissement périodique de l'onglet éco (en plus du push)1335 setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);1336 // retour au premier plan (iOS coupe le WS quand l'écran se verrouille) :1337 // reconnexion IMMÉDIATE + resynchronisation de la conversation en cours1338 document.addEventListener('visibilitychange', () => {1339 if (document.hidden) return;1340 if (!ws || ws.readyState !== 1) {1341 wsRetry = 500;1342 try { ws && ws.close(); } catch {}1343 connect();1344 } else if (currentChat) {1345 send({ type: 'open', chatId: currentChat.id });1346 }1347 if (curView === 'eco') loadEco();1348 });1349 window.addEventListener('online', () => { wsRetry = 500; if (!ws || ws.readyState !== 1) connect(); });13501351 // ---------- feuille ----------1352 function openSheet(html) {1353 $('sheetBody').innerHTML = html;1354 $('sheet').classList.remove('hidden');1355 }1356 function closeSheet() { $('sheet').classList.add('hidden'); }1357 $('sheetBack').onclick = closeSheet;13581359 // ---------- réglages ----------1360 function refreshSettingsView() {1361 document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));1362 document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));1363 $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';1364 $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';1365 $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';1366 }1367 document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });1368 document.querySelectorAll('#segPerm button').forEach(b => {1369 b.onclick = () => {1370 if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;1371 applyOpts({ permMode: b.dataset.v });1372 };1373 });1374 $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };1375 $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };1376 $('logoutBtn').onclick = async () => {1377 wsWanted = false;1378 await fetch('/api/logout', { method: 'POST' });1379 location.reload();1380 };13811382 // ---------- login / init ----------1383 async function init() {1384 const me = await fetch('/api/me');1385 if (!me.ok) {1386 $('login').classList.remove('hidden');1387 $('app').classList.add('hidden');1388 return;1389 }1390 const info = await me.json();1391 $('setHost').textContent = (info.node || 'M3U96a') + ':3300';1392 $('login').classList.add('hidden');1393 $('app').classList.remove('hidden');1394 const pr = await fetch('/api/projects');1395 const { projects } = await pr.json();1396 projectSel.innerHTML = '';1397 const allOpt = document.createElement('option');1398 allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + projects.length + ')';1399 projectSel.appendChild(allOpt);1400 for (const p of projects) {1401 const o = document.createElement('option');1402 o.value = p.id; o.textContent = p.name + ' · ' + p.node;1403 projectSel.appendChild(o);1404 }1405 if (settings.projectId) projectSel.value = settings.projectId;1406 projectSel.onchange = () => {1407 settings.projectId = projectSel.value; saveStore(); refreshChips();1408 inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…'1409 : projectSel.value === 'ORCH' ? 'Tâche multi-sites — 1 session qui coordonne tout…'1410 : 'Demander à Claude Code…';1411 };1412 refreshChips();1413 refreshSettingsView();1414 // reprise automatique : on rouvre la dernière conversation (historique +1415 // live si une tâche tourne encore) même après fermeture complète de l'app1416 if (settings.lastChatId) currentChat = { id: settings.lastChatId };1417 connect();1418 }1419 $('loginBtn').onclick = doLogin;1420 $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });1421 async function doLogin() {1422 const r = await fetch('/api/login', {1423 method: 'POST',1424 headers: { 'Content-Type': 'application/json' },1425 body: JSON.stringify({ password: $('password').value }),1426 });1427 if (r.ok) { $('password').value = ''; init(); }1428 else {1429 const d = await r.json().catch(() => ({}));1430 $('loginError').textContent = d.error || 'Erreur';1431 }1432 }14331434 init();1435})();1436