/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */ (() => { const $ = (id) => document.getElementById(id); const messagesEl = $('messages'); const inputEl = $('input'); const sendBtn = $('sendBtn'); const stopBtn = $('stopBtn'); const projectSel = $('projectSel'); marked.setOptions({ breaks: true, mangle: false, headerIds: false }); // ---------- état ---------- const store = JSON.parse(localStorage.getItem('adminka') || '{}'); const settings = { model: store.model || 'fable', permMode: store.permMode || 'default', projectId: store.projectId || null, }; function saveStore() { localStorage.setItem('adminka', JSON.stringify(settings)); } let ws = null, wsRetry = 1000, wsWanted = true; let currentChat = null; // publicChat du serveur let isRunning = false; let live = null, liveThink = null; let curView = 'chat'; let ecoData = null; const toolCards = new Map(); const permCards = new Map(); const MODEL_LABEL = { fable: 'Fable 5', opus: 'Opus 5', sonnet: 'Sonnet 5', haiku: 'Haiku' }; const PERM_LABEL = { default: 'normal', acceptEdits: 'acceptEdits', plan: 'plan', bypass: '⚠ bypass' }; // ---------- utilitaires ---------- function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; } function fmt$(v) { return (v || 0) < 0.005 ? '<0,01 $' : (v).toFixed(2).replace('.', ',') + ' $'; } function fmtTok(v) { return v > 1000 ? Math.round(v / 1000) + 'k' : String(v || 0); } function timeAgo(ts) { if (!ts) return '—'; const s = (Date.now() - ts) / 1000; if (s < 90) return 'à l’instant'; if (s < 3600) return Math.round(s / 60) + ' min'; if (s < 86400) return Math.round(s / 3600) + ' h'; return Math.round(s / 86400) + ' j'; } function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; } // ---------- navigation (sidebar desktop + bottom nav mobile) ---------- const I = { home: '', chat: '', sessions: '', prompts: '', eco: '', visitors: '', social: '', settings: '', more: '', }; const NAV = [ { id: 'overview', label: 'Vue d’ensemble', icon: I.home }, { id: 'chat', label: 'Claude Code', icon: I.chat }, { id: 'sessions', label: 'Sessions', icon: I.sessions }, { id: 'prompts', label: 'Prompts', icon: I.prompts }, { id: 'eco', label: 'Écosystème', icon: I.eco }, { id: 'visitors', label: 'Visiteurs', icon: I.visitors }, { id: 'social', label: 'Studio', icon: I.social }, { id: 'settings', label: 'Réglages', icon: I.settings }, ]; const views = NAV.map(n => n.id); const TITLES = Object.fromEntries(NAV.map(n => [n.id, n.label])); function navBtnHTML(n) { return ``; } $('sideNav').innerHTML = NAV.map(navBtnHTML).join(''); const MOBILE_NAV = ['overview', 'chat', 'eco', 'visitors']; $('bottomNav').innerHTML = NAV.filter(n => MOBILE_NAV.includes(n.id)).map(navBtnHTML).join('') + ``; $('navMore').onclick = () => { openSheet(`

Navigation

${ NAV.filter(n => !MOBILE_NAV.includes(n.id)).map(n => ``).join('') }
`); document.querySelectorAll('.more-btn').forEach(b => { b.onclick = () => { closeSheet(); switchView(b.dataset.view); }; }); }; function switchView(name) { curView = name; for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); const pt = $('pageTitle'); if (pt) pt.textContent = TITLES[name] || name; document.querySelectorAll('.nav-btn').forEach((b) => { b.classList.toggle('active', b.dataset.view === name); if (b.dataset.view === name) { const bd = b.querySelector('.badge'); if (bd) bd.classList.add('hidden'); } }); if (name === 'overview' && window.startOverview) window.startOverview(); if (name === 'sessions') loadSessions(); if (name === 'prompts') loadPrompts(); if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); } if (name === 'social' && window.__loadGallery) window.__loadGallery(); if (name === 'visitors' && window.startVisitors) window.startVisitors(); if (name === 'settings') { refreshSettingsView(); if (window.loadAnCfg) window.loadAnCfg(); } } document.addEventListener('click', (e) => { const nb = e.target.closest('.nav-btn[data-view]'); if (nb) { switchView(nb.dataset.view); return; } const gl = e.target.closest('[data-goto]'); if (gl) switchView(gl.dataset.goto); }); // ---------- thème (auto / clair / sombre) ---------- function applyTheme() { const t = localStorage.getItem('ka_theme') || 'auto'; const dark = t === 'dark' || (t === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.dataset.theme = dark ? 'dark' : 'light'; const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.content = dark ? '#16130e' : '#F2F1EC'; document.querySelectorAll('#segTheme button').forEach(b => b.classList.toggle('on', b.dataset.v === t)); } document.querySelectorAll('#segTheme button').forEach(b => { b.onclick = () => { localStorage.setItem('ka_theme', b.dataset.v); applyTheme(); }; }); $('themeBtn').onclick = () => { const cur = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; localStorage.setItem('ka_theme', cur === 'dark' ? 'light' : 'dark'); applyTheme(); }; matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme); applyTheme(); // ===== VISITEURS v2 — trafic humain crédible ===== (function () { const $ = (id) => document.getElementById(id); let timer = null, mode = 'human', lastSummary = null; const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' '); const DEV_LABEL = { m: '📱 mobile', t: '💻 tablette', d: '🖥️ desktop', '?': '—' }; const CLS_LABEL = { human: 'Humains', crawler: 'Crawlers', datacenter: 'Datacenter', automation: 'Automation', internal: 'Interne' }; const CLS_COLOR = { human: 'var(--good)', crawler: '#6a9fd8', datacenter: 'var(--warn)', automation: '#a08cc9', internal: 'var(--muted)' }; function delta(cur, prev) { if (!prev) return ''; const d = Math.round((cur - prev) / prev * 100); if (!isFinite(d) || Math.abs(d) < 1) return '≈ hier'; return `${d > 0 ? '+' : ''}${d} % vs hier`; } function kpi(v, l, extra, accent) { return `
${v}
${l}
${extra || ''}
`; } function renderGlobal(d) { const g = d.global || {}; $('visLive').innerHTML = ' ' + nf(g.online) + ' humain' + (g.online > 1 ? 's' : '') + ' en ligne'; const all = mode === 'all'; const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0); $('visGlobal').innerHTML = kpi(nf(g.online), 'en ligne (actifs < 2 min)', '', true) + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame)) + kpi(nf(g.sessions), 'sessions') + kpi(nf(g.pv), 'pages vues') + kpi(nf(g.d7), '7 jours') + kpi(nf(g.d30), '30 jours') + (all ? kpi(nf(g.total_vids), 'visiteurs (toutes classes)') + kpi(nf(autoHits), 'hits automatisés') : ''); // barre de répartition des classes (événements du jour) const seg = [['human', g.humanHits], ['crawler', g.crawler], ['datacenter', g.datacenter], ['automation', g.automation], ['internal', g.internal]] .map(([k, v]) => [k, v || 0]); const tot = seg.reduce((a, [, v]) => a + v, 0) || 1; $('visBreak').innerHTML = `
${seg.map(([k, v]) => v ? `` : '').join('')}
${seg.map(([k, v]) => `${CLS_LABEL[k]} ${nf(v)}`).join('')} événements du jour — les métriques principales n’affichent que les humains
`; } function siteCard(s) { const all = mode === 'all'; const autoHits = (s.crawler || 0) + (s.datacenter || 0) + (s.automation || 0) + (s.internal || 0); return `
${s.label || s.site}
${nf(s.online)} en ligne
${nf(s.today)}
humains
${nf(s.sessions)}
sessions
${nf(s.pv)}
pages
${nf(s.d7)}
7 j
${all ? `
🤖 ${nf(autoHits)} hits automatisés (crawler ${nf(s.crawler)} · dc ${nf(s.datacenter)} · autom. ${nf(s.automation)} · interne ${nf(s.internal)})
` : `
${delta(s.today, s.yesterdaySame) || ' '}détail →
`}
`; } async function loadVisitors() { try { const d = await (await fetch('/api/analytics/summary')).json(); lastSummary = d; renderGlobal(d); $('visGrid').innerHTML = (d.sites || []).map(siteCard).join(''); $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); }); } catch {} loadRealtime(); loadAnoms(); } async function loadRealtime() { try { const d = await (await fetch('/api/analytics/realtime')).json(); $('rtCount').textContent = d.active.length + ' actif' + (d.active.length > 1 ? 's' : '') + ' · fenêtre ' + d.activeSec + ' s'; if (!d.active.length) { $('visRealtime').innerHTML = '
Personne en ce moment — le compteur expire tout seul (~2 min).
'; return; } $('visRealtime').innerHTML = d.active.slice(0, 30).map(a => { const dur = Math.max(0, Math.round((Date.now() - (a.since || a.lastTs)) / 60000)); return `
${a.label} ${esc((a.path || '/').slice(0, 42))} ${esc(a.loc)}${a.cc && a.cc !== 'CA' && a.cc !== 'XX' ? ' · ' + a.cc : ''} ${DEV_LABEL[a.dev] || ''} ${dur < 1 ? '<1 min' : dur + ' min'}
`; }).join(''); } catch {} } async function loadAnoms() { try { const d = await (await fetch('/api/analytics/anomalies')).json(); const bits = []; if (d.datacenter?.surge) bits.push(`
▲ Poussée datacenter : ${nf(d.datacenter.lastHour)} hits/h (moy. ${nf(d.datacenter.hourlyAvg)})
`); for (const s of d.spikes || []) bits.push(`
▲ Pic sur ${esc(s.site)} : ${nf(s.lastHour)} evt/h (×${s.factor})
`); for (const ip of (d.topIps || []).slice(0, 5)) bits.push(`
${esc(ip.ip)} · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} · classe ${esc(ip.cls)}
`); $('visAnom').innerHTML = bits.length ? bits.join('') : '
● Rien à signaler — trafic dans les normes.
'; $('visAnom').querySelectorAll('[data-ip]').forEach(b => { b.onclick = () => inspect(b.dataset.ip); }); } catch {} } async function inspect(q) { const val = q || prompt('IP ou visitor-id à inspecter :'); if (!val) return; const isIp = /^[\d.:a-f]+$/i.test(val) && val.includes('.') || val.includes(':'); const r = await fetch('/api/analytics/inspect?' + (isIp ? 'ip=' : 'vid=') + encodeURIComponent(val)); const d = await r.json(); const ipi = d.ipinfo; openSheet(`

🔬 Inspecteur de trafic

Classe stockée${esc(d.storedClass || '—')}
IP${esc(d.ip || '—')}
${ipi ? `
RéseauAS${ipi.asn} · ${esc(ipi.org || ipi.isp || '?')}
Lieu${esc([ipi.city, ipi.region, ipi.country].filter(Boolean).join(', ') || '—')}
Drapeaux${ipi.hosting ? 'hosting ' : ''}${ipi.proxy ? 'proxy ' : ''}${ipi.mobile ? 'mobile ' : ''}${!ipi.hosting && !ipi.proxy ? 'résidentiel' : ''}
` : '
IP pas encore résolue (file ip-api)
'}

Raisons de la classification

${(d.reasons || []).map(x => `
·${esc(x)}
`).join('')}

Événements récents (${(d.events || []).length})

${(d.events || []).slice(0, 20).map(e2 => `
${e2.ev}${new Date(e2.ts).toLocaleTimeString('fr-CA')} · ${esc(e2.site)} · ${esc(e2.path)} · ${esc(e2.cls)}
`).join('') || '
aucun
'}
`); } $('visDebugBtn').onclick = () => inspect(null); document.querySelectorAll('#visMode button').forEach(b => { b.onclick = () => { mode = b.dataset.v; document.querySelectorAll('#visMode button').forEach(x => x.classList.toggle('on', x === b)); if (lastSummary) { renderGlobal(lastSummary); $('visGrid').innerHTML = (lastSummary.sites || []).map(siteCard).join(''); $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); }); } }; }); // graphique série 30 j : humains (trait plein) + héritage v1 (pointillé gris) function chart(series, mig) { const w = 680, h = 220, pl = 42, pb = 26, pt = 10; const n = series.length; if (n < 2) return '
Pas encore assez de données.
'; const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0])); const X = i => pl + (w - pl - 10) * (i / (n - 1)); const Y = v => h - pb - (h - pb - pt) * (v / max); let dH = '', dL = '', pen = false; series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); }); series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; }); const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`; let lab = ''; for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `${series[i].day.slice(5)}`; const grid = [0.5, 1].map(f => `${nf(Math.round(max * f))}`).join(''); const migDay = mig ? new Date(mig).toISOString().slice(0, 10) : null; const migIdx = migDay ? series.findIndex(x => x.day >= migDay) : -1; const migLine = migIdx > 0 ? `migration v2` : ''; return ` ${grid}${migLine} ${lab}
humains (v2)ancien comptage (non filtré)
`; } async function openDetail(site, label) { openSheet(`

${esc(label)}

Chargement…
`); try { const [sm, se, det] = await Promise.all([ lastSummary ? Promise.resolve(lastSummary) : (await fetch('/api/analytics/summary')).json(), (await fetch('/api/analytics/series/' + site)).json(), (await fetch('/api/analytics/site/' + site + '?days=7')).json(), ]); const s = (sm.sites || []).find(x => x.site === site) || {}; const bar = (rows, kL, kV, unit) => { const mx = Math.max(1, ...rows.map(r => r[kV])); return rows.map(r => `
${esc(String(r[kL]).slice(0, 42))}
${nf(r[kV])}${unit || ''}
`).join('') || '
rien encore
'; }; const devTot = det.devs.reduce((a, x) => a + x.uv, 0) || 1; $('vdBody').innerHTML = `
${nf(s.online)}
en ligne
${nf(s.today)}
humains auj.
${nf(s.sessions)}
sessions
${nf(s.pv)}
pages vues
${nf(det.newToday)}
nouveaux
${nf(det.returningToday)}
récurrents
${det.pagesPerSession || 0}
pages/session
${det.avgSessionSec >= 60 ? Math.round(det.avgSessionSec / 60) + ' min' : (det.avgSessionSec || 0) + ' s'}
durée moy.

Visiteurs humains — 30 jours

${chart(se.series || [], se.migratedAt)}

Pages populaires — 7 j (humains)

${bar(det.pages, 'path', 'pv')}

Sources / referrers — 7 j

${det.refs.length ? bar(det.refs.map(r => ({ ...r, ref: r.ref.replace(/^https?:\/\//, '') })), 'ref', 'uv') : '
rien encore (referrer fourni par le beacon v2)
'}

Appareils

${bar(det.devs.map(x => ({ l: (DEV_LABEL[x.dev] || 'inconnu (beacon v1)') + ' · ' + Math.round(x.uv / devTot * 100) + ' %', uv: x.uv })), 'l', 'uv')}

D'où viennent les visiteurs — 7 j

${bar(det.geo.map(x => ({ l: x.city + (x.region ? ', ' + (x.cc === 'CA' ? x.region : x.country) : ''), uv: x.uv })), 'l', 'uv')} ${det.countries.length > 1 ? '

Pays

' + bar(det.countries.map(x => ({ l: x.country, uv: x.uv })), 'l', 'uv') : ''}

Trafic filtré — 7 j

${bar((det.classes || []).filter(c => c.cls !== 'human').map(c => ({ l: (CLS_LABEL[c.cls] || c.cls) + ' (' + nf(c.uv) + ' ids)', n: c.n })), 'l', 'n', ' hits')}
`; } catch (e) { $('vdBody').innerHTML = '
Erreur : ' + esc(e.message) + '
'; } } window.startVisitors = function () { loadVisitors(); clearInterval(timer); timer = setInterval(() => { if (curView === 'visitors' && !document.hidden) { loadVisitors(); } }, 12000); }; })(); // ===== VUE D'ENSEMBLE ===== (function () { const $ = (id) => document.getElementById(id); let timer = null; const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' '); const fmt$$ = (v) => (v || 0).toFixed(2).replace('.', ',') + ' $'; function kpi(v, l, extra, cls) { return `
${v}
${l}
${extra || ''}
`; } function delta(cur, prev) { if (!prev) return ''; const d = Math.round((cur - prev) / prev * 100); if (!isFinite(d) || Math.abs(d) < 1) return '≈ hier'; return `${d > 0 ? '+' : ''}${d} % vs hier`; } async function load() { try { const [an, eco, chats, anom, series] = await Promise.all([ (await fetch('/api/analytics/summary')).json(), (await fetch('/api/eco/summary')).json(), (await fetch('/api/chats')).json(), (await fetch('/api/analytics/anomalies')).json(), (await fetch('/api/analytics/series/_all')).json(), ]); const g = an.global || {}, eg = eco.global || {}; const running = (chats.chats || []).filter(c => c.state !== 'idle'); const cost = (chats.chats || []).reduce((a, c) => a + (c.totalCost || 0), 0); $('ovStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const ts = $('topStamp'); if (ts) ts.textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }); $('ovKpis').innerHTML = kpi(nf(g.online), 'humains en ligne', '', 'accent') + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame)) + kpi(nf(g.sessions), 'sessions') + kpi(nf(g.pv), 'pages vues') + kpi(`${eg.up}/${eg.total}`, 'sites en ligne', '', eg.down ? 'bad' : 'good') + kpi(eg.openIncidents || 0, 'incidents ouverts', '', eg.openIncidents ? 'bad' : '') + kpi((eg.uptime24 ?? '—') + '%', 'uptime 24 h') + kpi(eg.avgLatency + ' ms', 'latence moy.') + kpi(running.length, 'sessions Claude actives', '', running.length ? 'accent' : '') + kpi(fmt$$(cost), 'coût Claude cumulé'); // graphique 30 j global const s = series.series || []; $('ovChart').innerHTML = ovChart(s, series.migratedAt); const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0); $('ovBreak').innerHTML = `
Aujourd’hui : ${nf(g.today)} humains · trafic filtré : ${nf(g.crawler)} crawler + ${nf(g.datacenter)} datacenter + ${nf(g.automation)} automation + ${nf(g.internal)} interne (${nf(autoHits)} hits exclus des métriques)
`; // écosystème compact const order = { down: 0, slow: 1, unknown: 2, up: 3 }; const stt = (x) => (x.incident || (x.last && !x.last.ok)) ? 'down' : (x.last && x.last.ms > 4000) ? 'slow' : x.last ? 'up' : 'unknown'; $('ovEco').innerHTML = '
' + [...eco.sites].sort((a, b) => order[stt(a)] - order[stt(b)]).map(x => { const st = stt(x); return `${st === 'up' ? '●' : st === 'slow' ? '▲' : '■'} ${x.label}${x.last?.ok ? x.last.ms + 'ms' : 'OFF'}`; }).join('') + '
'; // incidents récents const inc = await (await fetch('/api/eco/incidents')).json(); $('ovInc').innerHTML = (inc.incidents || []).slice(0, 5).map(i => `
${i.ended ? '✓' : '■'} ${esc(i.site)} · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'}
`).join('') || '
Aucun incident récent 🎉
'; // Claude Code $('ovClaude').innerHTML = (chats.chats || []).slice(0, 5).map(c => `
${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'} ${esc(c.title)} ${esc(c.projectName || '')} · ${fmt$$(c.totalCost)}
`).join('') || '
Aucune session encore.
'; $('ovClaude').querySelectorAll('[data-chat]').forEach(x => { x.onclick = () => openChat(x.dataset.chat); }); // anomalies const bits = []; if (anom.datacenter?.surge) bits.push(`
▲ Poussée datacenter : ${nf(anom.datacenter.lastHour)} hits/h
`); for (const sp of anom.spikes || []) bits.push(`
▲ Pic sur ${esc(sp.site)} : ×${sp.factor}
`); for (const ip of (anom.topIps || []).slice(0, 3)) bits.push(`
${esc(ip.ip)} · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} → ${esc(ip.cls)}
`); $('ovAnom').innerHTML = bits.length ? bits.join('') : '
● Rien à signaler.
'; } catch {} } function ovChart(series, mig) { const w = 760, h = 200, pl = 44, pb = 24, pt = 8; const n = series.length; if (n < 2) return '
Les données humaines s’accumulent — revenez dans quelques heures.
'; const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0])); const X = i => pl + (w - pl - 10) * (i / (n - 1)); const Y = v => h - pb - (h - pb - pt) * (v / max); let dH = '', dL = '', pen = false; series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); }); series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; }); const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`; let lab = ''; for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `${series[i].day.slice(5)}`; const grid = [0.5, 1].map(f => `${nf(Math.round(max * f))}`).join(''); return `${grid} ${lab}
humains (v2, filtré)ancien comptage v1 (non filtré)
`; } window.startOverview = function () { load(); clearInterval(timer); timer = setInterval(() => { if (curView === 'overview' && !document.hidden) load(); }, 30000); }; })(); // ---------- Écran du nœud (noVNC embarqué) ---------- let _rfb = null; (function bindVNC(){ const connect = document.getElementById('vncConnect'); const disc = document.getElementById('vncDisconnect'); const statusEl = document.getElementById('vncStatus'); const panel = document.getElementById('vncPanel'); if (!connect) return; connect.onclick = async () => { if (_rfb) { statusEl.textContent = 'déjà connecté'; return; } statusEl.textContent = 'connexion…'; try { const pw = await (await fetch('/api/social/vncpw')).json(); const RFB = window.KA_RFB; if (!RFB) { statusEl.textContent = 'noVNC pas encore chargé — rafraîchis la page'; return; } const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws'; panel.style.display = 'block'; _rfb = new RFB(panel, url, { credentials: { password: pw.password } }); _rfb.scaleViewport = true; _rfb.resizeSession = false; _rfb.background = '#0d0d0d'; _rfb.addEventListener('connect', () => { statusEl.textContent = '✅ connecté — tu peux cliquer dans l\'écran'; }); _rfb.addEventListener('disconnect', (e) => { statusEl.textContent = 'déconnecté'; _rfb = null; panel.style.display='none'; }); _rfb.addEventListener('credentialsrequired', () => { _rfb.sendCredentials({ password: pw.password }); }); } catch (e) { statusEl.textContent = 'échec : ' + e.message; } }; disc.onclick = () => { if (_rfb) { try { _rfb.disconnect(); } catch {} _rfb = null; } panel.style.display='none'; statusEl.textContent='déconnecté'; }; })(); // ---------- Social ---------- let socDraft = null; async function loadSocial() { try { const r = await fetch('/api/social/state'); const d = await r.json(); renderSocialState(d.state); renderSocialLog(d.log || []); } catch {} } function renderSocialState(st) { if (!st) return; const tog = $('socAutoToggle'); if (tog) tog.checked = !!st.auto; const nx = $('socNext'); if (nx) { 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)'; else nx.textContent = st.auto ? 'Automatique activé' : 'Automatique désactivé'; } } function renderSocialLog(log) { const el = $('socLog'); if (!el) return; if (!log.length) { el.innerHTML = '
Aucun événement.
'; return; } el.innerHTML = log.map(function (e) { const t = new Date(e.ts).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); let msg = e.kind; if (e.kind === 'published') msg = '📣 Publié — ' + (e.site || e.trigger || '') + (e.headline ? ' · ' + e.headline : ''); else if (e.kind === 'cycle_error') msg = '⚠️ Erreur — ' + (e.error || ''); else if (e.kind === 'cycle_skip') msg = '⏸️ Ignoré — ' + (e.reason || ''); else if (e.kind === 'auto_on') msg = '▶️ Automatique activé'; else if (e.kind === 'auto_off') msg = '⏹️ Automatique désactivé'; else if (e.kind === 'cycle_start') msg = '… cycle démarré (' + (e.trigger || '') + ')'; return '
' + t + ' ' + esc(msg) + '
'; }).join(''); } function bindSocial() { const tog = $('socAutoToggle'); if (tog) tog.onchange = async () => { const r = await fetch('/api/social/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on: tog.checked }) }); const d = await r.json(); renderSocialState(d.state); banner(tog.checked ? 'good' : 'muted', tog.checked ? 'Publication automatique activée' : 'Publication automatique désactivée'); }; const gen = $('socGenerate'); if (gen) gen.onclick = doGenerate; const rg = $('socRegen'); if (rg) rg.onclick = doGenerate; const pub = $('socPublish'); if (pub) pub.onclick = async () => { if (!socDraft) return; pub.disabled = true; pub.textContent = 'Publication…'; try { 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() }) }); const d = await r.json(); if (d.ok) { banner('good', '📣 Post publié sur la Page Groupe-KA'); $('socDraft').classList.add('hidden'); socDraft = null; loadSocial(); } else banner('bad', 'Échec : ' + (d.error || '?')); } catch (e) { banner('bad', 'Échec : ' + e.message); } pub.disabled = false; pub.textContent = '📣 Publier ce post'; }; const run = $('socRunNow'); if (run) run.onclick = async () => { run.disabled = true; run.textContent = 'Publication…'; try { const r = await fetch('/api/social/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); const d = await r.json(); banner(d.ok ? 'good' : 'bad', d.ok ? ('📣 Publié : ' + (d.insight ? d.insight.site : '')) : ('Échec : ' + (d.error || d.reason || '?'))); loadSocial(); } catch (e) { banner('bad', 'Échec : ' + e.message); } run.disabled = false; run.textContent = '⚡ Publier maintenant'; }; const chk = $('socCheckLogin'); if (chk) chk.onclick = async () => { const el = $('socLogin'); el.textContent = 'Vérification…'; try { const r = await fetch('/api/social/login'); const d = await r.json(); 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))); } catch (e) { el.textContent = 'Erreur : ' + e.message; } }; } let socReelDraft = null; (function bindReel(){ const gen = document.getElementById('socReelGen'); if (gen) gen.onclick = async () => { const prev = gen.textContent; gen.disabled = true; gen.textContent = 'Génération vidéo…'; try { const r = await fetch('/api/social/reel/generate', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ site: document.getElementById('socReelSite').value, prompt: document.getElementById('socReelPrompt').value }) }); const d = await r.json(); if (d.draft) { socReelDraft = d.draft; document.getElementById('socReelVid').src = d.draft.videoUrl + '?t=' + Date.now(); document.getElementById('socReelCaption').value = d.draft.caption; document.getElementById('socReelDl').href = d.draft.videoUrl; document.getElementById('socReelDraft').classList.remove('hidden'); } else banner('bad', 'Échec : ' + (d.error||'?')); } catch(e){ banner('bad','Échec : '+e.message); } gen.disabled = false; gen.textContent = prev; }; const pub = document.getElementById('socReelPublish'); if (pub) pub.onclick = async () => { if (!socReelDraft) return; pub.disabled = true; pub.textContent = 'Publication…'; banner('muted', '🎬 Publication du reel — termine le « Suivant » sur l\'écran distant du nœud si demandé', null, 9000); try { const r = await fetch('/api/social/reel/publish', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ caption: document.getElementById('socReelCaption').value, video: socReelDraft.video.split('/').pop() }) }); const d = await r.json(); banner(d.ok?'good':'bad', d.ok?'📣 Reel publié':'Échec : '+(d.error||'?')); if (d.ok) { document.getElementById('socReelDraft').classList.add('hidden'); socReelDraft=null; loadSocial(); } } catch(e){ banner('bad','Échec : '+e.message); } pub.disabled = false; pub.textContent = '📣 Publier (assisté)'; }; })(); async function doGenerate() { const gen = $('socGenerate'); const prev = gen.textContent; gen.disabled = true; gen.textContent = 'Génération…'; try { const r = await fetch('/api/social/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ site: $('socSite').value, prompt: $('socPrompt').value }) }); const d = await r.json(); if (d.draft) { socDraft = d.draft; $('socImg').src = d.draft.imageUrl + '?t=' + Date.now(); $('socCaption').value = d.draft.caption; $('socDraft').classList.remove('hidden'); } else banner('bad', 'Échec : ' + (d.error || '?')); } catch (e) { banner('bad', 'Échec : ' + e.message); } gen.disabled = false; gen.textContent = prev; } bindSocial(); function navBadge(view) { if (curView === view) return; document.querySelectorAll(`.nav-btn[data-view="${view}"] .badge`).forEach(b => b.classList.remove('hidden')); } // ---------- bannières ---------- function banner(kind, html, onclick, ttl = 6000) { const b = el('div', 'banner ' + kind, html); if (onclick) b.onclick = () => { onclick(); b.remove(); }; $('banners').appendChild(b); if (navigator.vibrate) navigator.vibrate(60); setTimeout(() => b.remove(), ttl); } // ---------- scroll / messages ---------- function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; } function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; } function addMsg(node) { $('welcome').classList.add('hidden'); const wasNear = nearBottom(); const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node); scrollBottom(wasNear); } function renderMd(container, text) { container.innerHTML = marked.parse(text || ''); container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} }); } // bouton copier sur tous les blocs de code (délégué, marche pour tout
)
  messagesEl.addEventListener('click', (e) => {
    const pre = e.target.closest('pre');
    if (!pre || e.target.closest('button') || pre.dataset.copying) return;
    if (window.getSelection().toString()) return;
    navigator.clipboard.writeText(pre.textContent).then(() => {
      pre.dataset.copying = '1';
      const tag = el('span', 'copied-tag', 'copié ✓');
      pre.appendChild(tag);
      setTimeout(() => { tag.remove(); delete pre.dataset.copying; }, 1200);
    }).catch(() => {});
  });
  function truncPre(text, limit = 3000) {
    const pre = el('pre');
    const full = String(text ?? '');
    if (full.length <= limit) { pre.textContent = full; return pre; }
    pre.textContent = full.slice(0, limit);
    const wrap = el('div');
    const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');
    btn.onclick = () => { pre.textContent = full; btn.remove(); };
    wrap.append(pre, btn);
    return wrap;
  }

  function workingEl() { return messagesEl.querySelector('.working'); }
  let workTimer = null, workStart = 0;
  function setRunning(r) {
    isRunning = r;
    sendBtn.classList.toggle('hidden', r);
    stopBtn.classList.toggle('hidden', !r);
    let w = workingEl();
    if (r && !w) {
      $('welcome').classList.add('hidden');
      w = el('div', 'working', '
Claude Code travaille…'); messagesEl.appendChild(w); scrollBottom(true); workStart = Date.now(); clearInterval(workTimer); workTimer = setInterval(() => { const t = w.querySelector('.work-time'); if (!t) { clearInterval(workTimer); return; } const s = Math.floor((Date.now() - workStart) / 1000); t.textContent = s >= 60 ? Math.floor(s / 60) + ' min ' + (s % 60) + ' s' : s + ' s'; }, 1000); } else if (!r && w) { w.remove(); clearInterval(workTimer); } if (!r) { finalizeLive(); finalizeThink(); flushQueued(); } } 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' }; function setWorkingLabel(name, sub) { const w = workingEl(); if (!w) return; const span = w.querySelector('span'); if (!span) return; const verb = WORK_VERB[name] || 'utilise ' + name; const detail = sub ? ' ' + sub.slice(0, 46) : ''; span.textContent = `Claude ${verb}${detail}…`; } // file d'attente : un message tapé pendant l'exécution part dès que Claude a fini let queuedPrompt = null; function flushQueued() { if (!queuedPrompt) return; const q = queuedPrompt; queuedPrompt = null; const n = messagesEl.querySelector('.queued-notice'); if (n) n.remove(); doSend(q); } // ---------- streaming live ---------- function ensureLive() { if (live) return live; const w = workingEl(); if (w) { const s = w.querySelector('span'); if (s) s.textContent = 'Claude rédige…'; } const wrap = el('div', 'msg-claude'); const md = el('div', 'md'); wrap.appendChild(md); addMsg(wrap); live = { el: wrap, textEl: md, text: '' }; return live; } function finalizeLive() { if (live) { live.el.remove(); live = null; } } function ensureThink() { if (liveThink) return liveThink; const box = el('div', 'thinking'); addMsg(box); liveThink = { el: box, text: '' }; return liveThink; } function finalizeThink() { if (!liveThink) return; const d = el('details', 'thinking-box', 'réflexion'); const inner = el('div', 'thinking'); inner.textContent = liveThink.text; d.appendChild(inner); liveThink.el.replaceWith(d); liveThink = null; } // ---------- cartes outils ---------- const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' }; 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' }; function toolSubtitle(name, input) { input = input || {}; if (name === 'Bash') return input.command || ''; if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/'); if (input.pattern) return input.pattern; if (input.url) return input.url; if (name === 'Task') return input.description || ''; if (name === 'TodoWrite') return 'liste de tâches'; return ''; } function diffBlock(oldStr, newStr) { const d = el('div', 'diff'); for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l))); for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l))); return d; } function toolBody(name, input) { const body = el('div', 'tool-body'); input = input || {}; if (name === 'Bash') { body.appendChild(el('div', 'label', 'commande')); body.appendChild(truncPre(input.command)); if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description))); } else if (name === 'Edit') { body.appendChild(diffBlock(input.old_string, input.new_string)); } else if (name === 'MultiEdit') { for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string)); } else if (name === 'Write') { body.appendChild(el('div', 'label', 'contenu')); const d = el('div', 'diff'); const lines = String(input.content || '').split('\n'); for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l))); if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus')); body.appendChild(d); } else if (name === 'TodoWrite') { const ul = el('ul', 'todos'); for (const t of input.todos || []) { const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content))); } body.appendChild(ul); } else if (name === 'Task') { body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2))); } else if (name === 'ExitPlanMode') { const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md); } else { body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500)); } return body; } // outils dont on ouvre la carte par défaut (on veut VOIR ce qui est fait) const OPEN_BY_DEFAULT = new Set(['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'ExitPlanMode']); function addToolCard(block) { finalizeLive(); finalizeThink(); const name = block.name || '?'; const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name; const card = el('details', 'tool'); if (OPEN_BY_DEFAULT.has(name)) card.open = true; const sum = el('summary'); sum.innerHTML = `${TOOL_ICONS[name] || '⚙️'}${esc(shortName)}${esc(toolSubtitle(name, block.input))}● en cours`; card.appendChild(sum); card.appendChild(toolBody(name, block.input)); addMsg(card); toolCards.set(block.id, { card, name, stateEl: sum.querySelector('.t-state'), subEl: sum.querySelector('.t-sub'), bodyEl: card.querySelector('.tool-body') }); // met à jour l'indicateur « en cours » avec l'action réelle setWorkingLabel(name, toolSubtitle(name, block.input)); } function attachToolResult(block) { const t = toolCards.get(block.tool_use_id); if (!t) return; t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok'); t.stateEl.textContent = block.is_error ? '✗ erreur' : '✓'; let text = ''; if (typeof block.content === 'string') text = block.content; else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n'); text = text.trim(); if (text) { // les cartes repliées (Read/Grep/…) montrent un aperçu du résultat dans l'en-tête if (!t.card.open && t.subEl) { const firstLine = text.split('\n').find(l => l.trim()) || ''; t.subEl.textContent = firstLine.slice(0, 80); } t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat')); t.bodyEl.appendChild(truncPre(text)); } else { t.bodyEl.appendChild(el('div', 'meta-line', block.is_error ? 'erreur (vide)' : 'terminé sans sortie')); } } // ---------- permissions ---------- function addPermCard(ev) { finalizeLive(); finalizeThink(); const card = el('div', 'perm'); const isPlan = ev.tool === 'ExitPlanMode'; card.innerHTML = `
${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}
`; if (isPlan && ev.input?.plan) { const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md); } else { const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre); } const actions = el('div', 'perm-actions'); const deny = el('button', 'perm-deny', 'Refuser'); const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser'); actions.append(deny, allow); if (!isPlan) { const always = el('button', 'perm-always', 'Toujours
cet outil'); always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true }); actions.insertBefore(always, allow); // « Tout autoriser » : le serveur approuve TOUTES les permissions de la // session — la tâche va au bout même téléphone rangé const all = el('button', 'perm-allall', '🔓 Tout autoriser — finir la tâche sans redemander'); all.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, allowAll: true }); card.appendChild(actions); card.appendChild(all); } else { card.appendChild(actions); } deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false }); allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true }); addMsg(card); permCards.set(ev.requestId, card); scrollBottom(true); if (navigator.vibrate) navigator.vibrate([60, 60, 60]); } function resolvePermCard(ev) { const card = permCards.get(ev.requestId); if (!card) return; const actions = card.querySelector('.perm-actions'); if (actions) actions.remove(); const allBtn = card.querySelector('.perm-allall'); if (allBtn) allBtn.remove(); card.appendChild(el('div', 'perm-resolved', ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : '')))); } // ---------- rendu des événements ---------- function renderEvent(ev) { if (ev.type === 'user_prompt') { finalizeLive(); finalizeThink(); const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b); return; } if (ev.type === 'status') { setRunning(ev.state === 'running'); return; } if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; } if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; } if (ev.type === 'permission_request') { addPermCard(ev); return; } if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; } if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; } if (ev.type === 'claude') { renderClaude(ev.event); return; } } function renderClaude(ev) { if (ev.type === 'system') { if (ev.subtype === 'init') { addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`))); } else if (ev.subtype === 'compact_boundary') { addMsg(el('div', 'meta-line', '⇣ contexte compacté')); } else if (ev.subtype && ev.subtype !== 'hook') { addMsg(el('div', 'meta-line', esc('⌁ ' + ev.subtype))); } return; } if (ev.type === 'stream_event' && ev.event) { const se = ev.event; if (se.type === 'content_block_delta' && se.delta) { if (se.delta.type === 'text_delta') { const l = ensureLive(); l.text += se.delta.text; renderMd(l.textEl, l.text); scrollBottom(); } else if (se.delta.type === 'thinking_delta') { const t = ensureThink(); t.text += se.delta.thinking; t.el.textContent = t.text.slice(-600); scrollBottom(); } } return; } if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { for (const block of ev.message.content) { if (block.type === 'text' && block.text && block.text.trim()) { finalizeLive(); finalizeThink(); const wrap = el('div', 'msg-claude'); const md = el('div', 'md'); renderMd(md, block.text); wrap.appendChild(md); addMsg(wrap); } else if (block.type === 'tool_use') { addToolCard(block); } else if (block.type === 'thinking' && block.thinking) { if (liveThink) { liveThink.el.remove(); liveThink = null; } const d = el('details', 'thinking-box', 'réflexion'); const inner = el('div', 'thinking'); inner.textContent = block.thinking; d.appendChild(inner); addMsg(d); } } return; } if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) { for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block); return; } if (ev.type === 'result') { finalizeLive(); finalizeThink(); const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : ''; const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : ''; const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : ''; addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns))); if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result)))); return; } } // ---------- méta de session (chips + bandeau) ---------- function refreshChips() { const model = currentChat ? currentChat.model : settings.model; const perm = currentChat ? currentChat.permMode : settings.permMode; $('chipModel').textContent = MODEL_LABEL[model] || model; $('chipPerm').textContent = PERM_LABEL[perm] || perm; $('chipPerm').classList.toggle('warn', perm === 'bypass'); $('chipModel').classList.add('orange'); projectSel.classList.toggle('all-mode', !currentChat && projectSel.value === 'ALL'); const meta = $('sessMeta'); if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) { meta.classList.remove('hidden'); $('metaModel').textContent = (currentChat.autoAllowAll ? '🔓 ' : '') + (currentChat.lastModel || MODEL_LABEL[model]); $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : ''; $('metaCost').textContent = fmt$(currentChat.totalCost); $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok (' + Math.min(99, Math.round(currentChat.contextTokens / 2000)) + '%)' : ''; } else meta.classList.add('hidden'); } // ---------- WebSocket ---------- function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } function setConn(state) { const txt = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne'; for (const id of ['connPill', 'connPillSide']) { const pill = $(id); if (!pill) continue; pill.className = 'conn-pill ' + state; const t = pill.querySelector('.conn-txt'); if (t) t.textContent = txt; } } function connect() { if (!wsWanted) return; setConn('mid'); const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { setConn('on'); wsRetry = 1000; if (currentChat) send({ type: 'open', chatId: currentChat.id }); if (curView === 'eco') send({ type: 'eco_sub' }); }; ws.onclose = () => { setConn('off'); setTimeout(connect, wsRetry); wsRetry = Math.min(wsRetry * 1.6, 20000); }; ws.onmessage = (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } handleServer(msg); }; } function rememberChat(id) { settings.lastChatId = id; saveStore(); } function handleServer(msg) { if (msg.type === 'batch_created') { banner('good', `🌐 Lancé sur ${msg.count}/${msg.total} apps — suis-les dans Sessions`, () => switchView('sessions'), 6000); switchView('sessions'); return; } if (msg.type === 'chat_created') { currentChat = msg.chat; rememberChat(msg.chat.id); refreshChips(); return; } if (msg.type === 'chat_meta') { if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); } return; } if (msg.type === 'history') { currentChat = msg.chat; rememberChat(msg.chat.id); clearConversation(true); for (const ev of msg.events) { try { renderEvent(ev); } catch {} } setRunning(msg.running); for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value; refreshChips(); scrollBottom(true); return; } if (msg.type === 'notify') { if (msg.kind === 'done') { if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) { banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId)); navBadge('chat'); navBadge('sessions'); } } else if (msg.kind === 'perm') { if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) { banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000); navBadge('chat'); } } return; } if (msg.type === 'projects') { // le registre mld a bougé : la liste des apps/nœuds suit fillProjects(msg.projects || []); loadRegistryInfo(); return; } if (msg.type === 'eco_alert') { if (msg.kind === 'registry') { banner('warn', `📦 ${esc(msg.reason || '')} — registre mld`, () => switchView('settings'), 12000); loadRegistryInfo(); if (curView === 'eco') loadEco(); return; } if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); } else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco')); navBadge('eco'); if (curView === 'eco') loadEco(); return; } if (msg.type === 'eco') { if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items); return; } renderEvent(msg); } function clearConversation(keepChat) { messagesEl.innerHTML = ''; toolCards.clear(); permCards.clear(); live = null; liveThink = null; if (!keepChat && !currentChat) $('welcome').classList.remove('hidden'); } function openChat(chatId) { switchView('chat'); currentChat = { id: chatId }; clearConversation(true); send({ type: 'open', chatId }); } // ---------- composer ---------- function doSend(text) { send({ type: 'start', chatId: currentChat ? currentChat.id : null, project: projectSel.value, model: currentChat ? currentChat.model : settings.model, permMode: currentChat ? currentChat.permMode : settings.permMode, prompt: text, }); } function sendPrompt() { const text = inputEl.value.trim(); if (!text) return; // commandes slash locales if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; } if (text === '/cost') { if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`))); inputEl.value = ''; autoGrow(); return; } if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; } inputEl.value = ''; autoGrow(); hideSlash(); // diffusion à toutes les apps KA if (!currentChat && projectSel.value === 'ALL') { broadcastToAll(text); return; } if (isRunning) { // Claude travaille : le message part automatiquement dès la fin du tour queuedPrompt = queuedPrompt ? queuedPrompt + '\n' + text : text; let n = messagesEl.querySelector('.queued-notice'); if (!n) { n = el('div', 'notice-line queued-notice'); messagesEl.appendChild(n); } n.textContent = '⏳ en attente de la fin du tour : « ' + queuedPrompt.slice(0, 80) + ' »'; scrollBottom(true); return; } doSend(text); } function broadcastToAll(text) { if (!confirm('Lancer ce prompt sur TOUTES les apps du Groupe KA en parallèle (une session par app) ?')) return; send({ type: 'start_all', prompt: text, model: settings.model, permMode: settings.permMode }); } sendBtn.onclick = sendPrompt; stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); }; inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); } }); function autoGrow() { inputEl.style.height = 'auto'; inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px'; } inputEl.addEventListener('input', () => { autoGrow(); if (inputEl.value.startsWith('/')) showSlash(); else hideSlash(); }); inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300)); function showSlash() { const h = $('slashHint'); h.classList.remove('hidden'); if (!h.childElementCount) { for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) { const b = el('button', '', `${cmd} ${tip}`); b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); }; h.appendChild(b); } } } function hideSlash() { $('slashHint').classList.add('hidden'); } document.querySelectorAll('.hint').forEach((h) => { h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); }; }); // ---------- upgradeur de prompt (Opus 4.8 + savoir Groupe KA) ---------- const upgradeBtn = $('upgradeBtn'); let upgrading = false; upgradeBtn.onclick = async () => { const raw = inputEl.value.trim(); if (!raw || upgrading) return; upgrading = true; upgradeBtn.classList.add('busy'); const original = inputEl.value; inputEl.disabled = true; inputEl.value = '✨ amélioration du prompt avec Opus 4.8…'; try { const r = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: raw, project: projectSel.value }), }); const d = await r.json(); if (r.ok && d.upgraded) { inputEl.value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); } else { inputEl.value = original; banner('bad', '✗ Upgrade impossible : ' + esc(d.error || 'erreur'), null, 5000); } } catch (e) { inputEl.value = original; banner('bad', '✗ Upgrade impossible (réseau)', null, 5000); } finally { inputEl.disabled = false; upgrading = false; upgradeBtn.classList.remove('busy'); autoGrow(); inputEl.focus(); } }; // ---------- chips ---------- $('chipModel').onclick = () => { const order = ['fable', 'opus', 'sonnet', 'haiku']; const cur = currentChat ? currentChat.model : settings.model; const next = order[(order.indexOf(cur) + 1) % order.length]; applyOpts({ model: next }); }; $('chipPerm').onclick = () => { const order = ['default', 'acceptEdits', 'plan', 'bypass']; const cur = currentChat ? currentChat.permMode : settings.permMode; const next = order[(order.indexOf(cur) + 1) % 4]; if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return; applyOpts({ permMode: next }); }; function applyOpts(opts) { if (currentChat) { Object.assign(currentChat, opts); send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode }); } if (opts.model) settings.model = opts.model; if (opts.permMode) settings.permMode = opts.permMode; saveStore(); refreshChips(); refreshSettingsView(); } $('chipMore').onclick = () => { const c = currentChat; openSheet(`

