SPB Git

spb/admin-ka

Public
82.0 KB · 1,589 lines javascript
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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', 'visitors', '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' && window.__loadGallery) window.__loadGallery();66    if (name === 'visitors' && window.startVisitors) window.startVisitors();67    if (name === 'settings') refreshSettingsView();68  }69  document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });7071// ===== VISITORS_TAB =====72(function(){73  const $ = (id) => document.getElementById(id);74  let timer=null;75  function nf(n){ return (n||0).toLocaleString('fr-CA').replace(/,/g,' '); }76  function card(s){77    return '<div class="vis-card" data-site="'+s.site+'" data-label="'+(s.label||s.site)+'">'78      + '<div class="vis-h"><img src="/icons/'+s.site+'"><div class="vis-name">'+(s.label||s.site)+'</div><span class="vis-dot'+(s.online>0?' on':'')+'"></span></div>'79      + '<div class="vis-online"><span class="vis-dot big'+(s.online>0?' on':'')+'"></span><span class="vis-onN">'+nf(s.online)+'</span><span class="vis-onL">en ligne</span></div>'80      + '<div class="vis-row">'81        + '<div class="vis-m"><div class="vm-n">'+nf(s.today)+'</div><div class="vm-l">jour</div></div>'82        + '<div class="vis-m"><div class="vm-n">'+nf(s.d7)+'</div><div class="vm-l">7j</div></div>'83        + '<div class="vis-m"><div class="vm-n">'+nf(s.d30)+'</div><div class="vm-l">30j</div></div>'84      + '</div>'85      + '<div class="vis-pv">'+nf(s.pv_today)+' pages vues · voir détail →</div></div>';86  }87  async function loadVisitors(){88    try{89      const d=await (await fetch('/api/analytics/summary')).json(); const g=d.global||{};90      if($('visLive')) $('visLive').innerHTML='<span class="vis-dot on"></span> '+nf(g.online)+' en ligne · '+nf(g.today)+' aujourd&#39;hui';91      if($('visGlobal')) $('visGlobal').innerHTML=92        '<div class="vg-item"><div class="vg-n">'+nf(g.online)+'</div><div class="vg-l">en ligne</div></div>'93       +'<div class="vg-item"><div class="vg-n">'+nf(g.today)+'</div><div class="vg-l">aujourd&#39;hui</div></div>'94       +'<div class="vg-item"><div class="vg-n">'+nf(g.d7)+'</div><div class="vg-l">7 jours</div></div>'95       +'<div class="vg-item"><div class="vg-n">'+nf(g.pv_today)+'</div><div class="vg-l">pages vues</div></div>';96      if($('visGrid')){ $('visGrid').innerHTML=(d.sites||[]).map(card).join('');97        $('visGrid').querySelectorAll('.vis-card').forEach(function(c){ c.onclick=function(){ openDetail(c.dataset.site,c.dataset.label); }; }); }98    }catch(e){}99  }100  function chart(series){101    var w=680,h=240,pad=30; var vals=series.map(function(x){return x.uniques;});102    var max=Math.max(1,Math.max.apply(null,vals)); var n=series.length;103    var X=function(i){return pad+(w-2*pad)*(n<=1?0:i/(n-1));};104    var Y=function(v){return h-pad-(h-2*pad)*(v/max);};105    var pts=series.map(function(x,i){return X(i)+','+Y(x.uniques);}).join(' ');106    var area='M'+X(0)+','+(h-pad)+' L'+pts.replace(/ /g,' L')+' L'+X(n-1)+','+(h-pad)+' Z';107    var last=series[n-1]||{uniques:0};108    var dots='<circle cx="'+X(n-1)+'" cy="'+Y(last.uniques)+'" r="6" fill="var(--accent,#f97316)"/>';109    // libellés d'axe : quelques jours110    var lab=''; for(var i=0;i<n;i+=Math.ceil(n/5)){ var dd=series[i].day.slice(5); lab+='<text x="'+X(i)+'" y="'+(h-8)+'" font-size="14" fill="#888" text-anchor="middle">'+dd+'</text>'; }111    return '<svg viewBox="0 0 '+w+' '+h+'" class="vis-chart">'112      +'<defs><linearGradient id="ga" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--accent,#f97316)" stop-opacity=".45"/><stop offset="1" stop-color="var(--accent,#f97316)" stop-opacity="0"/></linearGradient></defs>'113      +'<path d="'+area+'" fill="url(#ga)"/>'114      +'<polyline points="'+pts+'" fill="none" stroke="var(--accent,#f97316)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>'115      +dots+lab116      +'<text x="'+pad+'" y="20" font-size="15" fill="#999">max '+max+'/j</text></svg>';117  }118  async function openDetail(site,label){119    var ov=$('visDetail');120    if(!ov){ ov=document.createElement('div'); ov.id='visDetail'; document.body.appendChild(ov); }121    ov.innerHTML='<div class="vd-box"><div class="vd-head"><img src="/icons/'+site+'"><h3>'+label+'</h3><button id="vdClose">✕</button></div><div class="vd-body"><div class="soc-sub">Chargement…</div></div></div>';122    ov.classList.add('on'); $('vdClose').onclick=function(){ ov.classList.remove('on'); };123    ov.onclick=function(e){ if(e.target===ov) ov.classList.remove('on'); };124    try{125      var sm=await (await fetch('/api/analytics/summary')).json();126      var s=(sm.sites||[]).filter(function(x){return x.site===site;})[0]||{};127      var se=(await (await fetch('/api/analytics/series/'+site)).json()).series||[];128      var ge=(await (await fetch('/api/analytics/geo/'+site)).json()).geo||[];129      var maxg=Math.max(1,Math.max.apply(null,ge.map(function(x){return x.c;})||[1]));130      var geoHtml = ge.length ? ge.map(function(x){return '<div class="vd-geo"><span class="vd-geoL">'+x.label+'</span><div class="vd-bar"><div style="width:'+Math.round(100*x.c/maxg)+'%"></div></div><span class="vd-geoN">'+nf(x.c)+'</span></div>';}).join('') : '<div class="soc-sub">Emplacements en cours de géolocalisation…</div>';131      ov.querySelector('.vd-body').innerHTML=132        '<div class="vd-kpis">'133          +'<div class="vd-k"><div class="vd-kn">'+nf(s.online)+'</div><div class="vd-kl">en ligne</div></div>'134          +'<div class="vd-k"><div class="vd-kn">'+nf(s.today)+'</div><div class="vd-kl">aujourd&#39;hui</div></div>'135          +'<div class="vd-k"><div class="vd-kn">'+nf(s.d7)+'</div><div class="vd-kl">7 jours</div></div>'136          +'<div class="vd-k"><div class="vd-kn">'+nf(s.d30)+'</div><div class="vd-kl">30 jours</div></div>'137        +'</div>'138        +'<div class="vd-title">Visiteurs uniques · 30 jours</div>'+chart(se)139        +'<div class="vd-title">D&#39;où viennent les visiteurs</div>'+geoHtml;140    }catch(e){ ov.querySelector('.vd-body').innerHTML='<div class="soc-sub">Erreur : '+e.message+'</div>'; }141  }142  window.startVisitors=function(){ loadVisitors(); clearInterval(timer); timer=setInterval(function(){ if(curView==='visitors') loadVisitors(); },15000); };143})();144145146147148  // ---------- Écran du nœud (noVNC embarqué) ----------149  let _rfb = null;150  (function bindVNC(){151    const connect = document.getElementById('vncConnect');152    const disc = document.getElementById('vncDisconnect');153    const statusEl = document.getElementById('vncStatus');154    const panel = document.getElementById('vncPanel');155    if (!connect) return;156    connect.onclick = async () => {157      if (_rfb) { statusEl.textContent = 'déjà connecté'; return; }158      statusEl.textContent = 'connexion…';159      try {160        const pw = await (await fetch('/api/social/vncpw')).json();161        const RFB = window.KA_RFB; if (!RFB) { statusEl.textContent = 'noVNC pas encore chargé — rafraîchis la page'; return; }162        const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws';163        panel.style.display = 'block';164        _rfb = new RFB(panel, url, { credentials: { password: pw.password } });165        _rfb.scaleViewport = true;166        _rfb.resizeSession = false;167        _rfb.background = '#0d0d0d';168        _rfb.addEventListener('connect', () => { statusEl.textContent = '✅ connecté — tu peux cliquer dans l\'écran'; });169        _rfb.addEventListener('disconnect', (e) => { statusEl.textContent = 'déconnecté'; _rfb = null; panel.style.display='none'; });170        _rfb.addEventListener('credentialsrequired', () => { _rfb.sendCredentials({ password: pw.password }); });171      } catch (e) { statusEl.textContent = 'échec : ' + e.message; }172    };173    disc.onclick = () => { if (_rfb) { try { _rfb.disconnect(); } catch {} _rfb = null; } panel.style.display='none'; statusEl.textContent='déconnecté'; };174  })();175176  // ---------- Social ----------177  let socDraft = null;178  async function loadSocial() {179    try {180      const r = await fetch('/api/social/state'); const d = await r.json();181      renderSocialState(d.state); renderSocialLog(d.log || []);182    } catch {}183  }184  function renderSocialState(st) {185    if (!st) return;186    const tog = $('socAutoToggle'); if (tog) tog.checked = !!st.auto;187    const nx = $('socNext');188    if (nx) {189      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)';190      else nx.textContent = st.auto ? 'Automatique activé' : 'Automatique désactivé';191    }192  }193  function renderSocialLog(log) {194    const el = $('socLog'); if (!el) return;195    if (!log.length) { el.innerHTML = '<div class="soc-sub">Aucun événement.</div>'; return; }196    el.innerHTML = log.map(function (e) {197      const t = new Date(e.ts).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });198      let msg = e.kind;199      if (e.kind === 'published') msg = '📣 Publié — ' + (e.site || e.trigger || '') + (e.headline ? ' · ' + e.headline : '');200      else if (e.kind === 'cycle_error') msg = '⚠️ Erreur — ' + (e.error || '');201      else if (e.kind === 'cycle_skip') msg = '⏸️ Ignoré — ' + (e.reason || '');202      else if (e.kind === 'auto_on') msg = '▶️ Automatique activé';203      else if (e.kind === 'auto_off') msg = '⏹️ Automatique désactivé';204      else if (e.kind === 'cycle_start') msg = '… cycle démarré (' + (e.trigger || '') + ')';205      return '<div class="soc-logrow"><span class="soc-logt">' + t + '</span> ' + esc(msg) + '</div>';206    }).join('');207  }208  function bindSocial() {209    const tog = $('socAutoToggle');210    if (tog) tog.onchange = async () => {211      const r = await fetch('/api/social/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on: tog.checked }) });212      const d = await r.json(); renderSocialState(d.state);213      banner(tog.checked ? 'good' : 'muted', tog.checked ? 'Publication automatique activée' : 'Publication automatique désactivée');214    };215    const gen = $('socGenerate');216    if (gen) gen.onclick = doGenerate;217    const rg = $('socRegen'); if (rg) rg.onclick = doGenerate;218    const pub = $('socPublish');219    if (pub) pub.onclick = async () => {220      if (!socDraft) return;221      pub.disabled = true; pub.textContent = 'Publication…';222      try {223        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() }) });224        const d = await r.json();225        if (d.ok) { banner('good', '📣 Post publié sur la Page Groupe-KA'); $('socDraft').classList.add('hidden'); socDraft = null; loadSocial(); }226        else banner('bad', 'Échec : ' + (d.error || '?'));227      } catch (e) { banner('bad', 'Échec : ' + e.message); }228      pub.disabled = false; pub.textContent = '📣 Publier ce post';229    };230    const run = $('socRunNow');231    if (run) run.onclick = async () => {232      run.disabled = true; run.textContent = 'Publication…';233      try {234        const r = await fetch('/api/social/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });235        const d = await r.json();236        banner(d.ok ? 'good' : 'bad', d.ok ? ('📣 Publié : ' + (d.insight ? d.insight.site : '')) : ('Échec : ' + (d.error || d.reason || '?')));237        loadSocial();238      } catch (e) { banner('bad', 'Échec : ' + e.message); }239      run.disabled = false; run.textContent = '⚡ Publier maintenant';240    };241    const chk = $('socCheckLogin');242    if (chk) chk.onclick = async () => {243      const el = $('socLogin'); el.textContent = 'Vérification…';244      try { const r = await fetch('/api/social/login'); const d = await r.json();245        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)));246      } catch (e) { el.textContent = 'Erreur : ' + e.message; }247    };248  }249  let socReelDraft = null;250  (function bindReel(){251    const gen = document.getElementById('socReelGen');252    if (gen) gen.onclick = async () => {253      const prev = gen.textContent; gen.disabled = true; gen.textContent = 'Génération vidéo…';254      try {255        const r = await fetch('/api/social/reel/generate', { method:'POST', headers:{'Content-Type':'application/json'},256          body: JSON.stringify({ site: document.getElementById('socReelSite').value, prompt: document.getElementById('socReelPrompt').value }) });257        const d = await r.json();258        if (d.draft) { socReelDraft = d.draft;259          document.getElementById('socReelVid').src = d.draft.videoUrl + '?t=' + Date.now();260          document.getElementById('socReelCaption').value = d.draft.caption;261          document.getElementById('socReelDl').href = d.draft.videoUrl;262          document.getElementById('socReelDraft').classList.remove('hidden');263        } else banner('bad', 'Échec : ' + (d.error||'?'));264      } catch(e){ banner('bad','Échec : '+e.message); }265      gen.disabled = false; gen.textContent = prev;266    };267    const pub = document.getElementById('socReelPublish');268    if (pub) pub.onclick = async () => {269      if (!socReelDraft) return;270      pub.disabled = true; pub.textContent = 'Publication…';271      banner('muted', '🎬 Publication du reel — termine le « Suivant » sur l\'écran distant du nœud si demandé', null, 9000);272      try {273        const r = await fetch('/api/social/reel/publish', { method:'POST', headers:{'Content-Type':'application/json'},274          body: JSON.stringify({ caption: document.getElementById('socReelCaption').value, video: socReelDraft.video.split('/').pop() }) });275        const d = await r.json();276        banner(d.ok?'good':'bad', d.ok?'📣 Reel publié':'Échec : '+(d.error||'?'));277        if (d.ok) { document.getElementById('socReelDraft').classList.add('hidden'); socReelDraft=null; loadSocial(); }278      } catch(e){ banner('bad','Échec : '+e.message); }279      pub.disabled = false; pub.textContent = '📣 Publier (assisté)';280    };281  })();282283  async function doGenerate() {284    const gen = $('socGenerate'); const prev = gen.textContent;285    gen.disabled = true; gen.textContent = 'Génération…';286    try {287      const r = await fetch('/api/social/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ site: $('socSite').value, prompt: $('socPrompt').value }) });288      const d = await r.json();289      if (d.draft) {290        socDraft = d.draft;291        $('socImg').src = d.draft.imageUrl + '?t=' + Date.now();292        $('socCaption').value = d.draft.caption;293        $('socDraft').classList.remove('hidden');294      } else banner('bad', 'Échec : ' + (d.error || '?'));295    } catch (e) { banner('bad', 'Échec : ' + e.message); }296    gen.disabled = false; gen.textContent = prev;297  }298299  bindSocial();300  function navBadge(view) {301    if (curView === view) return;302    const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);303    if (b) b.classList.remove('hidden');304  }305306  // ---------- bannières ----------307  function banner(kind, html, onclick, ttl = 6000) {308    const b = el('div', 'banner ' + kind, html);309    if (onclick) b.onclick = () => { onclick(); b.remove(); };310    $('banners').appendChild(b);311    if (navigator.vibrate) navigator.vibrate(60);312    setTimeout(() => b.remove(), ttl);313  }314315  // ---------- scroll / messages ----------316  function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }317  function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }318  function addMsg(node) {319    $('welcome').classList.add('hidden');320    const wasNear = nearBottom();321    const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node);322    scrollBottom(wasNear);323  }324  function renderMd(container, text) {325    container.innerHTML = marked.parse(text || '');326    container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });327  }328  // bouton copier sur tous les blocs de code (délégué, marche pour tout <pre>)329  messagesEl.addEventListener('click', (e) => {330    const pre = e.target.closest('pre');331    if (!pre || e.target.closest('button') || pre.dataset.copying) return;332    if (window.getSelection().toString()) return;333    navigator.clipboard.writeText(pre.textContent).then(() => {334      pre.dataset.copying = '1';335      const tag = el('span', 'copied-tag', 'copié ✓');336      pre.appendChild(tag);337      setTimeout(() => { tag.remove(); delete pre.dataset.copying; }, 1200);338    }).catch(() => {});339  });340  function truncPre(text, limit = 3000) {341    const pre = el('pre');342    const full = String(text ?? '');343    if (full.length <= limit) { pre.textContent = full; return pre; }344    pre.textContent = full.slice(0, limit);345    const wrap = el('div');346    const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');347    btn.onclick = () => { pre.textContent = full; btn.remove(); };348    wrap.append(pre, btn);349    return wrap;350  }351352  function workingEl() { return messagesEl.querySelector('.working'); }353  let workTimer = null, workStart = 0;354  function setRunning(r) {355    isRunning = r;356    sendBtn.classList.toggle('hidden', r);357    stopBtn.classList.toggle('hidden', !r);358    let w = workingEl();359    if (r && !w) {360      $('welcome').classList.add('hidden');361      w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span><b class="work-time"></b>');362      messagesEl.appendChild(w);363      scrollBottom(true);364      workStart = Date.now();365      clearInterval(workTimer);366      workTimer = setInterval(() => {367        const t = w.querySelector('.work-time');368        if (!t) { clearInterval(workTimer); return; }369        const s = Math.floor((Date.now() - workStart) / 1000);370        t.textContent = s >= 60 ? Math.floor(s / 60) + ' min ' + (s % 60) + ' s' : s + ' s';371      }, 1000);372    } else if (!r && w) { w.remove(); clearInterval(workTimer); }373    if (!r) {374      finalizeLive(); finalizeThink();375      flushQueued();376    }377  }378  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' };379  function setWorkingLabel(name, sub) {380    const w = workingEl(); if (!w) return;381    const span = w.querySelector('span'); if (!span) return;382    const verb = WORK_VERB[name] || 'utilise ' + name;383    const detail = sub ? ' ' + sub.slice(0, 46) : '';384    span.textContent = `Claude ${verb}${detail}…`;385  }386  // file d'attente : un message tapé pendant l'exécution part dès que Claude a fini387  let queuedPrompt = null;388  function flushQueued() {389    if (!queuedPrompt) return;390    const q = queuedPrompt; queuedPrompt = null;391    const n = messagesEl.querySelector('.queued-notice'); if (n) n.remove();392    doSend(q);393  }394395  // ---------- streaming live ----------396  function ensureLive() {397    if (live) return live;398    const w = workingEl(); if (w) { const s = w.querySelector('span'); if (s) s.textContent = 'Claude rédige…'; }399    const wrap = el('div', 'msg-claude');400    const md = el('div', 'md');401    wrap.appendChild(md);402    addMsg(wrap);403    live = { el: wrap, textEl: md, text: '' };404    return live;405  }406  function finalizeLive() { if (live) { live.el.remove(); live = null; } }407  function ensureThink() {408    if (liveThink) return liveThink;409    const box = el('div', 'thinking');410    addMsg(box);411    liveThink = { el: box, text: '' };412    return liveThink;413  }414  function finalizeThink() {415    if (!liveThink) return;416    const d = el('details', 'thinking-box', '<summary>réflexion</summary>');417    const inner = el('div', 'thinking'); inner.textContent = liveThink.text;418    d.appendChild(inner);419    liveThink.el.replaceWith(d);420    liveThink = null;421  }422423  // ---------- cartes outils ----------424  const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };425  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' };426  function toolSubtitle(name, input) {427    input = input || {};428    if (name === 'Bash') return input.command || '';429    if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/');430    if (input.pattern) return input.pattern;431    if (input.url) return input.url;432    if (name === 'Task') return input.description || '';433    if (name === 'TodoWrite') return 'liste de tâches';434    return '';435  }436  function diffBlock(oldStr, newStr) {437    const d = el('div', 'diff');438    for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l)));439    for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l)));440    return d;441  }442  function toolBody(name, input) {443    const body = el('div', 'tool-body');444    input = input || {};445    if (name === 'Bash') {446      body.appendChild(el('div', 'label', 'commande'));447      body.appendChild(truncPre(input.command));448      if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description)));449    } else if (name === 'Edit') {450      body.appendChild(diffBlock(input.old_string, input.new_string));451    } else if (name === 'MultiEdit') {452      for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string));453    } else if (name === 'Write') {454      body.appendChild(el('div', 'label', 'contenu'));455      const d = el('div', 'diff');456      const lines = String(input.content || '').split('\n');457      for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l)));458      if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus'));459      body.appendChild(d);460    } else if (name === 'TodoWrite') {461      const ul = el('ul', 'todos');462      for (const t of input.todos || []) {463        const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';464        ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));465      }466      body.appendChild(ul);467    } else if (name === 'Task') {468      body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));469    } else if (name === 'ExitPlanMode') {470      const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);471    } else {472      body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));473    }474    return body;475  }476  // outils dont on ouvre la carte par défaut (on veut VOIR ce qui est fait)477  const OPEN_BY_DEFAULT = new Set(['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'ExitPlanMode']);478  function addToolCard(block) {479    finalizeLive(); finalizeThink();480    const name = block.name || '?';481    const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name;482    const card = el('details', 'tool');483    if (OPEN_BY_DEFAULT.has(name)) card.open = true;484    const sum = el('summary');485    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">●&nbsp;en cours</span>`;486    card.appendChild(sum);487    card.appendChild(toolBody(name, block.input));488    addMsg(card);489    toolCards.set(block.id, { card, name, stateEl: sum.querySelector('.t-state'), subEl: sum.querySelector('.t-sub'), bodyEl: card.querySelector('.tool-body') });490    // met à jour l'indicateur « en cours » avec l'action réelle491    setWorkingLabel(name, toolSubtitle(name, block.input));492  }493  function attachToolResult(block) {494    const t = toolCards.get(block.tool_use_id);495    if (!t) return;496    t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok');497    t.stateEl.textContent = block.is_error ? '✗ erreur' : '✓';498    let text = '';499    if (typeof block.content === 'string') text = block.content;500    else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');501    text = text.trim();502    if (text) {503      // les cartes repliées (Read/Grep/…) montrent un aperçu du résultat dans l'en-tête504      if (!t.card.open && t.subEl) {505        const firstLine = text.split('\n').find(l => l.trim()) || '';506        t.subEl.textContent = firstLine.slice(0, 80);507      }508      t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));509      t.bodyEl.appendChild(truncPre(text));510    } else {511      t.bodyEl.appendChild(el('div', 'meta-line', block.is_error ? 'erreur (vide)' : 'terminé sans sortie'));512    }513  }514515  // ---------- permissions ----------516  function addPermCard(ev) {517    finalizeLive(); finalizeThink();518    const card = el('div', 'perm');519    const isPlan = ev.tool === 'ExitPlanMode';520    card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;521    if (isPlan && ev.input?.plan) {522      const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);523    } else {524      const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);525    }526    const actions = el('div', 'perm-actions');527    const deny = el('button', 'perm-deny', 'Refuser');528    const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');529    actions.append(deny, allow);530    if (!isPlan) {531      const always = el('button', 'perm-always', 'Toujours<br><small>cet outil</small>');532      always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });533      actions.insertBefore(always, allow);534      // « Tout autoriser » : le serveur approuve TOUTES les permissions de la535      // session — la tâche va au bout même téléphone rangé536      const all = el('button', 'perm-allall', '🔓 Tout autoriser — finir la tâche sans redemander');537      all.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, allowAll: true });538      card.appendChild(actions);539      card.appendChild(all);540    } else {541      card.appendChild(actions);542    }543    deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });544    allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });545    addMsg(card);546    permCards.set(ev.requestId, card);547    scrollBottom(true);548    if (navigator.vibrate) navigator.vibrate([60, 60, 60]);549  }550  function resolvePermCard(ev) {551    const card = permCards.get(ev.requestId);552    if (!card) return;553    const actions = card.querySelector('.perm-actions');554    if (actions) actions.remove();555    const allBtn = card.querySelector('.perm-allall');556    if (allBtn) allBtn.remove();557    card.appendChild(el('div', 'perm-resolved',558      ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));559  }560561  // ---------- rendu des événements ----------562  function renderEvent(ev) {563    if (ev.type === 'user_prompt') {564      finalizeLive(); finalizeThink();565      const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b);566      return;567    }568    if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }569    if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }570    if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }571    if (ev.type === 'permission_request') { addPermCard(ev); return; }572    if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }573    if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }574    if (ev.type === 'claude') { renderClaude(ev.event); return; }575  }576577  function renderClaude(ev) {578    if (ev.type === 'system') {579      if (ev.subtype === 'init') {580        addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));581      } else if (ev.subtype === 'compact_boundary') {582        addMsg(el('div', 'meta-line', '⇣ contexte compacté'));583      } else if (ev.subtype && ev.subtype !== 'hook') {584        addMsg(el('div', 'meta-line', esc('⌁ ' + ev.subtype)));585      }586      return;587    }588    if (ev.type === 'stream_event' && ev.event) {589      const se = ev.event;590      if (se.type === 'content_block_delta' && se.delta) {591        if (se.delta.type === 'text_delta') {592          const l = ensureLive();593          l.text += se.delta.text;594          renderMd(l.textEl, l.text);595          scrollBottom();596        } else if (se.delta.type === 'thinking_delta') {597          const t = ensureThink();598          t.text += se.delta.thinking;599          t.el.textContent = t.text.slice(-600);600          scrollBottom();601        }602      }603      return;604    }605    if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {606      for (const block of ev.message.content) {607        if (block.type === 'text' && block.text && block.text.trim()) {608          finalizeLive(); finalizeThink();609          const wrap = el('div', 'msg-claude');610          const md = el('div', 'md');611          renderMd(md, block.text);612          wrap.appendChild(md);613          addMsg(wrap);614        } else if (block.type === 'tool_use') {615          addToolCard(block);616        } else if (block.type === 'thinking' && block.thinking) {617          if (liveThink) { liveThink.el.remove(); liveThink = null; }618          const d = el('details', 'thinking-box', '<summary>réflexion</summary>');619          const inner = el('div', 'thinking'); inner.textContent = block.thinking;620          d.appendChild(inner);621          addMsg(d);622        }623      }624      return;625    }626    if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {627      for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);628      return;629    }630    if (ev.type === 'result') {631      finalizeLive(); finalizeThink();632      const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : '';633      const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : '';634      const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : '';635      addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns)));636      if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result))));637      return;638    }639  }640641  // ---------- méta de session (chips + bandeau) ----------642  function refreshChips() {643    const model = currentChat ? currentChat.model : settings.model;644    const perm = currentChat ? currentChat.permMode : settings.permMode;645    $('chipModel').textContent = MODEL_LABEL[model] || model;646    $('chipPerm').textContent = PERM_LABEL[perm] || perm;647    $('chipPerm').classList.toggle('warn', perm === 'bypass');648    $('chipModel').classList.add('orange');649    projectSel.classList.toggle('all-mode', !currentChat && projectSel.value === 'ALL');650    const meta = $('sessMeta');651    if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {652      meta.classList.remove('hidden');653      $('metaModel').textContent = (currentChat.autoAllowAll ? '🔓 ' : '') + (currentChat.lastModel || MODEL_LABEL[model]);654      $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';655      $('metaCost').textContent = fmt$(currentChat.totalCost);656      $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok (' + Math.min(99, Math.round(currentChat.contextTokens / 2000)) + '%)' : '';657    } else meta.classList.add('hidden');658  }659660  // ---------- WebSocket ----------661  function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }662  function setConn(state) {663    const pill = $('connPill');664    pill.className = 'conn-pill ' + state;665    $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';666  }667  function connect() {668    if (!wsWanted) return;669    setConn('mid');670    const proto = location.protocol === 'https:' ? 'wss' : 'ws';671    ws = new WebSocket(`${proto}://${location.host}/ws`);672    ws.onopen = () => {673      setConn('on');674      wsRetry = 1000;675      if (currentChat) send({ type: 'open', chatId: currentChat.id });676      if (curView === 'eco') send({ type: 'eco_sub' });677    };678    ws.onclose = () => {679      setConn('off');680      setTimeout(connect, wsRetry);681      wsRetry = Math.min(wsRetry * 1.6, 20000);682    };683    ws.onmessage = (e) => {684      let msg;685      try { msg = JSON.parse(e.data); } catch { return; }686      handleServer(msg);687    };688  }689  function rememberChat(id) { settings.lastChatId = id; saveStore(); }690  function handleServer(msg) {691    if (msg.type === 'batch_created') {692      banner('good', `🌐 Lancé sur ${msg.count}/${msg.total} apps — suis-les dans Sessions`, () => switchView('sessions'), 6000);693      switchView('sessions');694      return;695    }696    if (msg.type === 'chat_created') { currentChat = msg.chat; rememberChat(msg.chat.id); refreshChips(); return; }697    if (msg.type === 'chat_meta') {698      if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }699      return;700    }701    if (msg.type === 'history') {702      currentChat = msg.chat;703      rememberChat(msg.chat.id);704      clearConversation(true);705      for (const ev of msg.events) { try { renderEvent(ev); } catch {} }706      setRunning(msg.running);707      for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;708      refreshChips();709      scrollBottom(true);710      return;711    }712    if (msg.type === 'notify') {713      if (msg.kind === 'done') {714        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {715          banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));716          navBadge('chat'); navBadge('sessions');717        }718      } else if (msg.kind === 'perm') {719        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {720          banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);721          navBadge('chat');722        }723      }724      return;725    }726    if (msg.type === 'eco_alert') {727      if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);728      else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));729      navBadge('eco');730      if (curView === 'eco') loadEco();731      return;732    }733    if (msg.type === 'eco') {734      if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);735      return;736    }737    renderEvent(msg);738  }739740  function clearConversation(keepChat) {741    messagesEl.innerHTML = '';742    toolCards.clear(); permCards.clear();743    live = null; liveThink = null;744    if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');745  }746  function openChat(chatId) {747    switchView('chat');748    currentChat = { id: chatId };749    clearConversation(true);750    send({ type: 'open', chatId });751  }752753  // ---------- composer ----------754  function doSend(text) {755    send({756      type: 'start',757      chatId: currentChat ? currentChat.id : null,758      project: projectSel.value,759      model: currentChat ? currentChat.model : settings.model,760      permMode: currentChat ? currentChat.permMode : settings.permMode,761      prompt: text,762    });763  }764  function sendPrompt() {765    const text = inputEl.value.trim();766    if (!text) return;767    // commandes slash locales768    if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }769    if (text === '/cost') {770      if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));771      inputEl.value = ''; autoGrow(); return;772    }773    if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }774    inputEl.value = '';775    autoGrow();776    hideSlash();777    // diffusion à toutes les apps KA778    if (!currentChat && projectSel.value === 'ALL') { broadcastToAll(text); return; }779    if (isRunning) {780      // Claude travaille : le message part automatiquement dès la fin du tour781      queuedPrompt = queuedPrompt ? queuedPrompt + '\n' + text : text;782      let n = messagesEl.querySelector('.queued-notice');783      if (!n) { n = el('div', 'notice-line queued-notice'); messagesEl.appendChild(n); }784      n.textContent = '⏳ en attente de la fin du tour : « ' + queuedPrompt.slice(0, 80) + ' »';785      scrollBottom(true);786      return;787    }788    doSend(text);789  }790  function broadcastToAll(text) {791    if (!confirm('Lancer ce prompt sur TOUTES les apps du Groupe KA en parallèle (une session par app) ?')) return;792    send({ type: 'start_all', prompt: text, model: settings.model, permMode: settings.permMode });793  }794  sendBtn.onclick = sendPrompt;795  stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };796  inputEl.addEventListener('keydown', (e) => {797    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }798  });799  function autoGrow() {800    inputEl.style.height = 'auto';801    inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';802  }803  inputEl.addEventListener('input', () => {804    autoGrow();805    if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();806  });807  inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));808809  function showSlash() {810    const h = $('slashHint');811    h.classList.remove('hidden');812    if (!h.childElementCount) {813      for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {814        const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);815        b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };816        h.appendChild(b);817      }818    }819  }820  function hideSlash() { $('slashHint').classList.add('hidden'); }821822  document.querySelectorAll('.hint').forEach((h) => {823    h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };824  });825826  // ---------- upgradeur de prompt (Opus 4.8 + savoir Groupe KA) ----------827  const upgradeBtn = $('upgradeBtn');828  let upgrading = false;829  upgradeBtn.onclick = async () => {830    const raw = inputEl.value.trim();831    if (!raw || upgrading) return;832    upgrading = true;833    upgradeBtn.classList.add('busy');834    const original = inputEl.value;835    inputEl.disabled = true;836    inputEl.value = '✨ amélioration du prompt avec Opus 4.8…';837    try {838      const r = await fetch('/api/upgrade-prompt', {839        method: 'POST',840        headers: { 'Content-Type': 'application/json' },841        body: JSON.stringify({ prompt: raw, project: projectSel.value }),842      });843      const d = await r.json();844      if (r.ok && d.upgraded) {845        inputEl.value = d.upgraded;846        if (navigator.vibrate) navigator.vibrate(40);847      } else {848        inputEl.value = original;849        banner('bad', '✗ Upgrade impossible : ' + esc(d.error || 'erreur'), null, 5000);850      }851    } catch (e) {852      inputEl.value = original;853      banner('bad', '✗ Upgrade impossible (réseau)', null, 5000);854    } finally {855      inputEl.disabled = false;856      upgrading = false;857      upgradeBtn.classList.remove('busy');858      autoGrow();859      inputEl.focus();860    }861  };862863  // ---------- chips ----------864  $('chipModel').onclick = () => {865    const order = ['fable', 'opus', 'sonnet', 'haiku'];866    const cur = currentChat ? currentChat.model : settings.model;867    const next = order[(order.indexOf(cur) + 1) % order.length];868    applyOpts({ model: next });869  };870  $('chipPerm').onclick = () => {871    const order = ['default', 'acceptEdits', 'plan', 'bypass'];872    const cur = currentChat ? currentChat.permMode : settings.permMode;873    const next = order[(order.indexOf(cur) + 1) % 4];874    if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;875    applyOpts({ permMode: next });876  };877  function applyOpts(opts) {878    if (currentChat) {879      Object.assign(currentChat, opts);880      send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });881    }882    if (opts.model) settings.model = opts.model;883    if (opts.permMode) settings.permMode = opts.permMode;884    saveStore();885    refreshChips();886    refreshSettingsView();887  }888  $('chipMore').onclick = () => {889    const c = currentChat;890    openSheet(`891      <div class="sheet-h"><h3>Session</h3></div>892      <div class="sheet-sec">893        <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>894        <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>895        <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>896        <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>897        <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>898      </div>899      <div class="sheet-sec">900        <div class="set-actions">901          <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>902          <button class="btn-ghost" id="shCompact">/compact</button>903          <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>904        </div>905      </div>`);906    $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };907    $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };908    $('shNew').onclick = () => { closeSheet(); newChat(); };909  };910  function newChat() {911    currentChat = null;912    queuedPrompt = null;913    settings.lastChatId = null; saveStore();914    clearConversation();915    setRunning(false);916    refreshChips();917    switchView('chat');918  }919920  // ---------- sessions ----------921  async function loadSessions() {922    const r = await fetch('/api/chats');923    if (!r.ok) return;924    const { chats } = await r.json();925    const list = $('sessList');926    list.innerHTML = '';927    if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }928    const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };929    for (const c of chats) {930      const card = el('div', 'sess-card');931      card.innerHTML = `932        <div class="sess-top">933          <span class="sess-title">${esc(c.title)}</span>934          <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>935        </div>936        <div class="sess-info">937          ${c.batch ? '<span class="batch-tag">🌐 diffusion</span>' : ''}938          <span>${esc(c.projectName || '')}</span>939          <span>${MODEL_LABEL[c.model] || c.model}</span>940          <span>${fmt$(c.totalCost)}</span>941          <span>${timeAgo(c.updated)}</span>942        </div>`;943      const actions = el('div', 'sess-actions');944      const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');945      open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };946      actions.appendChild(open);947      if (c.state === 'idle') {948        const del = el('button', 'del', 'Supprimer');949        del.onclick = async (e) => {950          e.stopPropagation();951          if (!confirm('Supprimer cette session ?')) return;952          await fetch('/api/chats/' + c.id, { method: 'DELETE' });953          card.remove();954          if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }955        };956        actions.appendChild(del);957      }958      card.appendChild(actions);959      card.onclick = () => openChat(c.id);960      list.appendChild(card);961    }962  }963  $('newSessBtn').onclick = newChat;964965  // ---------- Prompt Bank ----------966  function projectOptionsHTML(selected) {967    return [...projectSel.options].map(o =>968      `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');969  }970  async function loadPrompts() {971    const r = await fetch('/api/prompts');972    if (!r.ok) return;973    const { prompts } = await r.json();974    const list = $('promptList');975    list.innerHTML = '';976    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; }977    for (const pr of prompts) {978      const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';979      const card = el('div', 'sess-card');980      card.innerHTML = `981        <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div>982        <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>983        <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`;984      const actions = el('div', 'sess-actions');985      const sendB = el('button', 'p-send', 'Envoyer →');986      const editB = el('button', '', '✎ Éditer');987      const upB = el('button', '', '✨ Améliorer');988      const delB = el('button', 'del', 'Suppr.');989      actions.append(sendB, upB, editB, delB);990      card.appendChild(actions);991      sendB.onclick = () => openSendSheet(pr);992      editB.onclick = () => openPromptEditor(pr);993      delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); card.remove(); };994      upB.onclick = async () => {995        upB.textContent = '✨ …'; upB.disabled = true;996        try {997          const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: pr.text, project: pr.project }) });998          const d = await rr.json();999          if (rr.ok && d.upgraded) {1000            await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: d.upgraded }) });1001            banner('good', '✨ Prompt amélioré et enregistré', null, 3000);1002            loadPrompts();1003          } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);1004        } catch { banner('bad', '✗ réseau', null, 4000); }1005        upB.textContent = '✨ Améliorer'; upB.disabled = false;1006      };1007      list.appendChild(card);1008    }1009  }1010  $('newPromptBtn').onclick = () => openPromptEditor(null);10111012  function openPromptEditor(pr) {1013    const isNew = !pr;1014    openSheet(`1015      <div class="sheet-h"><h3>${isNew ? 'Nouveau prompt' : 'Éditer le prompt'}</h3></div>1016      <div class="sheet-sec">1017        <input id="peTitle" class="pe-input" placeholder="Titre (optionnel)" value="${esc(pr?.title || '')}">1018        <textarea id="peText" class="pe-textarea" placeholder="Écris ton prompt ici…">${esc(pr?.text || '')}</textarea>1019        <div class="set-label" style="margin-top:10px">App par défaut</div>1020        <select id="peProject" class="pill-select" style="max-width:100%;width:100%">1021          <option value="">— app au choix (demandé à l’envoi) —</option>1022          ${projectOptionsHTML(pr?.project || '')}1023        </select>1024        <div class="set-actions" style="margin-top:12px">1025          <button class="btn-ghost" id="peUpgrade">✨ Améliorer (Opus 4.8)</button>1026          <button class="btn-cta small" id="peSave">Enregistrer</button>1027        </div>1028      </div>`);1029    $('peUpgrade').onclick = async () => {1030      const t = $('peText').value.trim(); if (!t) return;1031      const b = $('peUpgrade'); b.textContent = '✨ amélioration…'; b.disabled = true;1032      try {1033        const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: t, project: $('peProject').value }) });1034        const d = await rr.json();1035        if (rr.ok && d.upgraded) { $('peText').value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); }1036        else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);1037      } catch { banner('bad', '✗ réseau', null, 4000); }1038      b.textContent = '✨ Améliorer (Opus 4.8)'; b.disabled = false;1039    };1040    $('peSave').onclick = async () => {1041      const text = $('peText').value.trim(); if (!text) { banner('bad', 'Le prompt est vide', null, 3000); return; }1042      const payload = { title: $('peTitle').value, text, project: $('peProject').value || null };1043      if (isNew) await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });1044      else await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });1045      closeSheet(); loadPrompts();1046    };1047  }10481049  function openSendSheet(pr) {1050    openSheet(`1051      <div class="sheet-h"><h3>Envoyer à Claude Code</h3></div>1052      <div class="sheet-sec">1053        <div class="prompt-preview" style="max-height:180px;overflow:auto">${esc(pr.text)}</div>1054      </div>1055      <div class="sheet-sec">1056        <div class="set-label">App cible (nœud)</div>1057        <select id="sendProject" class="pill-select" style="max-width:100%;width:100%">${projectOptionsHTML(pr.project || projectSel.value)}</select>1058        <div class="set-label" style="margin-top:10px">Modèle · permissions</div>1059        <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>1060        <button class="btn-cta" style="width:100%;margin-top:12px" id="sendGo">🚀 Lancer sur Claude Code</button>1061      </div>`);1062    $('sendGo').onclick = () => {1063      const proj = $('sendProject').value;1064      closeSheet();1065      sendPromptToApp(pr.text, proj);1066    };1067  }10681069  function sendPromptToApp(text, projectId) {1070    if (projectId === 'ALL') { broadcastToAll(text); return; }1071    currentChat = null;1072    queuedPrompt = null;1073    clearConversation();1074    setRunning(false);1075    for (const o of projectSel.options) if (o.value === projectId) projectSel.value = o.value;1076    switchView('chat');1077    refreshChips();1078    send({ type: 'start', chatId: null, project: projectId, model: settings.model, permMode: settings.permMode, prompt: text });1079    banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);1080  }10811082  // incidents globaux (bouton en tête de l'onglet Écosystème)1083  $('incBtn').onclick = async () => {1084    const r = await fetch('/api/eco/incidents');1085    if (!r.ok) return;1086    const { incidents } = await r.json();1087    const label = (app) => (ecoData?.sites.find(s => s.app === app)?.label) || app;1088    openSheet(`1089      <div class="sheet-h"><h3>Incidents</h3></div>1090      <div class="sheet-sec">1091        <h4>Historique — 50 derniers</h4>1092        ${incidents.length ? incidents.map(i => `1093          <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1094          <span><b>${esc(label(i.site))}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1095          ${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('')1096          : '<div style="color:var(--muted)">Aucun incident enregistré 🎉</div>'}1097      </div>`);1098  };10991100  // ---------- écosystème ----------1101  function siteStatus(s) {1102    if (s.incident || (s.last && !s.last.ok)) return 'down';1103    if (s.last && s.last.ms > 4000) return 'slow';1104    return s.last ? 'up' : 'unknown';1105  }1106  const ST = {1107    up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },1108    slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },1109    down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },1110    unknown: { cls: 'slow', ico: '▲', lbl: '…' },1111  };1112  function sparkline(spark, w = 120, h = 30) {1113    if (!spark || spark.length < 2) return '<svg class="spark"></svg>';1114    const ms = spark.map(p => p.ok ? p.ms : null);1115    const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;1116    const step = w / (spark.length - 1);1117    let d = '', pen = false;1118    spark.forEach((p, i) => {1119      if (!p.ok) { pen = false; return; }1120      const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);1121      d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);1122      pen = true;1123    });1124    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('');1125    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>`;1126  }1127  async function loadEco() {1128    const [r, rn] = await Promise.all([fetch('/api/eco/summary'), fetch('/api/eco/nodes')]);1129    if (!r.ok) return;1130    ecoData = await r.json();1131    renderEco();1132    if (rn.ok) renderNodes(await rn.json());1133  }1134  function nbar(label, val, pct) {1135    const c = pct > 88 ? 'var(--bad)' : pct > 70 ? 'var(--warn)' : 'var(--good)';1136    return `<div class="nbar"><div class="nbar-l"><span>${label}</span><b>${val}</b></div>1137      <div class="nbar-track"><div class="nbar-fill" style="width:${Math.max(2, Math.min(100, pct))}%;background:${c}"></div></div></div>`;1138  }1139  function renderNodes(data) {1140    const grid = $('ecoNodes');1141    if (!grid) return;1142    grid.innerHTML = '';1143    for (const n of data.nodes) {1144      const L = n.last;1145      const memPct = L && L.mem_total ? Math.round(L.mem_used / L.mem_total * 100) : null;1146      const upApps = n.apps.filter(a => a.status === 'online').length;1147      const badApp = n.apps.some(a => a.status && a.status !== 'online');1148      const card = el('div', 'node-card' + (badApp ? ' warn' : ''));1149      card.innerHTML = `1150        <div class="node-top"><span class="node-name">${esc(n.node)}</span>1151          <span class="node-apps${badApp ? ' bad' : ''}">${upApps}/${n.apps.length} apps</span></div>1152        ${nbar('charge', L ? L.load1.toFixed(1) : '—', L ? Math.min(100, L.load1 / 32 * 100) : 0)}1153        ${nbar('RAM', memPct !== null ? memPct + '%' : '—', memPct || 0)}1154        ${nbar('disque', L ? L.disk_pct + '%' : '—', L ? L.disk_pct : 0)}`;1155      grid.appendChild(card);1156    }1157  }1158  function applyChecks(items) {1159    if (!ecoData) return;1160    for (const it of items) {1161      const s = ecoData.sites.find(x => x.app === it.site);1162      if (!s) continue;1163      s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };1164      s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });1165      if (s.spark.length > 40) s.spark.shift();1166    }1167    renderEco();1168  }1169  function trendBadge(t) {1170    if (t == null) return '';1171    if (t <= -8) return `<span class="trend down" title="latence en baisse">▼ ${Math.abs(t)}%</span>`;1172    if (t >= 8) return `<span class="trend up" title="latence en hausse">▲ ${t}%</span>`;1173    return `<span class="trend flat">≈</span>`;1174  }1175  function statusBar(sd) {1176    const total = (sd['2xx'] + sd['3xx'] + sd['4xx'] + sd['5xx'] + sd.err) || 1;1177    const seg = (n, cls) => n ? `<i class="${cls}" style="width:${(n / total * 100).toFixed(1)}%" title="${cls} : ${n}"></i>` : '';1178    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>`;1179  }1180  function renderEco() {1181    const sites = ecoData.sites;1182    const g = ecoData.global || {};1183    $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });1184    // bandeau KPI du command center1185    $('ecoOverview').innerHTML = `1186      <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>1187      <div class="kpi"><div class="v">${g.uptime24 ?? '—'}<small>%</small></div><div class="l">uptime 24h</div></div>1188      <div class="kpi"><div class="v">${g.avgLatency}<small>ms</small></div><div class="l">latence moy.</div></div>1189      <div class="kpi"><div class="v">${(g.checks24 / 1000).toFixed(1)}<small>k</small></div><div class="l">checks 24h</div></div>1190      <div class="kpi ${g.fails24 ? 'bad' : ''}"><div class="v">${g.fails24}</div><div class="l">échecs 24h</div></div>1191      <div class="kpi ${g.openIncidents ? 'bad' : ''}"><div class="v">${g.openIncidents}</div><div class="l">incidents</div></div>`;1192    const grid = $('ecoGrid');1193    grid.innerHTML = '';1194    const order = { down: 0, slow: 1, unknown: 2, up: 3 };1195    for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)] || (b.last?.ms || 0) - (a.last?.ms || 0))) {1196      const st = ST[siteStatus(s)];1197      const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));1198      card.innerHTML = `1199        <div class="site-top">1200          <img class="site-logo" src="/icons/${s.app}" alt="" loading="lazy" onerror="this.style.display='none'">1201          <span class="site-name">${esc(s.label)}</span>1202          <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>1203        </div>1204        <div class="site-mid">1205          <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small> ${trendBadge(s.trend)}</div>1206          ${sparkline(s.spark)}1207        </div>1208        ${statusBar(s.status24 || { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 })}1209        <div class="site-metrics">1210          <div class="m"><b>${s.uptime24 ?? '—'}%</b><span>uptime</span></div>1211          <div class="m"><b>${s.p95_24 ?? '—'}</b><span>p95 ms</span></div>1212          <div class="m"><b>${s.p99_24 ?? '—'}</b><span>p99 ms</span></div>1213          <div class="m"><b>${((s.checks24 || 0) / 1000).toFixed(1)}k</b><span>checks</span></div>1214        </div>1215        <div class="site-foot">1216          <span>${esc(s.node)}</span>1217          ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">proc <b>${esc(s.proc.status)}</b></span>` : ''}1218          ${s.proc?.last_commit ? `<span>commit <b>${timeAgo(s.proc.last_commit)}</b></span>` : ''}1219          ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}1220        </div>`;1221      card.onclick = () => openSiteSheet(s.app);1222      grid.appendChild(card);1223    }1224  }12251226  async function openSiteSheet(app) {1227    const r = await fetch('/api/eco/site/' + app);1228    if (!r.ok) return;1229    const d = await r.json();1230    const s = ecoData.sites.find(x => x.app === app) || {};1231    const st = ST[siteStatus(s)];1232    const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;1233    const L = d.lat24 || {}; const C = d.counts24 || {}; const IS = d.incidentStats || {}; const SD = d.status24 || {};1234    openSheet(`1235      <div class="sheet-h">1236        <img class="site-logo big" src="/icons/${d.app}" alt="" onerror="this.style.display='none'">1237        <h3>${esc(d.label)}</h3>1238        <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>1239      </div>1240      <div class="sheet-sec">1241        <h4>Disponibilité</h4>1242        <div class="tile-row">1243          <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>1244          <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>1245          <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>1246        </div>1247        <div style="margin-top:10px">${uptimeDays(d.days)}</div>1248      </div>1249      <div class="sheet-sec">1250        <h4>Latence — percentiles 24 h ${trendBadge(d.trend)}</h4>1251        ${latencyChart(d.series)}1252        <div class="pgrid">1253          <div class="pc"><b>${L.min ?? '—'}</b><span>min</span></div>1254          <div class="pc"><b>${L.p50 ?? '—'}</b><span>p50</span></div>1255          <div class="pc"><b>${L.p90 ?? '—'}</b><span>p90</span></div>1256          <div class="pc hot"><b>${L.p95 ?? '—'}</b><span>p95</span></div>1257          <div class="pc hot"><b>${L.p99 ?? '—'}</b><span>p99</span></div>1258          <div class="pc"><b>${L.max ?? '—'}</b><span>max</span></div>1259        </div>1260      </div>1261      <div class="sheet-sec">1262        <h4>Codes HTTP & fiabilité — 24 h</h4>1263        ${statusBar(SD)}1264        <div class="legend">1265          <span><i class="sw s2"></i>2xx ${SD['2xx'] || 0}</span>1266          <span><i class="sw s3"></i>3xx ${SD['3xx'] || 0}</span>1267          <span><i class="sw s4"></i>4xx ${SD['4xx'] || 0}</span>1268          <span><i class="sw s5"></i>5xx ${SD['5xx'] || 0}</span>1269          <span><i class="sw se"></i>err ${SD.err || 0}</span>1270        </div>1271        <div class="tile-row" style="margin-top:10px">1272          <div class="tile"><div class="v">${C.total ?? '—'}</div><div class="l">checks</div></div>1273          <div class="tile"><div class="v ${C.fail ? 'red' : ''}">${C.fail ?? '—'}</div><div class="l">échecs</div></div>1274          <div class="tile"><div class="v">${C.avgBytes ? Math.round(C.avgBytes / 1024) + 'k' : '—'}</div><div class="l">taille moy</div></div>1275        </div>1276      </div>1277      <div class="sheet-sec">1278        <h4>Incidents — 30 j</h4>1279        <div class="tile-row">1280          <div class="tile"><div class="v ${IS.count30 ? 'red' : ''}">${IS.count30 ?? 0}</div><div class="l">incidents</div></div>1281          <div class="tile"><div class="v">${IS.mttrMin != null ? IS.mttrMin + ' min' : '—'}</div><div class="l">MTTR</div></div>1282          <div class="tile"><div class="v">${IS.longestMin ? IS.longestMin + ' min' : '—'}</div><div class="l">pire arrêt</div></div>1283        </div>1284      </div>1285      <div class="sheet-sec">1286        <h4>Service</h4>1287        <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>1288        <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>1289        <div class="kv"><span>Processus</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>1290        <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>1291        <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>1292        <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>1293        <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>1294      </div>1295      ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `1296        <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1297        <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1298        ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}1299      ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `1300        <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>` : ''}1301      <div class="sheet-sec">1302        <h4>Actions d'administration</h4>1303        <div class="admin-actions">1304          <button class="btn-ghost" id="aRestart">↻ Redémarrer</button>1305          <button class="btn-ghost" id="aLogs">📜 Logs récents</button>1306          <button class="btn-ghost" id="aCommits">⌥ Git & commits</button>1307          <a class="btn-ghost" href="https://${esc(d.domain)}" target="_blank" rel="noopener">↗ Ouvrir le site</a>1308        </div>1309        <div id="aOut" class="admin-out hidden"></div>1310      </div>1311      <button class="btn-cta" style="width:100%" id="shChat">💬 Demander à Claude Code →</button>1312    `);1313    $('shChat').onclick = () => {1314      closeSheet();1315      newChat();1316      for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;1317    };1318    const out = () => $('aOut');1319    const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; };1320    $('aRestart').onclick = async () => {1321      if (!confirm(`Redémarrer ${d.label} sur ${d.node} ?`)) return;1322      showOut('<div class="admin-load">redémarrage…</div>');1323      const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) });1324      const j = await r.json();1325      showOut(`<div class="${j.ok ? 'admin-ok' : 'admin-err'}">${j.ok ? '✓ redémarré' : '✗ échec'} — statut : ${esc(j.output || '')}</div>`);1326      if (navigator.vibrate) navigator.vibrate(60);1327    };1328    $('aLogs').onclick = async () => {1329      showOut('<div class="admin-load">chargement des logs…</div>');1330      const r = await fetch('/api/admin/logs/' + d.app);1331      const j = await r.json();1332      showOut(`<pre>${esc((j.logs || '').slice(-4000))}</pre>`);1333    };1334    $('aCommits').onclick = async () => {1335      showOut('<div class="admin-load">…</div>');1336      const r = await fetch('/api/admin/commits/' + d.app);1337      const j = await r.json();1338      showOut(`<pre>${esc(j.text || '')}</pre>`);1339    };1340    attachChartTips();1341  }13421343  function uptimeDays(days) {1344    if (!days) return '';1345    const cells = days.map(dd => {1346      const u = dd.uptime;1347      const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';1348      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>`;1349    }).join('');1350    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>`;1351  }13521353  function latencyChart(series) {1354    const W = 340, H = 130, PL = 38, PB = 18, PT = 8;1355    if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';1356    const ok = series.filter(p => p.ok);1357    const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;1358    const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);1359    const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);1360    let d = '', pen = false;1361    const pts = [];1362    for (const p of series) {1363      if (!p.ok) { pen = false; continue; }1364      const px = x(p.t), py = y(p.ms);1365      d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);1366      pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });1367      pen = true;1368    }1369    const failMarks = series.filter(p => !p.ok).map(p =>1370      `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');1371    const gridLines = [0.25, 0.5, 0.75, 1].map(f => {1372      const gy = y(max * f / 1.15);1373      return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>1374        <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>`;1375    }).join('');1376    const lastPt = pts[pts.length - 1];1377    const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>1378      <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>` : '';1379    const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);1380    const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';1381    return `<div style="position:relative">1382      <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 })))}'>1383        ${gridLines}1384        <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>1385        ${failMarks}${lastLbl}1386        <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>1387        <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>1388      </svg></div>`;1389  }1390  function attachChartTips() {1391    document.querySelectorAll('.lat-chart').forEach((svg) => {1392      const pts = JSON.parse(svg.dataset.pts || '[]');1393      if (!pts.length) return;1394      const wrap = svg.parentElement;1395      let tip = null;1396      const show = (clientX) => {1397        const rect = svg.getBoundingClientRect();1398        const vx = (clientX - rect.left) / rect.width * 340;1399        let best = pts[0];1400        for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;1401        if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }1402        tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;1403        tip.style.left = (best.x / 340 * rect.width) + 'px';1404        tip.style.top = (best.y / 130 * rect.height) + 'px';1405      };1406      svg.addEventListener('pointerdown', (e) => show(e.clientX));1407      svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });1408      svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });1409    });1410  }14111412  // rafraîchissement périodique de l'onglet éco (en plus du push)1413  setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);1414  // retour au premier plan (iOS coupe le WS quand l'écran se verrouille) :1415  // reconnexion IMMÉDIATE + resynchronisation de la conversation en cours1416  document.addEventListener('visibilitychange', () => {1417    if (document.hidden) return;1418    if (!ws || ws.readyState !== 1) {1419      wsRetry = 500;1420      try { ws && ws.close(); } catch {}1421      connect();1422    } else if (currentChat) {1423      send({ type: 'open', chatId: currentChat.id });1424    }1425    if (curView === 'eco') loadEco();1426  });1427  window.addEventListener('online', () => { wsRetry = 500; if (!ws || ws.readyState !== 1) connect(); });14281429  // ---------- feuille ----------1430  function openSheet(html) {1431    $('sheetBody').innerHTML = html;1432    $('sheet').classList.remove('hidden');1433  }1434  function closeSheet() { $('sheet').classList.add('hidden'); }1435  $('sheetBack').onclick = closeSheet;14361437  // ---------- réglages ----------1438  function refreshSettingsView() {1439    document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));1440    document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));1441    $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';1442    $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';1443    $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';1444  }1445  document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });1446  document.querySelectorAll('#segPerm button').forEach(b => {1447    b.onclick = () => {1448      if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;1449      applyOpts({ permMode: b.dataset.v });1450    };1451  });1452  $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };1453  $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };1454  $('logoutBtn').onclick = async () => {1455    wsWanted = false;1456    await fetch('/api/logout', { method: 'POST' });1457    location.reload();1458  };14591460  // ---------- login / init ----------1461  async function init() {1462    const me = await fetch('/api/me');1463    if (!me.ok) {1464      $('login').classList.remove('hidden');1465      $('app').classList.add('hidden');1466      return;1467    }1468    const info = await me.json();1469    $('setHost').textContent = (info.node || 'M3U96a') + ':3300';1470    $('login').classList.add('hidden');1471    $('app').classList.remove('hidden');1472    const pr = await fetch('/api/projects');1473    const { projects } = await pr.json();1474    projectSel.innerHTML = '';1475    const allOpt = document.createElement('option');1476    allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + projects.length + ')';1477    projectSel.appendChild(allOpt);1478    for (const p of projects) {1479      const o = document.createElement('option');1480      o.value = p.id; o.textContent = p.name + ' · ' + p.node;1481      projectSel.appendChild(o);1482    }1483    if (settings.projectId) projectSel.value = settings.projectId;1484    projectSel.onchange = () => {1485      settings.projectId = projectSel.value; saveStore(); refreshChips();1486      inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…'1487        : projectSel.value === 'ORCH' ? 'Tâche multi-sites — 1 session qui coordonne tout…'1488        : 'Demander à Claude Code…';1489    };1490    refreshChips();1491    refreshSettingsView();1492    // reprise automatique : on rouvre la dernière conversation (historique +1493    // live si une tâche tourne encore) même après fermeture complète de l'app1494    if (settings.lastChatId) currentChat = { id: settings.lastChatId };1495    connect();1496  }1497  $('loginBtn').onclick = doLogin;1498  $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });1499  async function doLogin() {1500    const r = await fetch('/api/login', {1501      method: 'POST',1502      headers: { 'Content-Type': 'application/json' },1503      body: JSON.stringify({ password: $('password').value }),1504    });1505    if (r.ok) { $('password').value = ''; init(); }1506    else {1507      const d = await r.json().catch(() => ({}));1508      $('loginError').textContent = d.error || 'Erreur';1509    }1510  }15111512  init();1513})();151415151516151715181519// ===== STUDIO_SOCIAL v3 (générateurs + galerie + loading) =====1520(function(){1521  const $ = (id) => document.getElementById(id);1522  const SITE_LABEL = {"lou-ka":"Lou·Ka","immo-ka":"Immo·Ka","vrai-prix":"Vrai-Prix","auto-ka":"Auto·Ka","food-ka":"Food·Ka","fabri-ka":"Fabri·Ka","resto-ka":"Resto·Ka","sorti-ka":"Sorti·Ka","crea-ka":"Créa·Ka","job-ka":"Job·Ka","trouve-ka":"Trouve·Ka"};1523  // overlay de chargement1524  let ov;1525  function showLoading(msg){1526    if(!ov){ ov=document.createElement('div'); ov.id='stLoad';1527      ov.innerHTML='<div class="stload-box"><div class="stload-spin"></div><div class="stload-msg"></div><div class="stload-sub">Ça peut prendre un moment…</div></div>';1528      document.body.appendChild(ov); }1529    ov.querySelector('.stload-msg').textContent=msg||'Génération…';1530    ov.classList.add('on');1531  }1532  function hideLoading(){ if(ov) ov.classList.remove('on'); }1533  async function copyText(txt, btn){1534    try { await navigator.clipboard.writeText(txt); }1535    catch(e){ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(_){}1536      document.body.removeChild(t); }1537    if(btn){ const o=btn.innerHTML; btn.innerHTML='✓ Copié'; setTimeout(()=>btn.innerHTML=o,1400); }1538  }1539  function esc(s){ return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }1540  function renderGallery(items, generating){1541    const el=$('stGallery'); if(!el) return;1542    if($('stCount')) $('stCount').textContent = items.length ? (items.length+' visuel'+(items.length>1?'s':'')) : '';1543    let html = '';1544    if(generating){ html += '<div class="st-item st-skel"><div class="st-media"><div class="st-shimmer"></div><span class="st-badge">⏳ en création…</span></div><div class="st-cap">L\'Agent KA génère un visuel…</div></div>'; }1545    if(!items.length && !generating){ el.innerHTML='<div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div>'; return; }1546    html += items.map(function(it,idx){1547      const media = it.kind==='reel'1548        ? '<video src="'+it.url+'" muted loop playsinline preload="metadata" onmouseover="this.play()" onmouseout="this.pause()"></video>'1549        : '<img src="'+it.url+'" loading="lazy">';1550      const badge = (SITE_LABEL[it.site]||it.site)+(it.kind==='reel'?' · Reel':' · Post');1551      return '<div class="st-item"><div class="st-media">'+media+'<span class="st-badge">'+esc(badge)+'</span></div>'1552        + '<div class="st-cap" id="stcap'+idx+'">'+esc(it.caption||'')+'</div>'1553        + '<div class="st-item-actions"><a class="btn-cta small" href="'+it.url+'" download="'+it.site+'-'+it.kind+(it.kind==='reel'?'.mp4':'.png')+'">⬇︎</a>'1554        + '<button class="btn-ghost small" data-copy="'+idx+'">📋 Copier</button></div></div>';1555    }).join('');1556    el.innerHTML = html;1557    el.querySelectorAll('[data-copy]').forEach(function(b){ b.onclick=function(){ copyText(items[+b.dataset.copy].caption||'', b); }; });1558  }1559  let polling=false;1560  async function loadGallery(){1561    try{ const d=await (await fetch('/api/social/gallery')).json();1562      const gen = d.gen && d.gen.running;1563      renderGallery(d.items||[], gen);1564      if($('stGenStatus')) $('stGenStatus').textContent = gen ? '⏳ génération des reels en cours…' : '';1565      if(gen && !polling){ polling=true; setTimeout(function(){ polling=false; loadGallery(); }, 10000); }1566    }catch(e){}1567  }1568  async function genOne(url, body, msg){1569    showLoading(msg);1570    try{ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});1571      const d=await r.json(); if(!d.draft){ alert('Échec : '+(d.error||'?')); } else { await loadGallery(); }1572    }catch(e){ alert('Échec : '+e.message); }1573    hideLoading();1574  }1575  function bind(){1576    if($('stPostGen')) $('stPostGen').onclick=()=>genOne('/api/social/generate',{site:$('stPostSite').value,prompt:$('stPostPrompt').value},'🖼️ Génération de l\'image…');1577    if($('stReelGen')) $('stReelGen').onclick=()=>genOne('/api/social/reel/generate',{site:$('stReelSite').value,prompt:$('stReelPrompt').value},'🎬 Génération du reel (vidéo + musique)…');1578    if($('stReel10')) $('stReel10').onclick=async ()=>{1579      try{ const r=await fetch('/api/social/reel/genbatch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});1580        const d=await r.json(); if($('stGenStatus')) $('stGenStatus').textContent=d.started?'⏳ 10 reels en génération…':(d.reason||'déjà en cours'); loadGallery();1581      }catch(e){ alert('Échec : '+e.message); }1582    };1583    if($('stRefresh')) $('stRefresh').onclick=loadGallery;1584  }1585  bind();1586  window.__loadGallery = loadGallery;1587})();15881589
Files · e3791694eb62c5751d2f54d887e077839b8e01d2
Menu

Theme