/* 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(`
)
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 = `${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 ``;
}
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 ``;
}
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 ``;
}
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}mslatence moy.
${(g.checks24 / 1000).toFixed(1)}kchecks 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
`);
$('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 `
`;
}
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 '';
}).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;
})();