Session

Projet${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}
Session Claude Code${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}
Modèle du dernier tour${esc(c?.lastModel || '—')}
Coût cumulé${fmt$(c?.totalCost)}
Contexte${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}
`); $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); }; $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); }; $('shNew').onclick = () => { closeSheet(); newChat(); }; }; function newChat() { currentChat = null; queuedPrompt = null; settings.lastChatId = null; saveStore(); clearConversation(); setRunning(false); refreshChips(); switchView('chat'); } // ---------- sessions ---------- let sessCache = []; function sessFilters() { return { q: ($('sessSearch')?.value || '').toLowerCase(), app: $('sessApp')?.value || '', st: $('sessState')?.value || '' }; } function renderSessions() { const { q, app, st } = sessFilters(); const list = $('sessList'); list.innerHTML = ''; const rows = sessCache.filter(c => (!q || (c.title + ' ' + (c.projectName || '')).toLowerCase().includes(q)) && (!app || c.projectId === app) && (!st || c.state === st)); if (!rows.length) { list.appendChild(el('div', 'sess-empty', sessCache.length ? 'Aucune session ne correspond aux filtres.' : 'Aucune session. Lance une conversation dans l’onglet Claude Code.')); return; } const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' }; for (const c of rows) { const card = el('div', 'sess-card row'); card.innerHTML = ` ${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}
${c.batch ? '🌐 ' : ''}${esc(c.title)} ${esc(c.projectName || '')}${MODEL_LABEL[c.model] || c.model}${c.contextTokens ? 'ctx ' + fmtTok(c.contextTokens) : ''}
${fmt$(c.totalCost)} ${timeAgo(c.updated)} `; const btns = card.querySelector('.sess-btns'); const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir'); open.onclick = (e) => { e.stopPropagation(); openChat(c.id); }; btns.appendChild(open); if (c.state === 'idle') { const del = el('button', 'del', '✕'); del.title = 'Supprimer'; del.onclick = async (e) => { e.stopPropagation(); if (!confirm('Supprimer cette session ?')) return; await fetch('/api/chats/' + c.id, { method: 'DELETE' }); sessCache = sessCache.filter(x => x.id !== c.id); renderSessions(); if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } }; btns.appendChild(del); } card.onclick = () => openChat(c.id); list.appendChild(card); } } async function loadSessions() { const r = await fetch('/api/chats'); if (!r.ok) return; sessCache = (await r.json()).chats || []; // remplit le filtre app à partir des sessions existantes const sel = $('sessApp'); if (sel && sel.options.length <= 1) { const seen = new Map(); for (const c of sessCache) if (c.projectId && !seen.has(c.projectId)) seen.set(c.projectId, c.projectName || c.projectId); for (const [id2, name] of seen) { const o = document.createElement('option'); o.value = id2; o.textContent = name; sel.appendChild(o); } } renderSessions(); } $('newSessBtn').onclick = newChat; for (const id2 of ['sessSearch', 'sessApp', 'sessState']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderSessions; } // sessions récentes sur l'écran d'accueil du chat async function loadWelcomeRecent() { const box = $('welcomeRecent'); if (!box) return; try { const { chats } = await (await fetch('/api/chats')).json(); if (!chats.length) { box.innerHTML = ''; return; } box.innerHTML = '
Reprendre une session
' + chats.slice(0, 4).map(c => ` `).join(''); box.querySelectorAll('[data-chat]').forEach(b => { b.onclick = () => openChat(b.dataset.chat); }); } catch {} } // ---------- Prompt Bank ---------- function projectOptionsHTML(selected) { return [...projectSel.options].map(o => ``).join(''); } let promptCache = []; function renderPrompts() { const q = ($('promptSearch')?.value || '').toLowerCase(); const app = $('promptApp')?.value || ''; const list = $('promptList'); list.innerHTML = ''; const rows = promptCache .filter(pr => (!q || (pr.title + ' ' + pr.text).toLowerCase().includes(q)) && (!app || pr.project === app)) .sort((a, b) => (b.fav ? 1 : 0) - (a.fav ? 1 : 0) || (b.updated || 0) - (a.updated || 0)); if (!rows.length) { list.appendChild(el('div', 'sess-empty', promptCache.length ? 'Aucun prompt ne correspond.' : 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; } for (const pr of rows) { const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix'; const card = el('div', 'sess-card prow'); card.innerHTML = `
${esc(pr.title)} 🎯 ${esc(projName)}${pr.uses ? `${pr.uses}× utilisé` : ''}${timeAgo(pr.updated)}
${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}
`; const actions = el('div', 'sess-actions'); const sendB = el('button', 'p-send', 'Lancer →'); const editB = el('button', '', '✎ Éditer'); const upB = el('button', '', '✨ Améliorer'); const dupB = el('button', '', '⧉ Dupliquer'); const delB = el('button', 'del', 'Suppr.'); actions.append(sendB, upB, editB, dupB, delB); card.appendChild(actions); card.querySelector('.fav-btn').onclick = async (e) => { e.stopPropagation(); pr.fav = !pr.fav; await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fav: pr.fav }) }); renderPrompts(); }; sendB.onclick = () => { fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bumpUse: true }) }).catch(() => {}); openSendSheet(pr); }; editB.onclick = () => openPromptEditor(pr); dupB.onclick = async () => { await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: pr.title + ' (copie)', text: pr.text, project: pr.project }) }); loadPrompts(); }; delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); promptCache = promptCache.filter(x => x.id !== pr.id); renderPrompts(); }; upB.onclick = async () => { upB.textContent = '✨ …'; upB.disabled = true; try { const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: pr.text, project: pr.project }) }); const d = await rr.json(); if (rr.ok && d.upgraded) { await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: d.upgraded }) }); banner('good', '✨ Prompt amélioré et enregistré', null, 3000); loadPrompts(); } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000); } catch { banner('bad', '✗ réseau', null, 4000); } upB.textContent = '✨ Améliorer'; upB.disabled = false; }; list.appendChild(card); } } async function loadPrompts() { const r = await fetch('/api/prompts'); if (!r.ok) return; promptCache = (await r.json()).prompts || []; const sel = $('promptApp'); if (sel && sel.options.length <= 1) { const seen = new Set(promptCache.map(p2 => p2.project).filter(Boolean)); for (const id2 of seen) { const o = document.createElement('option'); o.value = id2; o.textContent = [...projectSel.options].find(x => x.value === id2)?.textContent || id2; sel.appendChild(o); } } renderPrompts(); } $('newPromptBtn').onclick = () => openPromptEditor(null); for (const id2 of ['promptSearch', 'promptApp']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderPrompts; } function openPromptEditor(pr) { const isNew = !pr; openSheet(`

${isNew ? 'Nouveau prompt' : 'Éditer le prompt'}

App par défaut
`); $('peUpgrade').onclick = async () => { const t = $('peText').value.trim(); if (!t) return; const b = $('peUpgrade'); b.textContent = '✨ amélioration…'; b.disabled = true; try { const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: t, project: $('peProject').value }) }); const d = await rr.json(); if (rr.ok && d.upgraded) { $('peText').value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000); } catch { banner('bad', '✗ réseau', null, 4000); } b.textContent = '✨ Améliorer (Opus 4.8)'; b.disabled = false; }; $('peSave').onclick = async () => { const text = $('peText').value.trim(); if (!text) { banner('bad', 'Le prompt est vide', null, 3000); return; } const payload = { title: $('peTitle').value, text, project: $('peProject').value || null }; if (isNew) await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); else await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); closeSheet(); loadPrompts(); }; } function openSendSheet(pr) { openSheet(`

Envoyer à Claude Code

${esc(pr.text)}
App cible (nœud)
Modèle · permissions
${MODEL_LABEL[settings.model] || settings.model} · ${PERM_LABEL[settings.permMode] || settings.permMode}(réglages par défaut)
`); $('sendGo').onclick = () => { const proj = $('sendProject').value; closeSheet(); sendPromptToApp(pr.text, proj); }; } function sendPromptToApp(text, projectId) { if (projectId === 'ALL') { broadcastToAll(text); return; } currentChat = null; queuedPrompt = null; clearConversation(); setRunning(false); for (const o of projectSel.options) if (o.value === projectId) projectSel.value = o.value; switchView('chat'); refreshChips(); send({ type: 'start', chatId: null, project: projectId, model: settings.model, permMode: settings.permMode, prompt: text }); banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000); } // incidents globaux (bouton Écosystème + cloche topbar) $('topInc').onclick = () => openIncidents(); $('incBtn').onclick = () => openIncidents(); async function openIncidents() { const tb = $('topIncBadge'); if (tb) tb.classList.add('hidden'); const r = await fetch('/api/eco/incidents'); if (!r.ok) return; const { incidents } = await r.json(); const label = (app) => (ecoData?.sites.find(s => s.app === app)?.label) || app; openSheet(`

Incidents

Historique — 50 derniers

${incidents.length ? incidents.map(i => `
${i.ended ? '✓' : '■'} ${esc(label(i.site))} · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} ${i.ended ? ' → rétabli en ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}
`).join('') : '
Aucun incident enregistré 🎉
'}
`); }; // ---------- écosystème ---------- function siteStatus(s) { if (s.incident || (s.last && !s.last.ok)) return 'down'; if (s.last && s.last.ms > 4000) return 'slow'; return s.last ? 'up' : 'unknown'; } const ST = { up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' }, slow: { cls: 'slow', ico: '▲', lbl: 'LENT' }, down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' }, unknown: { cls: 'slow', ico: '▲', lbl: '…' }, }; function sparkline(spark, w = 120, h = 30) { if (!spark || spark.length < 2) return ''; const ms = spark.map(p => p.ok ? p.ms : null); const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15; const step = w / (spark.length - 1); let d = '', pen = false; spark.forEach((p, i) => { if (!p.ok) { pen = false; return; } const x = i * step, y = h - 3 - (p.ms / max) * (h - 8); d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1); pen = true; }); const fails = spark.map((p, i) => !p.ok ? `` : '').join(''); return `${fails}`; } async function loadEco() { const [r, rn] = await Promise.all([fetch('/api/eco/summary'), fetch('/api/eco/nodes')]); if (!r.ok) return; ecoData = await r.json(); renderEco(); if (rn.ok) renderNodes(await rn.json()); } function nbar(label, val, pct) { const c = pct > 88 ? 'var(--bad)' : pct > 70 ? 'var(--warn)' : 'var(--good)'; return `
${label}${val}
`; } function renderNodes(data) { const grid = $('ecoNodes'); if (!grid) return; grid.innerHTML = ''; for (const n of data.nodes) { const L = n.last; const memPct = L && L.mem_total ? Math.round(L.mem_used / L.mem_total * 100) : null; const upApps = n.apps.filter(a => a.status === 'online').length; const badApp = n.apps.some(a => a.status && a.status !== 'online'); const card = el('div', 'node-card' + (badApp ? ' warn' : '')); card.innerHTML = `
${esc(n.node)} ${upApps}/${n.apps.length} apps
${nbar('charge', L ? L.load1.toFixed(1) : '—', L ? Math.min(100, L.load1 / 32 * 100) : 0)} ${nbar('RAM', memPct !== null ? memPct + '%' : '—', memPct || 0)} ${nbar('disque', L ? L.disk_pct + '%' : '—', L ? L.disk_pct : 0)}`; grid.appendChild(card); } } function applyChecks(items) { if (!ecoData) return; for (const it of items) { const s = ecoData.sites.find(x => x.app === it.site); if (!s) continue; s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err }; s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok }); if (s.spark.length > 40) s.spark.shift(); } renderEco(); } function trendBadge(t) { if (t == null) return ''; if (t <= -8) return `▼ ${Math.abs(t)}%`; if (t >= 8) return `▲ ${t}%`; return `≈`; } function statusBar(sd) { const total = (sd['2xx'] + sd['3xx'] + sd['4xx'] + sd['5xx'] + sd.err) || 1; const seg = (n, cls) => n ? `` : ''; return `
${seg(sd['2xx'], 's2')}${seg(sd['3xx'], 's3')}${seg(sd['4xx'], 's4')}${seg(sd['5xx'], 's5')}${seg(sd.err, 'se')}
`; } function renderEco() { const sites = ecoData.sites; const g = ecoData.global || {}; $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); // bandeau KPI du command center $('ecoOverview').innerHTML = `
${g.up}/${g.total}
en ligne
${g.uptime24 ?? '—'}%
uptime 24h
${g.avgLatency}ms
latence moy.
${(g.checks24 / 1000).toFixed(1)}k
checks 24h
${g.fails24}
échecs 24h
${g.openIncidents}
incidents
`; const grid = $('ecoGrid'); grid.innerHTML = ''; const order = { down: 0, slow: 1, unknown: 2, up: 3 }; for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)] || (b.last?.ms || 0) - (a.last?.ms || 0))) { const st = ST[siteStatus(s)]; const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : '')); card.innerHTML = `
${esc(s.label)} ${st.ico} ${st.lbl}
${s.last?.ok ? s.last.ms : '—'} ms ${trendBadge(s.trend)}
${sparkline(s.spark)}
${statusBar(s.status24 || { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 })}
${s.uptime24 ?? '—'}%uptime
${s.p95_24 ?? '—'}p95 ms
${s.p99_24 ?? '—'}p99 ms
${((s.checks24 || 0) / 1000).toFixed(1)}kchecks
${esc(s.node)} ${s.proc?.status ? `proc ${esc(s.proc.status)}` : ''} ${s.proc?.last_commit ? `commit ${timeAgo(s.proc.last_commit)}` : ''} ${s.certExpires ? `SSL ${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j` : ''}
`; card.onclick = () => openSiteSheet(s.app); grid.appendChild(card); } } async function openSiteSheet(app) { const r = await fetch('/api/eco/site/' + app); if (!r.ok) return; const d = await r.json(); const s = ecoData.sites.find(x => x.app === app) || {}; const st = ST[siteStatus(s)]; const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null; const L = d.lat24 || {}; const C = d.counts24 || {}; const IS = d.incidentStats || {}; const SD = d.status24 || {}; openSheet(`

${esc(d.label)}

${st.ico} ${st.lbl}

Disponibilité

${s.uptime24 ?? '—'}%
24 h
${s.uptime7 ?? '—'}%
7 j
${s.uptime30 ?? '—'}%
30 j
${uptimeDays(d.days)}

Latence — percentiles 24 h ${trendBadge(d.trend)}

${latencyChart(d.series)}
${L.min ?? '—'}min
${L.p50 ?? '—'}p50
${L.p90 ?? '—'}p90
${L.p95 ?? '—'}p95
${L.p99 ?? '—'}p99
${L.max ?? '—'}max

Codes HTTP & fiabilité — 24 h

${statusBar(SD)}
2xx ${SD['2xx'] || 0} 3xx ${SD['3xx'] || 0} 4xx ${SD['4xx'] || 0} 5xx ${SD['5xx'] || 0} err ${SD.err || 0}
${C.total ?? '—'}
checks
${C.fail ?? '—'}
échecs
${C.avgBytes ? Math.round(C.avgBytes / 1024) + 'k' : '—'}
taille moy

Incidents — 30 j

${IS.count30 ?? 0}
incidents
${IS.mttrMin != null ? IS.mttrMin + ' min' : '—'}
MTTR
${IS.longestMin ? IS.longestMin + ' min' : '—'}
pire arrêt

Service

Domaine${esc(d.domain)}
Nœud${esc(d.node)}
Processus${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}
CPU / RAM process${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}
Charge nœud${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}
Dernier commit${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}
Certificat SSL${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}
${d.incidents.length ? `

Incidents — 30 j

${d.incidents.map(i => `
${i.ended ? '✓' : '■'} ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}
`).join('')}
` : ''} ${d.errors.length ? `

Erreurs récentes

${d.errors.slice(0, 8).map(e => `
✗${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 || '')}
`).join('')}
` : ''}

Actions d'administration

↗ Ouvrir le site
`); $('shChat').onclick = () => { closeSheet(); newChat(); for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value; }; const out = () => $('aOut'); const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; }; $('aRestart').onclick = async () => { if (!confirm(`Redémarrer ${d.label} sur ${d.node} ?`)) return; showOut('
redémarrage…
'); const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) }); const j = await r.json(); showOut(`
${j.ok ? '✓ redémarré' : '✗ échec'} — statut : ${esc(j.output || '')}
`); if (navigator.vibrate) navigator.vibrate(60); }; $('aLogs').onclick = async () => { showOut('
chargement des logs…
'); const r = await fetch('/api/admin/logs/' + d.app); const j = await r.json(); showOut(`
${esc((j.logs || '').slice(-4000))}
`); }; $('aCommits').onclick = async () => { showOut('
…
'); const r = await fetch('/api/admin/commits/' + d.app); const j = await r.json(); showOut(`
${esc(j.text || '')}
`); }; attachChartTips(); } function uptimeDays(days) { if (!days) return ''; const cells = days.map(dd => { const u = dd.uptime; const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)'; return `
`; }).join(''); return `
${cells}
il y a 14 jaujourd'hui
`; } function latencyChart(series) { const W = 340, H = 130, PL = 38, PB = 18, PT = 8; if (!series || series.length < 2) return '
Pas encore assez de données.
'; const ok = series.filter(p => p.ok); const max = Math.max(...ok.map(p => p.ms), 100) * 1.15; const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6); const y = (ms) => PT + (1 - ms / max) * (H - PT - PB); let d = '', pen = false; const pts = []; for (const p of series) { if (!p.ok) { pen = false; continue; } const px = x(p.t), py = y(p.ms); d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1); pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t }); pen = true; } const failMarks = series.filter(p => !p.ok).map(p => ``).join(''); const gridLines = [0.25, 0.5, 0.75, 1].map(f => { const gy = y(max * f / 1.15); return ` ${Math.round(max * f / 1.15)}`; }).join(''); const lastPt = pts[pts.length - 1]; const lastLbl = lastPt ? ` ${lastPt.ms} ms` : ''; const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t); const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h'; return `
${gridLines} ${failMarks}${lastLbl} ${fmtH(t0)} ${fmtH(t1)}
`; } function attachChartTips() { document.querySelectorAll('.lat-chart').forEach((svg) => { const pts = JSON.parse(svg.dataset.pts || '[]'); if (!pts.length) return; const wrap = svg.parentElement; let tip = null; const show = (clientX) => { const rect = svg.getBoundingClientRect(); const vx = (clientX - rect.left) / rect.width * 340; let best = pts[0]; for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p; if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); } tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`; tip.style.left = (best.x / 340 * rect.width) + 'px'; tip.style.top = (best.y / 130 * rect.height) + 'px'; }; svg.addEventListener('pointerdown', (e) => show(e.clientX)); svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); }); svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } }); }); } // rafraîchissement périodique de l'onglet éco (en plus du push) setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000); // retour au premier plan (iOS coupe le WS quand l'écran se verrouille) : // reconnexion IMMÉDIATE + resynchronisation de la conversation en cours document.addEventListener('visibilitychange', () => { if (document.hidden) return; if (!ws || ws.readyState !== 1) { wsRetry = 500; try { ws && ws.close(); } catch {} connect(); } else if (currentChat) { send({ type: 'open', chatId: currentChat.id }); } if (curView === 'eco') loadEco(); }); window.addEventListener('online', () => { wsRetry = 500; if (!ws || ws.readyState !== 1) connect(); }); // ---------- feuille ---------- function openSheet(html) { $('sheetBody').innerHTML = html; $('sheet').classList.remove('hidden'); } function closeSheet() { $('sheet').classList.add('hidden'); } $('sheetBack').onclick = closeSheet; // ---------- réglages ---------- function refreshSettingsView() { document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model)); document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode)); $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—'; $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—'; $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—'; } document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); }); document.querySelectorAll('#segPerm button').forEach(b => { b.onclick = () => { if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return; applyOpts({ permMode: b.dataset.v }); }; }); $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } }; $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); }; $('logoutBtn').onclick = async () => { wsWanted = false; await fetch('/api/logout', { method: 'POST' }); location.reload(); }; // ---------- réglages analytics ---------- window.loadAnCfg = async function () { const box = $('anCfg'); if (!box) return; try { const c = await (await fetch('/api/analytics/config')).json(); box.innerHTML = `
Fenêtre de session min
Fenêtre « en ligne » s
Rétention des événements jours
Seuil anti-flood par IP evt/h
IPs internes exclues (préfixes, une par ligne)
User-Agents internes / bots additionnels (fragments)
ASNs exclus (numéros, un par ligne)
`; $('acSave').onclick = async () => { const lines = (id2) => $(id2).value.split('\n').map(s => s.trim()).filter(Boolean); const body = { sessionMin: +$('acSess').value, activeSec: +$('acAct').value, retentionDays: +$('acRet').value, maxIpPerHour: +$('acFlood').value, internalIps: lines('acIps'), internalUAs: lines('acUas'), extraBotUAs: lines('acBots'), blockedASNs: lines('acAsn').map(Number).filter(Boolean), }; const r2 = await fetch('/api/analytics/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); $('acMsg').textContent = r2.ok ? '✓ enregistré et appliqué' : '✗ échec'; setTimeout(() => { $('acMsg').textContent = ''; }, 3000); }; } catch { box.innerHTML = '
Config indisponible.
'; } }; // ---------- login / init ---------- // Sélecteur d'app : rempli depuis /api/projects (dérivé du registre mld) et RE-rempli // quand le serveur annonce un changement de topologie (message WS `projects`). function fillProjects(projects) { const keep = projectSel.value || settings.projectId; projectSel.innerHTML = ''; const allOpt = document.createElement('option'); allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + Math.max(0, projects.length - 1) + ')'; projectSel.appendChild(allOpt); for (const p of projects) { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name + ' · ' + p.node; projectSel.appendChild(o); } // un ancien id « app@nœud » reste valide côté serveur (résolu par app) ; côté UI on // retrouve l'option de la même app si elle a changé de nœud if (keep) { const app = String(keep).split('@')[0]; const match = [...projectSel.options].find(o => o.value === keep) || [...projectSel.options].find(o => o.value.split('@')[0] === app); if (match) { projectSel.value = match.value; if (settings.projectId !== match.value) { settings.projectId = match.value; saveStore(); } } } if (typeof refreshChips === 'function') refreshChips(); } async function loadRegistryInfo() { const el = $('setRegistry'), en = $('setRegistryNodes'); if (!el) return; try { const r = await (await fetch('/api/registry')).json(); const when = r.updated ? r.updated.replace('T', ' ') : 'jamais reçu'; const age = r.ageMs != null ? Math.round(r.ageMs / 60000) : null; el.textContent = r.error ? `⚠ ${r.error}` : `${r.sites.length} apps Ka / ${r.nApps} au registre · mld ${when}${age != null ? ` · lu il y a ${age} min` : ''}${r.stale ? ' · ⚠ PÉRIMÉ' : ''}`; el.title = `source : ${r.source || '?'} · cache ${r.cache || ''}${r.missing?.length ? ` · absentes du registre : ${r.missing.join(', ')}` : ''}`; if (en) en.textContent = (r.nodes || []).map(n => n === r.self ? n + ' (console)' : n).join(' · '); } catch (e) { el.textContent = 'indisponible'; } } async function init() { const me = await fetch('/api/me'); if (!me.ok) { $('login').classList.remove('hidden'); $('app').classList.add('hidden'); return; } const info = await me.json(); $('setHost').textContent = (info.node || '?') + ':3300'; $('login').classList.add('hidden'); $('app').classList.remove('hidden'); const pr = await fetch('/api/projects'); const { projects } = await pr.json(); fillProjects(projects); loadRegistryInfo(); const rb = $('btnRegRefresh'); if (rb) rb.onclick = async () => { rb.disabled = true; rb.textContent = '↻ lecture…'; try { const r = await fetch('/api/registry/refresh', { method: 'POST' }); const d = await r.json(); banner(d.ok ? 'good' : 'bad', d.ok ? `Registre relu (${esc(d.source || '')})${d.events?.length ? ' — ' + d.events.length + ' changement(s)' : ''}` : 'Registre injoignable : ' + esc(d.error || '?')); const pr2 = await fetch('/api/projects'); fillProjects((await pr2.json()).projects || []); } catch (e) { banner('bad', 'Échec : ' + e.message); } rb.disabled = false; rb.textContent = '↻ Relire le registre (M1M32)'; loadRegistryInfo(); }; const tb = $('btnRegTooling'); if (tb) tb.onclick = async () => { tb.disabled = true; tb.textContent = '🧰 vérification…'; try { const d = await (await fetch('/api/registry/tooling', { method: 'POST' })).json(); const lines = (d.nodes || []).map(n => `${n.node} : ${n.local ? 'console (local)' : n.error ? '✗ ' + n.error : (n.actions && n.actions.length ? n.actions.join(' ; ') : 'ok')}`); banner('good', lines.map(esc).join('
'), null, 15000); } catch (e) { banner('bad', 'Échec : ' + e.message); } tb.disabled = false; tb.textContent = '🧰 Vérifier l\'outillage des nœuds'; }; projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); refreshChips(); inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…' : projectSel.value === 'ORCH' ? 'Tâche multi-sites — 1 session qui coordonne tout…' : 'Demander à Claude Code…'; }; refreshChips(); refreshSettingsView(); loadWelcomeRecent(); // reprise automatique : on rouvre la dernière conversation (historique + // live si une tâche tourne encore) même après fermeture complète de l'app if (settings.lastChatId) currentChat = { id: settings.lastChatId }; connect(); // page d'accueil = Vue d'ensemble (le chat garde sa reprise en arrière-plan) switchView('overview'); } $('loginBtn').onclick = doLogin; $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); async function doLogin() { const r = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: $('password').value }), }); if (r.ok) { $('password').value = ''; init(); } else { const d = await r.json().catch(() => ({})); $('loginError').textContent = d.error || 'Erreur'; } } init(); })(); // ===== STUDIO_SOCIAL v3 (générateurs + galerie + loading) ===== (function(){ const $ = (id) => document.getElementById(id); 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","sorti-ka":"Sorti·Ka","job-ka":"Job·Ka",}; // overlay de chargement let ov; function showLoading(msg){ if(!ov){ ov=document.createElement('div'); ov.id='stLoad'; ov.innerHTML='
Ça peut prendre un moment…
'; document.body.appendChild(ov); } ov.querySelector('.stload-msg').textContent=msg||'Génération…'; ov.classList.add('on'); } function hideLoading(){ if(ov) ov.classList.remove('on'); } async function copyText(txt, btn){ try { await navigator.clipboard.writeText(txt); } catch(e){ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(_){} document.body.removeChild(t); } if(btn){ const o=btn.innerHTML; btn.innerHTML='✓ Copié'; setTimeout(()=>btn.innerHTML=o,1400); } } function esc(s){ return (s||'').replace(/&/g,'&').replace(//g,'>'); } function renderGallery(items, generating){ const el=$('stGallery'); if(!el) return; if($('stCount')) $('stCount').textContent = items.length ? (items.length+' visuel'+(items.length>1?'s':'')) : ''; let html = ''; if(generating){ html += '
⏳ en création…
L\'Agent KA génère un visuel…
'; } if(!items.length && !generating){ el.innerHTML='
Aucun visuel encore — génère ton premier post ou reel ci-dessus.
'; return; } html += items.map(function(it,idx){ const media = it.kind==='reel' ? '' : ''; const badge = (SITE_LABEL[it.site]||it.site)+(it.kind==='reel'?' · Reel':' · Post'); return '
'+media+''+esc(badge)+'
' + '
'+esc(it.caption||'')+'
' + '
⬇︎' + '
'; }).join(''); el.innerHTML = html; el.querySelectorAll('[data-copy]').forEach(function(b){ b.onclick=function(){ copyText(items[+b.dataset.copy].caption||'', b); }; }); } let polling=false; async function loadGallery(){ try{ const d=await (await fetch('/api/social/gallery')).json(); const gen = d.gen && d.gen.running; renderGallery(d.items||[], gen); if($('stGenStatus')) $('stGenStatus').textContent = gen ? '⏳ génération des reels en cours…' : ''; if(gen && !polling){ polling=true; setTimeout(function(){ polling=false; loadGallery(); }, 10000); } }catch(e){} } async function genOne(url, body, msg){ showLoading(msg); try{ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); const d=await r.json(); if(!d.draft){ alert('Échec : '+(d.error||'?')); } else { await loadGallery(); } }catch(e){ alert('Échec : '+e.message); } hideLoading(); } function bind(){ if($('stPostGen')) $('stPostGen').onclick=()=>genOne('/api/social/generate',{site:$('stPostSite').value,prompt:$('stPostPrompt').value},'🖼️ Génération de l\'image…'); if($('stReelGen')) $('stReelGen').onclick=()=>genOne('/api/social/reel/generate',{site:$('stReelSite').value,prompt:$('stReelPrompt').value},'🎬 Génération du reel (vidéo + musique)…'); if($('stReel10')) $('stReel10').onclick=async ()=>{ try{ const r=await fetch('/api/social/reel/genbatch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}); const d=await r.json(); if($('stGenStatus')) $('stGenStatus').textContent=d.started?'⏳ 10 reels en génération…':(d.reason||'déjà en cours'); loadGallery(); }catch(e){ alert('Échec : '+e.message); } }; if($('stRefresh')) $('stRefresh').onclick=loadGallery; } bind(); window.__loadGallery = loadGallery; })();