admin-ka v2 — vrai Claude Code piloté, sessions détachées, Écosystème live, identité Ka
- Modèles par session: Fable 5 (défaut), Opus 5, Sonnet 5, Haiku (--model explicite) - Modes de permission: normal (Autoriser/Refuser/Toujours), acceptEdits, plan, bypass - Sessions détachées du WebSocket + replay du transcript à la reconnexion + notifications - Monitoring Écosystème: 13 sites, checks 45 s, SSL, incidents, sweep nœuds 5 min, SQLite, push WS - UI Groupe KA: crème/orange néo-brutaliste, bottom nav 4 onglets, graphiques SVG validés - /clear /compact /cost /resume, coût cumulé, tokens de contexte Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +1,529 −524
added
CLAUDE.md
+48 −0
@@ -0,0 +1,48 @@ | |||||
| 1 | +# CLAUDE.md — administration-ka.com (Admin-Ka v2) | ||||
| 2 | + | ||||
| 3 | +Console d'administration du Groupe KA : app chat mobile-first qui pilote le **vrai Claude Code CLI** sur le cluster MacLustr, plus un monitoring temps réel de tous les sites du groupe. | ||||
| 4 | + | ||||
| 5 | +## Exigences non négociables | ||||
| 6 | + | ||||
| 7 | +1. **Vrai Claude Code, jamais l'API Messages.** Le backend lance le binaire `claude` (headless : `claude -p --output-format stream-json --verbose --include-partial-messages`), avec `cwd` = le repo de l'app choisie. Chaque app KA s'exécute **sur son nœud de déploiement** (via SSH pour les nœuds distants) — le repo de prod est la seule source de vérité, aucun clone. Tous les événements du stream (texte, thinking, tool_use, tool_result, result) sont relayés et rendus comme dans le terminal (diffs colorés, blocs outils repliables, coût/tokens). | ||||
| 8 | +2. **Options Claude Code dans l'UI** : modèle par session (Opus/Sonnet/Haiku, `--model`), mode de permissions (`default` avec cartes Autoriser/Refuser/Toujours, `acceptEdits`, `plan`, bypass derrière confirmation), choix du projet/cwd, `/clear` `/compact` `/cost` `/resume`, affichage live du modèle actif, session ID, coût cumulé, tokens de contexte. | ||||
| 9 | +3. **Sessions détachées** : le process claude ne dépend JAMAIS du WebSocket. Transcript JSONL persisté par conversation ; à la reconnexion l'app rejoue l'historique manqué puis reprend le live. États : en cours / en attente de permission / inactive. Reconnexion auto avec backoff + indicateur. Notifications in-app quand une tâche se termine ou attend une permission. | ||||
| 10 | +4. **Écosystème** : health checks HTTPS toutes les 45 s des 13 sites KA depuis M3U96a (latence, code, SSL), sweep des nœuds toutes les 5 min (charge/RAM/disque + PM2 cpu/mem/status + dernier commit), historique SQLite (`data/eco.sqlite`, rétention 35 j), incidents (2 échecs consécutifs → alerte, rétablissement → alerte verte), uptime 24h/7j/30j, moy/p95, sparklines + graphique 24 h + barres d'uptime 14 j, push temps réel via le même WebSocket. | ||||
| 11 | +5. **Identité Groupe KA** : crème `#F2F1EC`, orange `#F97316` (traits de graphe `#e05f00`), encre `#1a1611`, cartes à bords noirs 2 px + ombres décalées, pills, typo ronde, pastille « Ka ». Header fixe + bottom nav (Chat / Sessions / Écosystème / Réglages) avec safe-areas. Statuts JAMAIS en couleur seule (● ▲ ■ + libellé). | ||||
| 12 | +6. **Rien de simulé** : tout est branché sur le vrai CLI et les vrais sites. | ||||
| 13 | + | ||||
| 14 | +## Architecture | ||||
| 15 | + | ||||
| 16 | +``` | ||||
| 17 | +iPhone ⇄ wss://www.administration-ka.com (ngrok) ⇄ backend :3300 (M3U96a) | ||||
| 18 | + backend ⇄ claude CLI (local, apps M3U96a) | ||||
| 19 | + backend ⇄ ssh <nœud> claude CLI (apps distantes, repo de prod) | ||||
| 20 | + └ tunnel SSH inverse -R : cartes de permission (perm-mcp MCP) | ||||
| 21 | + backend ⇄ monitor.js : health checks + sweep nœuds + SQLite + push WS | ||||
| 22 | +``` | ||||
| 23 | + | ||||
| 24 | +- `server/server.js` — HTTP + WS + orchestration claude + états de session. | ||||
| 25 | +- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native). | ||||
| 26 | +- `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; déployé aussi sur M3U96b/M4M64a/M4M64b/M2M32 dans `~/.adminka/`. | ||||
| 27 | +- `public/` — frontend une page, 4 onglets, aucun framework. | ||||
| 28 | + | ||||
| 29 | +## Pièges connus (ne pas re-découvrir) | ||||
| 30 | + | ||||
| 31 | +- **Tailscale inter-nœuds bloqué (ACL)** : depuis M3U96a, joindre les nœuds via les noms Bonjour `.local` (config `~/.ssh/config` en place). M2U64 = sous-réseau 192.168.0.x. | ||||
| 32 | +- **IPv4 LAN non routée entre nœuds** (EHOSTUNREACH) : les permissions distantes passent par le tunnel SSH inverse, pas par le LAN. | ||||
| 33 | +- `ControlPath` ssh avec hostnames .local trop long → `%C`. | ||||
| 34 | +- Sans `--model`, les nœuds retombent sur leur défaut local (Sonnet) → toujours passer `--model` explicitement. | ||||
| 35 | +- Auth headless par `ANTHROPIC_API_KEY` de `~/.claude/.env` (pas de login OAuth sur les nœuds). | ||||
| 36 | +- Chaque run reçoit `--append-system-prompt` : après toute modif → rebuild + pm2 restart + healthcheck + commit/push **spbgit** (origin, pas GitHub). | ||||
| 37 | + | ||||
| 38 | +## Exploitation | ||||
| 39 | + | ||||
| 40 | +```bash | ||||
| 41 | +# sur M3U96a | ||||
| 42 | +launchctl kickstart -k gui/501/io.adminka.backend # redémarrer backend | ||||
| 43 | +launchctl kickstart -k gui/501/io.adminka.ngrok # redémarrer tunnel | ||||
| 44 | +tail -f ~/apps/admin-ka/data/backend.err.log | ||||
| 45 | +node server/set-password.js '<nouveau>' # changer le mot de passe | ||||
| 46 | +``` | ||||
| 47 | + | ||||
| 48 | +Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`. | ||||
modified
public/app.js
+542 −105
@@ -1,4 +1,4 @@ | |||||
| 1 | −/* admin-ka — client chat mobile pour Claude Code */ | 1 | +/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */ | ||
| 2 | (() => { | 2 | (() => { | ||
| 3 | const $ = (id) => document.getElementById(id); | 3 | const $ = (id) => document.getElementById(id); | ||
| 4 | const messagesEl = $('messages'); | 4 | const messagesEl = $('messages'); | ||
@@ -6,34 +6,82 @@ | |||||
| 6 | const sendBtn = $('sendBtn'); | 6 | const sendBtn = $('sendBtn'); | ||
| 7 | const stopBtn = $('stopBtn'); | 7 | const stopBtn = $('stopBtn'); | ||
| 8 | const projectSel = $('projectSel'); | 8 | const projectSel = $('projectSel'); | ||
| 9 | − const modeBtn = $('modeBtn'); | ||||
| 10 | − const connDot = $('connDot'); | ||||
| 11 | − const statusText = $('statusText'); | ||||
| 12 | 9 | ||||
| 13 | marked.setOptions({ breaks: true, mangle: false, headerIds: false }); | 10 | marked.setOptions({ breaks: true, mangle: false, headerIds: false }); | ||
| 14 | 11 | ||||
| 15 | − let ws = null; | 12 | + // ---------- état ---------- | ||
| 16 | − let wsRetry = 1000; | 13 | + const store = JSON.parse(localStorage.getItem('adminka') || '{}'); | ||
| 17 | − let currentChat = null; // {id, title, project, mode} | 14 | + const settings = { | ||
| 18 | − let mode = 'safe'; | 15 | + model: store.model || 'fable', | ||
| 16 | + permMode: store.permMode || 'default', | ||||
| 17 | + projectId: store.projectId || null, | ||||
| 18 | + }; | ||||
| 19 | + function saveStore() { localStorage.setItem('adminka', JSON.stringify(settings)); } | ||||
| 20 | + | ||||
| 21 | + let ws = null, wsRetry = 1000, wsWanted = true; | ||||
| 22 | + let currentChat = null; // publicChat du serveur | ||||
| 19 | let isRunning = false; | 23 | let isRunning = false; | ||
| 20 | − let live = null; // bloc de streaming en cours {el, textEl, text} | 24 | + let live = null, liveThink = null; | ||
| 21 | − let liveThink = null; | 25 | + let curView = 'chat'; | ||
| 22 | − const toolCards = new Map(); // tool_use_id -> {card, state, bodyEl} | 26 | + let ecoData = null; | ||
| 23 | − const permCards = new Map(); // requestId -> card | 27 | + const toolCards = new Map(); | ||
| 28 | + const permCards = new Map(); | ||||
| 29 | + | ||||
| 30 | + const MODEL_LABEL = { fable: 'Fable 5', opus: 'Opus 5', sonnet: 'Sonnet 5', haiku: 'Haiku' }; | ||||
| 31 | + const PERM_LABEL = { default: 'normal', acceptEdits: 'acceptEdits', plan: 'plan', bypass: '⚠ bypass' }; | ||||
| 24 | 32 | ||||
| 25 | − // ---------- helpers ---------- | 33 | + // ---------- utilitaires ---------- | ||
| 26 | function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } | 34 | function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } | ||
| 27 | − function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 120; } | ||||
| 28 | − function scrollBottom(force) { | ||||
| 29 | − if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; | ||||
| 30 | − } | ||||
| 31 | function el(tag, cls, html) { | 35 | function el(tag, cls, html) { | ||
| 32 | const e = document.createElement(tag); | 36 | const e = document.createElement(tag); | ||
| 33 | if (cls) e.className = cls; | 37 | if (cls) e.className = cls; | ||
| 34 | if (html !== undefined) e.innerHTML = html; | 38 | if (html !== undefined) e.innerHTML = html; | ||
| 35 | return e; | 39 | return e; | ||
| 36 | } | 40 | } | ||
| 41 | + function fmt$(v) { return (v || 0) < 0.005 ? '<0,01 $' : (v).toFixed(2).replace('.', ',') + ' $'; } | ||||
| 42 | + function fmtTok(v) { return v > 1000 ? Math.round(v / 1000) + 'k' : String(v || 0); } | ||||
| 43 | + function timeAgo(ts) { | ||||
| 44 | + if (!ts) return '—'; | ||||
| 45 | + const s = (Date.now() - ts) / 1000; | ||||
| 46 | + if (s < 90) return 'à l’instant'; | ||||
| 47 | + if (s < 3600) return Math.round(s / 60) + ' min'; | ||||
| 48 | + if (s < 86400) return Math.round(s / 3600) + ' h'; | ||||
| 49 | + return Math.round(s / 86400) + ' j'; | ||||
| 50 | + } | ||||
| 51 | + function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; } | ||||
| 52 | + | ||||
| 53 | + // ---------- navigation ---------- | ||||
| 54 | + const views = ['chat', 'sessions', 'eco', 'settings']; | ||||
| 55 | + function switchView(name) { | ||||
| 56 | + curView = name; | ||||
| 57 | + for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); | ||||
| 58 | + document.querySelectorAll('.nav-btn').forEach((b) => { | ||||
| 59 | + b.classList.toggle('active', b.dataset.view === name); | ||||
| 60 | + if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden'); | ||||
| 61 | + }); | ||||
| 62 | + if (name === 'sessions') loadSessions(); | ||||
| 63 | + if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); } | ||||
| 64 | + if (name === 'settings') refreshSettingsView(); | ||||
| 65 | + } | ||||
| 66 | + document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); }); | ||||
| 67 | + function navBadge(view) { | ||||
| 68 | + if (curView === view) return; | ||||
| 69 | + const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`); | ||||
| 70 | + if (b) b.classList.remove('hidden'); | ||||
| 71 | + } | ||||
| 72 | + | ||||
| 73 | + // ---------- bannières ---------- | ||||
| 74 | + function banner(kind, html, onclick, ttl = 6000) { | ||||
| 75 | + const b = el('div', 'banner ' + kind, html); | ||||
| 76 | + if (onclick) b.onclick = () => { onclick(); b.remove(); }; | ||||
| 77 | + $('banners').appendChild(b); | ||||
| 78 | + if (navigator.vibrate) navigator.vibrate(60); | ||||
| 79 | + setTimeout(() => b.remove(), ttl); | ||||
| 80 | + } | ||||
| 81 | + | ||||
| 82 | + // ---------- scroll / messages ---------- | ||||
| 83 | + function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; } | ||||
| 84 | + function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; } | ||||
| 37 | function addMsg(node) { | 85 | function addMsg(node) { | ||
| 38 | $('welcome').classList.add('hidden'); | 86 | $('welcome').classList.add('hidden'); | ||
| 39 | const wasNear = nearBottom(); | 87 | const wasNear = nearBottom(); | ||
@@ -56,7 +104,6 @@ | |||||
| 56 | return wrap; | 104 | return wrap; | ||
| 57 | } | 105 | } | ||
| 58 | 106 | ||||
| 59 | − // ---------- indicateur "travaille" ---------- | ||||
| 60 | function workingEl() { return messagesEl.querySelector('.working'); } | 107 | function workingEl() { return messagesEl.querySelector('.working'); } | ||
| 61 | function setRunning(r) { | 108 | function setRunning(r) { | ||
| 62 | isRunning = r; | 109 | isRunning = r; | ||
@@ -65,12 +112,10 @@ | |||||
| 65 | let w = workingEl(); | 112 | let w = workingEl(); | ||
| 66 | if (r && !w) { | 113 | if (r && !w) { | ||
| 67 | $('welcome').classList.add('hidden'); | 114 | $('welcome').classList.add('hidden'); | ||
| 68 | − w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude travaille…</span>'); | 115 | + w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span>'); | ||
| 69 | messagesEl.appendChild(w); | 116 | messagesEl.appendChild(w); | ||
| 70 | scrollBottom(true); | 117 | scrollBottom(true); | ||
| 71 | − } else if (!r && w) { | 118 | + } else if (!r && w) w.remove(); | ||
| 72 | − w.remove(); | ||||
| 73 | − } | ||||
| 74 | if (!r) { finalizeLive(); finalizeThink(); } | 119 | if (!r) { finalizeLive(); finalizeThink(); } | ||
| 75 | } | 120 | } | ||
| 76 | 121 | ||||
@@ -94,17 +139,15 @@ | |||||
| 94 | } | 139 | } | ||
| 95 | function finalizeThink() { | 140 | function finalizeThink() { | ||
| 96 | if (!liveThink) return; | 141 | if (!liveThink) return; | ||
| 97 | − const txt = liveThink.text; | 142 | + const d = el('details', 'thinking-box', '<summary>réflexion</summary>'); | ||
| 98 | − const d = el('details', 'thinking-box'); | 143 | + const inner = el('div', 'thinking'); inner.textContent = liveThink.text; | ||
| 99 | − d.innerHTML = '<summary>réflexion</summary>'; | ||||
| 100 | − const inner = el('div', 'thinking'); inner.textContent = txt; | ||||
| 101 | d.appendChild(inner); | 144 | d.appendChild(inner); | ||
| 102 | liveThink.el.replaceWith(d); | 145 | liveThink.el.replaceWith(d); | ||
| 103 | liveThink = null; | 146 | liveThink = null; | ||
| 104 | } | 147 | } | ||
| 105 | 148 | ||||
| 106 | // ---------- cartes outils ---------- | 149 | // ---------- cartes outils ---------- | ||
| 107 | − const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓' }; | 150 | + const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' }; | ||
| 108 | 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' }; | 151 | 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' }; | ||
| 109 | function toolSubtitle(name, input) { | 152 | function toolSubtitle(name, input) { | ||
| 110 | input = input || {}; | 153 | input = input || {}; | ||
@@ -144,12 +187,13 @@ | |||||
| 144 | const ul = el('ul', 'todos'); | 187 | const ul = el('ul', 'todos'); | ||
| 145 | for (const t of input.todos || []) { | 188 | for (const t of input.todos || []) { | ||
| 146 | const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; | 189 | const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; | ||
| 147 | − const li = el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)); | 190 | + ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content))); | ||
| 148 | − ul.appendChild(li); | ||||
| 149 | } | 191 | } | ||
| 150 | body.appendChild(ul); | 192 | body.appendChild(ul); | ||
| 151 | } else if (name === 'Task') { | 193 | } else if (name === 'Task') { | ||
| 152 | body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2))); | 194 | body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2))); | ||
| 195 | + } else if (name === 'ExitPlanMode') { | ||||
| 196 | + const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md); | ||||
| 153 | } else { | 197 | } else { | ||
| 154 | body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500)); | 198 | body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500)); | ||
| 155 | } | 199 | } | ||
@@ -176,8 +220,7 @@ | |||||
| 176 | if (typeof block.content === 'string') text = block.content; | 220 | if (typeof block.content === 'string') text = block.content; | ||
| 177 | else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n'); | 221 | else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n'); | ||
| 178 | if (text.trim()) { | 222 | if (text.trim()) { | ||
| 179 | − const lbl = el('div', 'label', block.is_error ? 'erreur' : 'résultat'); | 223 | + t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat')); | ||
| 180 | − t.bodyEl.appendChild(lbl); | ||||
| 181 | t.bodyEl.appendChild(truncPre(text)); | 224 | t.bodyEl.appendChild(truncPre(text)); | ||
| 182 | } | 225 | } | ||
| 183 | } | 226 | } | ||
@@ -186,26 +229,37 @@ | |||||
| 186 | function addPermCard(ev) { | 229 | function addPermCard(ev) { | ||
| 187 | finalizeLive(); finalizeThink(); | 230 | finalizeLive(); finalizeThink(); | ||
| 188 | const card = el('div', 'perm'); | 231 | const card = el('div', 'perm'); | ||
| 189 | − card.innerHTML = `<div class="perm-title">🔐 Permission demandée — ${esc(ev.tool)}</div>`; | 232 | + const isPlan = ev.tool === 'ExitPlanMode'; | ||
| 190 | − const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre); | 233 | + card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`; | ||
| 234 | + if (isPlan && ev.input?.plan) { | ||||
| 235 | + const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md); | ||||
| 236 | + } else { | ||||
| 237 | + const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre); | ||||
| 238 | + } | ||||
| 191 | const actions = el('div', 'perm-actions'); | 239 | const actions = el('div', 'perm-actions'); | ||
| 192 | const deny = el('button', 'perm-deny', 'Refuser'); | 240 | const deny = el('button', 'perm-deny', 'Refuser'); | ||
| 193 | − const allow = el('button', 'perm-allow', 'Autoriser'); | 241 | + const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser'); | ||
| 194 | actions.append(deny, allow); | 242 | actions.append(deny, allow); | ||
| 243 | + if (!isPlan) { | ||||
| 244 | + const always = el('button', 'perm-always', 'Toujours'); | ||||
| 245 | + always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true }); | ||||
| 246 | + actions.insertBefore(always, allow); | ||||
| 247 | + } | ||||
| 195 | card.appendChild(actions); | 248 | card.appendChild(actions); | ||
| 196 | deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false }); | 249 | deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false }); | ||
| 197 | allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true }); | 250 | allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true }); | ||
| 198 | addMsg(card); | 251 | addMsg(card); | ||
| 199 | permCards.set(ev.requestId, card); | 252 | permCards.set(ev.requestId, card); | ||
| 200 | scrollBottom(true); | 253 | scrollBottom(true); | ||
| 201 | − if (navigator.vibrate) navigator.vibrate(80); | 254 | + if (navigator.vibrate) navigator.vibrate([60, 60, 60]); | ||
| 202 | } | 255 | } | ||
| 203 | function resolvePermCard(ev) { | 256 | function resolvePermCard(ev) { | ||
| 204 | const card = permCards.get(ev.requestId); | 257 | const card = permCards.get(ev.requestId); | ||
| 205 | if (!card) return; | 258 | if (!card) return; | ||
| 206 | const actions = card.querySelector('.perm-actions'); | 259 | const actions = card.querySelector('.perm-actions'); | ||
| 207 | if (actions) actions.remove(); | 260 | if (actions) actions.remove(); | ||
| 208 | − card.appendChild(el('div', 'perm-resolved', ev.allow ? '✓ Autorisé' : '✗ Refusé' + (ev.reason === 'timeout' ? ' (timeout)' : ''))); | 261 | + card.appendChild(el('div', 'perm-resolved', | ||
| 262 | + ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : '')))); | ||||
| 209 | } | 263 | } | ||
| 210 | 264 | ||||
| 211 | // ---------- rendu des événements ---------- | 265 | // ---------- rendu des événements ---------- | ||
@@ -217,14 +271,16 @@ | |||||
| 217 | } | 271 | } | ||
| 218 | if (ev.type === 'status') { setRunning(ev.state === 'running'); return; } | 272 | if (ev.type === 'status') { setRunning(ev.state === 'running'); return; } | ||
| 219 | if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; } | 273 | if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; } | ||
| 274 | + if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; } | ||||
| 220 | if (ev.type === 'permission_request') { addPermCard(ev); return; } | 275 | if (ev.type === 'permission_request') { addPermCard(ev); return; } | ||
| 221 | if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; } | 276 | if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; } | ||
| 277 | + if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; } | ||||
| 222 | if (ev.type === 'claude') { renderClaude(ev.event); return; } | 278 | if (ev.type === 'claude') { renderClaude(ev.event); return; } | ||
| 223 | } | 279 | } | ||
| 224 | 280 | ||||
| 225 | function renderClaude(ev) { | 281 | function renderClaude(ev) { | ||
| 226 | if (ev.type === 'system' && ev.subtype === 'init') { | 282 | if (ev.type === 'system' && ev.subtype === 'init') { | ||
| 227 | − addMsg(el('div', 'meta-line', esc(`session ${String(ev.session_id || '').slice(0, 8)} · ${ev.model || ''} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`))); | 283 | + addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`))); | ||
| 228 | return; | 284 | return; | ||
| 229 | } | 285 | } | ||
| 230 | if (ev.type === 'stream_event' && ev.event) { | 286 | if (ev.type === 'stream_event' && ev.event) { | ||
@@ -256,20 +312,17 @@ | |||||
| 256 | } else if (block.type === 'tool_use') { | 312 | } else if (block.type === 'tool_use') { | ||
| 257 | addToolCard(block); | 313 | addToolCard(block); | ||
| 258 | } else if (block.type === 'thinking' && block.thinking) { | 314 | } else if (block.type === 'thinking' && block.thinking) { | ||
| 259 | − finalizeThink(); | 315 | + if (liveThink) { liveThink.el.remove(); liveThink = null; } | ||
| 260 | const d = el('details', 'thinking-box', '<summary>réflexion</summary>'); | 316 | const d = el('details', 'thinking-box', '<summary>réflexion</summary>'); | ||
| 261 | const inner = el('div', 'thinking'); inner.textContent = block.thinking; | 317 | const inner = el('div', 'thinking'); inner.textContent = block.thinking; | ||
| 262 | d.appendChild(inner); | 318 | d.appendChild(inner); | ||
| 263 | addMsg(d); | 319 | addMsg(d); | ||
| 264 | − if (liveThink) { liveThink.el.remove(); liveThink = null; } | ||||
| 265 | } | 320 | } | ||
| 266 | } | 321 | } | ||
| 267 | return; | 322 | return; | ||
| 268 | } | 323 | } | ||
| 269 | if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) { | 324 | if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) { | ||
| 270 | − for (const block of ev.message.content) { | 325 | + for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block); | ||
| 271 | − if (block.type === 'tool_result') attachToolResult(block); | ||||
| 272 | − } | ||||
| 273 | return; | 326 | return; | ||
| 274 | } | 327 | } | ||
| 275 | if (ev.type === 'result') { | 328 | if (ev.type === 'result') { | ||
@@ -283,67 +336,135 @@ | |||||
| 283 | } | 336 | } | ||
| 284 | } | 337 | } | ||
| 285 | 338 | ||||
| 339 | + // ---------- méta de session (chips + bandeau) ---------- | ||||
| 340 | + function refreshChips() { | ||||
| 341 | + const model = currentChat ? currentChat.model : settings.model; | ||||
| 342 | + const perm = currentChat ? currentChat.permMode : settings.permMode; | ||||
| 343 | + $('chipModel').textContent = MODEL_LABEL[model] || model; | ||||
| 344 | + $('chipPerm').textContent = PERM_LABEL[perm] || perm; | ||||
| 345 | + $('chipPerm').classList.toggle('warn', perm === 'bypass'); | ||||
| 346 | + $('chipModel').classList.add('orange'); | ||||
| 347 | + const meta = $('sessMeta'); | ||||
| 348 | + if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) { | ||||
| 349 | + meta.classList.remove('hidden'); | ||||
| 350 | + $('metaModel').textContent = currentChat.lastModel || MODEL_LABEL[model]; | ||||
| 351 | + $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : ''; | ||||
| 352 | + $('metaCost').textContent = fmt$(currentChat.totalCost); | ||||
| 353 | + $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok' : ''; | ||||
| 354 | + } else meta.classList.add('hidden'); | ||||
| 355 | + } | ||||
| 356 | + | ||||
| 286 | // ---------- WebSocket ---------- | 357 | // ---------- WebSocket ---------- | ||
| 287 | function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } | 358 | function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } | ||
| 359 | + function setConn(state) { | ||||
| 360 | + const pill = $('connPill'); | ||||
| 361 | + pill.className = 'conn-pill ' + state; | ||||
| 362 | + $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne'; | ||||
| 363 | + } | ||||
| 288 | function connect() { | 364 | function connect() { | ||
| 365 | + if (!wsWanted) return; | ||||
| 366 | + setConn('mid'); | ||||
| 289 | const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | 367 | const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | ||
| 290 | ws = new WebSocket(`${proto}://${location.host}/ws`); | 368 | ws = new WebSocket(`${proto}://${location.host}/ws`); | ||
| 291 | ws.onopen = () => { | 369 | ws.onopen = () => { | ||
| 292 | − connDot.classList.add('on'); | 370 | + setConn('on'); | ||
| 293 | wsRetry = 1000; | 371 | wsRetry = 1000; | ||
| 294 | if (currentChat) send({ type: 'open', chatId: currentChat.id }); | 372 | if (currentChat) send({ type: 'open', chatId: currentChat.id }); | ||
| 373 | + if (curView === 'eco') send({ type: 'eco_sub' }); | ||||
| 295 | }; | 374 | }; | ||
| 296 | ws.onclose = () => { | 375 | ws.onclose = () => { | ||
| 297 | − connDot.classList.remove('on'); | 376 | + setConn('off'); | ||
| 298 | setTimeout(connect, wsRetry); | 377 | setTimeout(connect, wsRetry); | ||
| 299 | wsRetry = Math.min(wsRetry * 1.6, 20000); | 378 | wsRetry = Math.min(wsRetry * 1.6, 20000); | ||
| 300 | }; | 379 | }; | ||
| 301 | ws.onmessage = (e) => { | 380 | ws.onmessage = (e) => { | ||
| 302 | let msg; | 381 | let msg; | ||
| 303 | try { msg = JSON.parse(e.data); } catch { return; } | 382 | try { msg = JSON.parse(e.data); } catch { return; } | ||
| 304 | − if (msg.type === 'chat_created') { | 383 | + handleServer(msg); | ||
| 305 | − currentChat = msg.chat; | ||||
| 306 | − return; | ||||
| 307 | − } | ||||
| 308 | − if (msg.type === 'history') { | ||||
| 309 | − currentChat = msg.chat; | ||||
| 310 | − clearConversation(); | ||||
| 311 | − for (const ev of msg.events) { try { renderEvent(ev); } catch {} } | ||||
| 312 | − setRunning(msg.running); | ||||
| 313 | − for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value; | ||||
| 314 | − scrollBottom(true); | ||||
| 315 | − return; | ||||
| 316 | − } | ||||
| 317 | − renderEvent(msg); | ||||
| 318 | }; | 384 | }; | ||
| 319 | } | 385 | } | ||
| 320 | − function clearConversation() { | 386 | + function handleServer(msg) { | ||
| 387 | + if (msg.type === 'chat_created') { currentChat = msg.chat; refreshChips(); return; } | ||||
| 388 | + if (msg.type === 'chat_meta') { | ||||
| 389 | + if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); } | ||||
| 390 | + return; | ||||
| 391 | + } | ||||
| 392 | + if (msg.type === 'history') { | ||||
| 393 | + currentChat = msg.chat; | ||||
| 394 | + clearConversation(true); | ||||
| 395 | + for (const ev of msg.events) { try { renderEvent(ev); } catch {} } | ||||
| 396 | + setRunning(msg.running); | ||||
| 397 | + for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value; | ||||
| 398 | + refreshChips(); | ||||
| 399 | + scrollBottom(true); | ||||
| 400 | + return; | ||||
| 401 | + } | ||||
| 402 | + if (msg.type === 'notify') { | ||||
| 403 | + if (msg.kind === 'done') { | ||||
| 404 | + if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) { | ||||
| 405 | + banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId)); | ||||
| 406 | + navBadge('chat'); navBadge('sessions'); | ||||
| 407 | + } | ||||
| 408 | + } else if (msg.kind === 'perm') { | ||||
| 409 | + if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) { | ||||
| 410 | + banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000); | ||||
| 411 | + navBadge('chat'); | ||||
| 412 | + } | ||||
| 413 | + } | ||||
| 414 | + return; | ||||
| 415 | + } | ||||
| 416 | + if (msg.type === 'eco_alert') { | ||||
| 417 | + if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); | ||||
| 418 | + else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco')); | ||||
| 419 | + navBadge('eco'); | ||||
| 420 | + if (curView === 'eco') loadEco(); | ||||
| 421 | + return; | ||||
| 422 | + } | ||||
| 423 | + if (msg.type === 'eco') { | ||||
| 424 | + if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items); | ||||
| 425 | + return; | ||||
| 426 | + } | ||||
| 427 | + renderEvent(msg); | ||||
| 428 | + } | ||||
| 429 | + | ||||
| 430 | + function clearConversation(keepChat) { | ||||
| 321 | messagesEl.innerHTML = ''; | 431 | messagesEl.innerHTML = ''; | ||
| 322 | toolCards.clear(); permCards.clear(); | 432 | toolCards.clear(); permCards.clear(); | ||
| 323 | live = null; liveThink = null; | 433 | live = null; liveThink = null; | ||
| 324 | − if (!currentChat) $('welcome').classList.remove('hidden'); | 434 | + if (!keepChat && !currentChat) $('welcome').classList.remove('hidden'); | ||
| 435 | + } | ||||
| 436 | + function openChat(chatId) { | ||||
| 437 | + switchView('chat'); | ||||
| 438 | + currentChat = { id: chatId }; | ||||
| 439 | + clearConversation(true); | ||||
| 440 | + send({ type: 'open', chatId }); | ||||
| 325 | } | 441 | } | ||
| 326 | − // suggestions de l'écran d'accueil → composer | ||||
| 327 | − document.querySelectorAll('.hint').forEach((h) => { | ||||
| 328 | − h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); }; | ||||
| 329 | − }); | ||||
| 330 | 442 | ||||
| 331 | − // ---------- actions ---------- | 443 | + // ---------- composer ---------- | ||
| 332 | function sendPrompt() { | 444 | function sendPrompt() { | ||
| 333 | const text = inputEl.value.trim(); | 445 | const text = inputEl.value.trim(); | ||
| 334 | if (!text || isRunning) return; | 446 | if (!text || isRunning) return; | ||
| 447 | + // commandes slash locales | ||||
| 448 | + if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; } | ||||
| 449 | + if (text === '/cost') { | ||||
| 450 | + if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`))); | ||||
| 451 | + inputEl.value = ''; autoGrow(); return; | ||||
| 452 | + } | ||||
| 453 | + if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; } | ||||
| 335 | inputEl.value = ''; | 454 | inputEl.value = ''; | ||
| 336 | autoGrow(); | 455 | autoGrow(); | ||
| 456 | + hideSlash(); | ||||
| 337 | send({ | 457 | send({ | ||
| 338 | type: 'start', | 458 | type: 'start', | ||
| 339 | chatId: currentChat ? currentChat.id : null, | 459 | chatId: currentChat ? currentChat.id : null, | ||
| 340 | project: projectSel.value, | 460 | project: projectSel.value, | ||
| 341 | − mode, | 461 | + model: currentChat ? currentChat.model : settings.model, | ||
| 462 | + permMode: currentChat ? currentChat.permMode : settings.permMode, | ||||
| 342 | prompt: text, | 463 | prompt: text, | ||
| 343 | }); | 464 | }); | ||
| 344 | } | 465 | } | ||
| 345 | sendBtn.onclick = sendPrompt; | 466 | sendBtn.onclick = sendPrompt; | ||
| 346 | − stopBtn.onclick = () => send({ type: 'interrupt' }); | 467 | + stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); }; | ||
| 347 | inputEl.addEventListener('keydown', (e) => { | 468 | inputEl.addEventListener('keydown', (e) => { | ||
| 348 | if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); } | 469 | if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); } | ||
| 349 | }); | 470 | }); | ||
@@ -351,57 +472,367 @@ | |||||
| 351 | inputEl.style.height = 'auto'; | 472 | inputEl.style.height = 'auto'; | ||
| 352 | inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px'; | 473 | inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px'; | ||
| 353 | } | 474 | } | ||
| 354 | − inputEl.addEventListener('input', autoGrow); | 475 | + inputEl.addEventListener('input', () => { | ||
| 476 | + autoGrow(); | ||||
| 477 | + if (inputEl.value.startsWith('/')) showSlash(); else hideSlash(); | ||||
| 478 | + }); | ||||
| 355 | inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300)); | 479 | inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300)); | ||
| 356 | 480 | ||||
| 357 | − $('newBtn').onclick = () => { currentChat = null; clearConversation(); setRunning(false); }; | 481 | + function showSlash() { | ||
| 482 | + const h = $('slashHint'); | ||||
| 483 | + h.classList.remove('hidden'); | ||||
| 484 | + if (!h.childElementCount) { | ||||
| 485 | + for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) { | ||||
| 486 | + const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`); | ||||
| 487 | + b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); }; | ||||
| 488 | + h.appendChild(b); | ||||
| 489 | + } | ||||
| 490 | + } | ||||
| 491 | + } | ||||
| 492 | + function hideSlash() { $('slashHint').classList.add('hidden'); } | ||||
| 358 | 493 | ||||
| 359 | − modeBtn.onclick = () => { | 494 | + document.querySelectorAll('.hint').forEach((h) => { | ||
| 360 | − if (mode === 'safe') { | 495 | + h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); }; | ||
| 361 | − if (!confirm('Mode AUTO : Claude exécutera toutes les actions sans demander de permission. Continuer ?')) return; | 496 | + }); | ||
| 362 | − mode = 'auto'; | 497 | + | ||
| 363 | − } else { | 498 | + // ---------- chips ---------- | ||
| 364 | − mode = 'safe'; | 499 | + $('chipModel').onclick = () => { | ||
| 500 | + const order = ['fable', 'opus', 'sonnet', 'haiku']; | ||||
| 501 | + const cur = currentChat ? currentChat.model : settings.model; | ||||
| 502 | + const next = order[(order.indexOf(cur) + 1) % order.length]; | ||||
| 503 | + applyOpts({ model: next }); | ||||
| 504 | + }; | ||||
| 505 | + $('chipPerm').onclick = () => { | ||||
| 506 | + const order = ['default', 'acceptEdits', 'plan', 'bypass']; | ||||
| 507 | + const cur = currentChat ? currentChat.permMode : settings.permMode; | ||||
| 508 | + const next = order[(order.indexOf(cur) + 1) % 4]; | ||||
| 509 | + if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return; | ||||
| 510 | + applyOpts({ permMode: next }); | ||||
| 511 | + }; | ||||
| 512 | + function applyOpts(opts) { | ||||
| 513 | + if (currentChat) { | ||||
| 514 | + Object.assign(currentChat, opts); | ||||
| 515 | + send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode }); | ||||
| 365 | } | 516 | } | ||
| 366 | − modeBtn.textContent = mode === 'auto' ? 'Auto' : 'Sûr'; | 517 | + if (opts.model) settings.model = opts.model; | ||
| 367 | − modeBtn.classList.toggle('auto', mode === 'auto'); | 518 | + if (opts.permMode) settings.permMode = opts.permMode; | ||
| 519 | + saveStore(); | ||||
| 520 | + refreshChips(); | ||||
| 521 | + refreshSettingsView(); | ||||
| 522 | + } | ||||
| 523 | + $('chipMore').onclick = () => { | ||||
| 524 | + const c = currentChat; | ||||
| 525 | + openSheet(` | ||||
| 526 | + <div class="sheet-h"><h3>Session</h3></div> | ||||
| 527 | + <div class="sheet-sec"> | ||||
| 528 | + <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div> | ||||
| 529 | + <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div> | ||||
| 530 | + <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div> | ||||
| 531 | + <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div> | ||||
| 532 | + <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div> | ||||
| 533 | + </div> | ||||
| 534 | + <div class="sheet-sec"> | ||||
| 535 | + <div class="set-actions"> | ||||
| 536 | + <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button> | ||||
| 537 | + <button class="btn-ghost" id="shCompact">/compact</button> | ||||
| 538 | + <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button> | ||||
| 539 | + </div> | ||||
| 540 | + </div>`); | ||||
| 541 | + $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); }; | ||||
| 542 | + $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); }; | ||||
| 543 | + $('shNew').onclick = () => { closeSheet(); newChat(); }; | ||||
| 368 | }; | 544 | }; | ||
| 545 | + function newChat() { | ||||
| 546 | + currentChat = null; | ||||
| 547 | + clearConversation(); | ||||
| 548 | + setRunning(false); | ||||
| 549 | + refreshChips(); | ||||
| 550 | + switchView('chat'); | ||||
| 551 | + } | ||||
| 369 | 552 | ||||
| 370 | − // ---------- drawer ---------- | 553 | + // ---------- sessions ---------- | ||
| 371 | − const drawer = $('drawer'); | 554 | + async function loadSessions() { | ||
| 372 | − $('menuBtn').onclick = async () => { | ||||
| 373 | − drawer.classList.remove('hidden'); | ||||
| 374 | const r = await fetch('/api/chats'); | 555 | const r = await fetch('/api/chats'); | ||
| 375 | if (!r.ok) return; | 556 | if (!r.ok) return; | ||
| 376 | const { chats } = await r.json(); | 557 | const { chats } = await r.json(); | ||
| 377 | − const list = $('chatList'); | 558 | + const list = $('sessList'); | ||
| 378 | list.innerHTML = ''; | 559 | list.innerHTML = ''; | ||
| 379 | − if (!chats.length) list.appendChild(el('div', 'chat-empty', 'Aucune conversation')); | 560 | + if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; } | ||
| 561 | + const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' }; | ||||
| 380 | for (const c of chats) { | 562 | for (const c of chats) { | ||
| 381 | − const item = el('div', 'chat-item' + (currentChat && currentChat.id === c.id ? ' active' : '')); | 563 | + const card = el('div', 'sess-card'); | ||
| 382 | − const when = new Date(c.updated).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); | 564 | + card.innerHTML = ` | ||
| 383 | − item.innerHTML = `<div class="ci-main"><span class="ci-title">${esc(c.title)}</span><span class="ci-meta">${esc(c.projectName || '')} · ${when}${c.running ? ' · <span class="ci-running">● en cours</span>' : ''}</span></div>`; | 565 | + <div class="sess-top"> | ||
| 384 | − const delBtn = el('button', 'ci-del', '✕'); | 566 | + <span class="sess-title">${esc(c.title)}</span> | ||
| 385 | − delBtn.onclick = async (e) => { | 567 | + <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span> | ||
| 386 | − e.stopPropagation(); | 568 | + </div> | ||
| 387 | − if (!confirm('Supprimer cette conversation ?')) return; | 569 | + <div class="sess-info"> | ||
| 388 | − await fetch('/api/chats/' + c.id, { method: 'DELETE' }); | 570 | + <span>${esc(c.projectName || '')}</span> | ||
| 389 | − item.remove(); | 571 | + <span>${MODEL_LABEL[c.model] || c.model}</span> | ||
| 390 | − if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } | 572 | + <span>${fmt$(c.totalCost)}</span> | ||
| 391 | − }; | 573 | + <span>${timeAgo(c.updated)}</span> | ||
| 392 | − if (!c.running) item.appendChild(delBtn); | 574 | + </div>`; | ||
| 393 | − item.onclick = () => { | 575 | + const actions = el('div', 'sess-actions'); | ||
| 394 | − drawer.classList.add('hidden'); | 576 | + const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir'); | ||
| 395 | − currentChat = { id: c.id }; | 577 | + open.onclick = (e) => { e.stopPropagation(); openChat(c.id); }; | ||
| 396 | − clearConversation(); | 578 | + actions.appendChild(open); | ||
| 397 | − send({ type: 'open', chatId: c.id }); | 579 | + if (c.state === 'idle') { | ||
| 398 | − }; | 580 | + const del = el('button', 'del', 'Supprimer'); | ||
| 399 | − list.appendChild(item); | 581 | + del.onclick = async (e) => { | ||
| 582 | + e.stopPropagation(); | ||||
| 583 | + if (!confirm('Supprimer cette session ?')) return; | ||||
| 584 | + await fetch('/api/chats/' + c.id, { method: 'DELETE' }); | ||||
| 585 | + card.remove(); | ||||
| 586 | + if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } | ||||
| 587 | + }; | ||||
| 588 | + actions.appendChild(del); | ||||
| 589 | + } | ||||
| 590 | + card.appendChild(actions); | ||||
| 591 | + card.onclick = () => openChat(c.id); | ||||
| 592 | + list.appendChild(card); | ||||
| 400 | } | 593 | } | ||
| 594 | + } | ||||
| 595 | + $('newSessBtn').onclick = newChat; | ||||
| 596 | + | ||||
| 597 | + // ---------- écosystème ---------- | ||||
| 598 | + function siteStatus(s) { | ||||
| 599 | + if (s.incident || (s.last && !s.last.ok)) return 'down'; | ||||
| 600 | + if (s.last && s.last.ms > 4000) return 'slow'; | ||||
| 601 | + return s.last ? 'up' : 'unknown'; | ||||
| 602 | + } | ||||
| 603 | + const ST = { | ||||
| 604 | + up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' }, | ||||
| 605 | + slow: { cls: 'slow', ico: '▲', lbl: 'LENT' }, | ||||
| 606 | + down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' }, | ||||
| 607 | + unknown: { cls: 'slow', ico: '▲', lbl: '…' }, | ||||
| 401 | }; | 608 | }; | ||
| 402 | − $('drawerClose').onclick = () => drawer.classList.add('hidden'); | 609 | + function sparkline(spark, w = 120, h = 30) { | ||
| 403 | − $('drawerBack').onclick = () => drawer.classList.add('hidden'); | 610 | + if (!spark || spark.length < 2) return '<svg class="spark"></svg>'; | ||
| 611 | + const ms = spark.map(p => p.ok ? p.ms : null); | ||||
| 612 | + const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15; | ||||
| 613 | + const step = w / (spark.length - 1); | ||||
| 614 | + let d = '', pen = false; | ||||
| 615 | + spark.forEach((p, i) => { | ||||
| 616 | + if (!p.ok) { pen = false; return; } | ||||
| 617 | + const x = i * step, y = h - 3 - (p.ms / max) * (h - 8); | ||||
| 618 | + d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1); | ||||
| 619 | + pen = true; | ||||
| 620 | + }); | ||||
| 621 | + const fails = spark.map((p, i) => !p.ok ? `<rect x="${(i * step - 1.5).toFixed(1)}" y="${h - 7}" width="3" height="5" fill="var(--bad)"/>` : '').join(''); | ||||
| 622 | + return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>${fails}</svg>`; | ||||
| 623 | + } | ||||
| 624 | + async function loadEco() { | ||||
| 625 | + const r = await fetch('/api/eco/summary'); | ||||
| 626 | + if (!r.ok) return; | ||||
| 627 | + ecoData = await r.json(); | ||||
| 628 | + renderEco(); | ||||
| 629 | + } | ||||
| 630 | + function applyChecks(items) { | ||||
| 631 | + if (!ecoData) return; | ||||
| 632 | + for (const it of items) { | ||||
| 633 | + const s = ecoData.sites.find(x => x.app === it.site); | ||||
| 634 | + if (!s) continue; | ||||
| 635 | + s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err }; | ||||
| 636 | + s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok }); | ||||
| 637 | + if (s.spark.length > 40) s.spark.shift(); | ||||
| 638 | + } | ||||
| 639 | + renderEco(); | ||||
| 640 | + } | ||||
| 641 | + function renderEco() { | ||||
| 642 | + const sites = ecoData.sites; | ||||
| 643 | + const up = sites.filter(s => siteStatus(s) === 'up').length; | ||||
| 644 | + const down = sites.filter(s => siteStatus(s) === 'down').length; | ||||
| 645 | + const lat = sites.filter(s => s.last?.ok).map(s => s.last.ms); | ||||
| 646 | + const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : 0; | ||||
| 647 | + $('ecoStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); | ||||
| 648 | + $('ecoOverview').innerHTML = ` | ||||
| 649 | + <div class="eco-stat ok"><div class="v">${up}/${sites.length}</div><div class="l">en ligne</div></div> | ||||
| 650 | + <div class="eco-stat ${down ? 'bad' : ''}"><div class="v">${down}</div><div class="l">hors ligne</div></div> | ||||
| 651 | + <div class="eco-stat"><div class="v">${avg}<small style="font-size:12px"> ms</small></div><div class="l">latence moy.</div></div>`; | ||||
| 652 | + const grid = $('ecoGrid'); | ||||
| 653 | + grid.innerHTML = ''; | ||||
| 654 | + const order = { down: 0, slow: 1, unknown: 2, up: 3 }; | ||||
| 655 | + for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)])) { | ||||
| 656 | + const st = ST[siteStatus(s)]; | ||||
| 657 | + const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : '')); | ||||
| 658 | + card.innerHTML = ` | ||||
| 659 | + <div class="site-top"> | ||||
| 660 | + <span class="site-name">${esc(s.label)}</span> | ||||
| 661 | + <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span> | ||||
| 662 | + </div> | ||||
| 663 | + <div class="site-mid"> | ||||
| 664 | + <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small></div> | ||||
| 665 | + ${sparkline(s.spark)} | ||||
| 666 | + </div> | ||||
| 667 | + <div class="site-foot"> | ||||
| 668 | + <span>up 24h <b>${s.uptime24 ?? '—'}%</b></span> | ||||
| 669 | + <span>p95 <b>${s.p95_24 ?? '—'} ms</b></span> | ||||
| 670 | + <span>${esc(s.node)}</span> | ||||
| 671 | + ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''} | ||||
| 672 | + </div>`; | ||||
| 673 | + card.onclick = () => openSiteSheet(s.app); | ||||
| 674 | + grid.appendChild(card); | ||||
| 675 | + } | ||||
| 676 | + } | ||||
| 677 | + | ||||
| 678 | + async function openSiteSheet(app) { | ||||
| 679 | + const r = await fetch('/api/eco/site/' + app); | ||||
| 680 | + if (!r.ok) return; | ||||
| 681 | + const d = await r.json(); | ||||
| 682 | + const s = ecoData.sites.find(x => x.app === app) || {}; | ||||
| 683 | + const st = ST[siteStatus(s)]; | ||||
| 684 | + const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null; | ||||
| 685 | + openSheet(` | ||||
| 686 | + <div class="sheet-h"> | ||||
| 687 | + <h3>${esc(d.label)}</h3> | ||||
| 688 | + <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span> | ||||
| 689 | + </div> | ||||
| 690 | + <div class="sheet-sec"> | ||||
| 691 | + <h4>Disponibilité</h4> | ||||
| 692 | + <div class="tile-row"> | ||||
| 693 | + <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div> | ||||
| 694 | + <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div> | ||||
| 695 | + <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div> | ||||
| 696 | + </div> | ||||
| 697 | + <div style="margin-top:10px">${uptimeDays(d.days)}</div> | ||||
| 698 | + </div> | ||||
| 699 | + <div class="sheet-sec"> | ||||
| 700 | + <h4>Latence — 24 h</h4> | ||||
| 701 | + ${latencyChart(d.series)} | ||||
| 702 | + <div class="tile-row" style="margin-top:10px"> | ||||
| 703 | + <div class="tile"><div class="v">${s.avg24 ?? '—'}</div><div class="l">moy ms</div></div> | ||||
| 704 | + <div class="tile"><div class="v">${s.p95_24 ?? '—'}</div><div class="l">p95 ms</div></div> | ||||
| 705 | + <div class="tile"><div class="v">${s.last?.code || '—'}</div><div class="l">HTTP</div></div> | ||||
| 706 | + </div> | ||||
| 707 | + </div> | ||||
| 708 | + <div class="sheet-sec"> | ||||
| 709 | + <h4>Service</h4> | ||||
| 710 | + <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div> | ||||
| 711 | + <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div> | ||||
| 712 | + <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div> | ||||
| 713 | + <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div> | ||||
| 714 | + <div class="kv"><span>Charge nœud</span><b>${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}</b></div> | ||||
| 715 | + <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div> | ||||
| 716 | + <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div> | ||||
| 717 | + </div> | ||||
| 718 | + ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => ` | ||||
| 719 | + <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span> | ||||
| 720 | + <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} | ||||
| 721 | + ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''} | ||||
| 722 | + ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => ` | ||||
| 723 | + <div class="inc-item"><span class="inc-dot">✗</span><span>${new Date(e.ts).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} · ${e.code ? 'HTTP ' + e.code : esc(e.err || '')}</span></div>`).join('')}</div>` : ''} | ||||
| 724 | + <button class="btn-cta" style="width:100%" id="shChat">Ouvrir dans le chat →</button> | ||||
| 725 | + `); | ||||
| 726 | + $('shChat').onclick = () => { | ||||
| 727 | + closeSheet(); | ||||
| 728 | + newChat(); | ||||
| 729 | + for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value; | ||||
| 730 | + }; | ||||
| 731 | + attachChartTips(); | ||||
| 732 | + } | ||||
| 733 | + | ||||
| 734 | + function uptimeDays(days) { | ||||
| 735 | + if (!days) return ''; | ||||
| 736 | + const cells = days.map(dd => { | ||||
| 737 | + const u = dd.uptime; | ||||
| 738 | + const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)'; | ||||
| 739 | + return `<div title="${new Date(dd.day).toLocaleDateString('fr-CA')} — ${u === null ? 'aucune donnée' : u + '%'}" style="flex:1;height:22px;border-radius:5px;background:${c};border:1.5px solid var(--line)"></div>`; | ||||
| 740 | + }).join(''); | ||||
| 741 | + return `<div style="display:flex;gap:4px">${cells}</div><div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:3px"><span>il y a 14 j</span><span>aujourd'hui</span></div>`; | ||||
| 742 | + } | ||||
| 743 | + | ||||
| 744 | + function latencyChart(series) { | ||||
| 745 | + const W = 340, H = 130, PL = 38, PB = 18, PT = 8; | ||||
| 746 | + if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>'; | ||||
| 747 | + const ok = series.filter(p => p.ok); | ||||
| 748 | + const max = Math.max(...ok.map(p => p.ms), 100) * 1.15; | ||||
| 749 | + const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6); | ||||
| 750 | + const y = (ms) => PT + (1 - ms / max) * (H - PT - PB); | ||||
| 751 | + let d = '', pen = false; | ||||
| 752 | + const pts = []; | ||||
| 753 | + for (const p of series) { | ||||
| 754 | + if (!p.ok) { pen = false; continue; } | ||||
| 755 | + const px = x(p.t), py = y(p.ms); | ||||
| 756 | + d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1); | ||||
| 757 | + pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t }); | ||||
| 758 | + pen = true; | ||||
| 759 | + } | ||||
| 760 | + const failMarks = series.filter(p => !p.ok).map(p => | ||||
| 761 | + `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join(''); | ||||
| 762 | + const gridLines = [0.25, 0.5, 0.75, 1].map(f => { | ||||
| 763 | + const gy = y(max * f / 1.15); | ||||
| 764 | + return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/> | ||||
| 765 | + <text x="${PL - 5}" y="${gy + 3.5}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${Math.round(max * f / 1.15)}</text>`; | ||||
| 766 | + }).join(''); | ||||
| 767 | + const lastPt = pts[pts.length - 1]; | ||||
| 768 | + const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/> | ||||
| 769 | + <text x="${Math.min(lastPt.x, W - 40)}" y="${Math.max(lastPt.y - 8, 10)}" font-size="10" font-weight="800" fill="var(--ink)" font-family="var(--mono)">${lastPt.ms} ms</text>` : ''; | ||||
| 770 | + const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t); | ||||
| 771 | + const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h'; | ||||
| 772 | + return `<div style="position:relative"> | ||||
| 773 | + <svg class="chart lat-chart" viewBox="0 0 ${W} ${H}" data-pts='${JSON.stringify(pts.map(p => ({ x: +p.x.toFixed(1), y: +p.y.toFixed(1), ms: p.ms, t: p.t })))}'> | ||||
| 774 | + ${gridLines} | ||||
| 775 | + <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/> | ||||
| 776 | + ${failMarks}${lastLbl} | ||||
| 777 | + <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text> | ||||
| 778 | + <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text> | ||||
| 779 | + </svg></div>`; | ||||
| 780 | + } | ||||
| 781 | + function attachChartTips() { | ||||
| 782 | + document.querySelectorAll('.lat-chart').forEach((svg) => { | ||||
| 783 | + const pts = JSON.parse(svg.dataset.pts || '[]'); | ||||
| 784 | + if (!pts.length) return; | ||||
| 785 | + const wrap = svg.parentElement; | ||||
| 786 | + let tip = null; | ||||
| 787 | + const show = (clientX) => { | ||||
| 788 | + const rect = svg.getBoundingClientRect(); | ||||
| 789 | + const vx = (clientX - rect.left) / rect.width * 340; | ||||
| 790 | + let best = pts[0]; | ||||
| 791 | + for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p; | ||||
| 792 | + if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); } | ||||
| 793 | + tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`; | ||||
| 794 | + tip.style.left = (best.x / 340 * rect.width) + 'px'; | ||||
| 795 | + tip.style.top = (best.y / 130 * rect.height) + 'px'; | ||||
| 796 | + }; | ||||
| 797 | + svg.addEventListener('pointerdown', (e) => show(e.clientX)); | ||||
| 798 | + svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); }); | ||||
| 799 | + svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } }); | ||||
| 800 | + }); | ||||
| 801 | + } | ||||
| 802 | + | ||||
| 803 | + // rafraîchissement périodique de l'onglet éco (en plus du push) | ||||
| 804 | + setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000); | ||||
| 805 | + document.addEventListener('visibilitychange', () => { | ||||
| 806 | + if (!document.hidden && curView === 'eco') loadEco(); | ||||
| 807 | + }); | ||||
| 808 | + | ||||
| 809 | + // ---------- feuille ---------- | ||||
| 810 | + function openSheet(html) { | ||||
| 811 | + $('sheetBody').innerHTML = html; | ||||
| 812 | + $('sheet').classList.remove('hidden'); | ||||
| 813 | + } | ||||
| 814 | + function closeSheet() { $('sheet').classList.add('hidden'); } | ||||
| 815 | + $('sheetBack').onclick = closeSheet; | ||||
| 816 | + | ||||
| 817 | + // ---------- réglages ---------- | ||||
| 818 | + function refreshSettingsView() { | ||||
| 819 | + document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model)); | ||||
| 820 | + document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode)); | ||||
| 821 | + $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—'; | ||||
| 822 | + $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—'; | ||||
| 823 | + $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—'; | ||||
| 824 | + } | ||||
| 825 | + document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); }); | ||||
| 826 | + document.querySelectorAll('#segPerm button').forEach(b => { | ||||
| 827 | + b.onclick = () => { | ||||
| 828 | + if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return; | ||||
| 829 | + applyOpts({ permMode: b.dataset.v }); | ||||
| 830 | + }; | ||||
| 831 | + }); | ||||
| 832 | + $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } }; | ||||
| 833 | + $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); }; | ||||
| 404 | $('logoutBtn').onclick = async () => { | 834 | $('logoutBtn').onclick = async () => { | ||
| 835 | + wsWanted = false; | ||||
| 405 | await fetch('/api/logout', { method: 'POST' }); | 836 | await fetch('/api/logout', { method: 'POST' }); | ||
| 406 | location.reload(); | 837 | location.reload(); | ||
| 407 | }; | 838 | }; | ||
@@ -414,6 +845,8 @@ | |||||
| 414 | $('app').classList.add('hidden'); | 845 | $('app').classList.add('hidden'); | ||
| 415 | return; | 846 | return; | ||
| 416 | } | 847 | } | ||
| 848 | + const info = await me.json(); | ||||
| 849 | + $('setHost').textContent = (info.node || 'M3U96a') + ':3300'; | ||||
| 417 | $('login').classList.add('hidden'); | 850 | $('login').classList.add('hidden'); | ||
| 418 | $('app').classList.remove('hidden'); | 851 | $('app').classList.remove('hidden'); | ||
| 419 | const pr = await fetch('/api/projects'); | 852 | const pr = await fetch('/api/projects'); | ||
@@ -421,9 +854,13 @@ | |||||
| 421 | projectSel.innerHTML = ''; | 854 | projectSel.innerHTML = ''; | ||
| 422 | for (const p of projects) { | 855 | for (const p of projects) { | ||
| 423 | const o = document.createElement('option'); | 856 | const o = document.createElement('option'); | ||
| 424 | − o.value = p.id; o.textContent = p.name; | 857 | + o.value = p.id; o.textContent = p.name + ' · ' + p.node; | ||
| 425 | projectSel.appendChild(o); | 858 | projectSel.appendChild(o); | ||
| 426 | } | 859 | } | ||
| 860 | + if (settings.projectId) projectSel.value = settings.projectId; | ||||
| 861 | + projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); }; | ||||
| 862 | + refreshChips(); | ||||
| 863 | + refreshSettingsView(); | ||||
| 427 | connect(); | 864 | connect(); | ||
| 428 | } | 865 | } | ||
| 429 | $('loginBtn').onclick = doLogin; | 866 | $('loginBtn').onclick = doLogin; | ||
modified
public/index.html
+127 −53
@@ -3,86 +3,160 @@ | |||||
| 3 | <head> | 3 | <head> | ||
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"> | ||
| 6 | −<meta name="theme-color" content="#ffffff"> | 6 | +<meta name="theme-color" content="#F2F1EC"> | ||
| 7 | <meta name="apple-mobile-web-app-capable" content="yes"> | 7 | <meta name="apple-mobile-web-app-capable" content="yes"> | ||
| 8 | <meta name="apple-mobile-web-app-status-bar-style" content="default"> | 8 | <meta name="apple-mobile-web-app-status-bar-style" content="default"> | ||
| 9 | −<meta name="apple-mobile-web-app-title" content="Admin·KA"> | 9 | +<meta name="apple-mobile-web-app-title" content="Admin-Ka"> | ||
| 10 | −<title>Administration·KA</title> | 10 | +<title>Admin-Ka — Console Groupe KA</title> | ||
| 11 | −<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233556f0'/%3E%3Ctext x='16' y='21.5' font-size='13' font-family='-apple-system,sans-serif' fill='white' text-anchor='middle' font-weight='700'%3EKA%3C/text%3E%3C/svg%3E"> | 11 | +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='-apple-system,sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E"> | ||
| 12 | −<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%233556f0'/%3E%3Ctext x='16' y='21.5' font-size='13' font-family='sans-serif' fill='white' text-anchor='middle' font-weight='700'%3EKA%3C/text%3E%3C/svg%3E"> | 12 | +<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E"> | ||
| 13 | <link rel="stylesheet" href="/vendor/hljs-github.min.css"> | 13 | <link rel="stylesheet" href="/vendor/hljs-github.min.css"> | ||
| 14 | <link rel="stylesheet" href="/style.css"> | 14 | <link rel="stylesheet" href="/style.css"> | ||
| 15 | </head> | 15 | </head> | ||
| 16 | <body> | 16 | <body> | ||
| 17 | 17 | ||||
| 18 | −<!-- Écran de connexion --> | 18 | +<!-- ============ Connexion ============ --> | ||
| 19 | <div id="login" class="login hidden"> | 19 | <div id="login" class="login hidden"> | ||
| 20 | <div class="login-box"> | 20 | <div class="login-box"> | ||
| 21 | − <div class="login-mark">KA</div> | 21 | + <div class="ka-mark big">Ka</div> | ||
| 22 | − <h1 class="login-title">Administration·KA</h1> | 22 | + <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1> | ||
| 23 | <p class="login-sub">Console Claude Code · Groupe KA</p> | 23 | <p class="login-sub">Console Claude Code · Groupe KA</p> | ||
| 24 | <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password"> | 24 | <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password"> | ||
| 25 | − <button id="loginBtn">Se connecter</button> | 25 | + <button id="loginBtn" class="btn-cta">Se connecter</button> | ||
| 26 | <div id="loginError" class="login-error"></div> | 26 | <div id="loginError" class="login-error"></div> | ||
| 27 | </div> | 27 | </div> | ||
| 28 | <div class="login-foot">M3U96a · MacLustr</div> | 28 | <div class="login-foot">M3U96a · MacLustr</div> | ||
| 29 | </div> | 29 | </div> | ||
| 30 | 30 | ||||
| 31 | −<!-- App --> | 31 | +<!-- ============ App ============ --> | ||
| 32 | <div id="app" class="app hidden"> | 32 | <div id="app" class="app hidden"> | ||
| 33 | + | ||||
| 33 | <header class="header"> | 34 | <header class="header"> | ||
| 34 | − <button id="menuBtn" class="icon-btn" title="Conversations" aria-label="Conversations"> | 35 | + <div class="brand"> | ||
| 35 | − <svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> | 36 | + <span class="ka-mark">Ka</span> | ||
| 36 | − </button> | 37 | + <span class="brand-name">Admin<span class="tiret">-</span>Ka</span> | ||
| 37 | − <div class="header-mid"> | ||||
| 38 | − <div class="project-row"> | ||||
| 39 | − <span id="connDot" class="dot" title="Connexion"></span> | ||||
| 40 | − <select id="projectSel" class="project-sel" title="Projet actif"></select> | ||||
| 41 | − </div> | ||||
| 42 | </div> | 38 | </div> | ||
| 43 | − <button id="modeBtn" class="mode-btn" title="Mode permissions">Sûr</button> | 39 | + <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText">hors ligne</span></div> | ||
| 44 | − <button id="newBtn" class="icon-btn accent" title="Nouvelle conversation" aria-label="Nouvelle conversation"> | ||||
| 45 | − <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg> | ||||
| 46 | − </button> | ||||
| 47 | </header> | 40 | </header> | ||
| 48 | 41 | ||||
| 49 | − <main id="messages" class="messages"></main> | 42 | + <!-- Bannière notifications / alertes --> | ||
| 43 | + <div id="banners" class="banners"></div> | ||||
| 50 | 44 | ||||
| 51 | − <div id="welcome" class="welcome"> | 45 | + <!-- ======== Vue CHAT ======== --> | ||
| 52 | − <div class="welcome-mark">KA</div> | 46 | + <section id="view-chat" class="view"> | ||
| 53 | − <h2>Administration·KA</h2> | 47 | + <div class="chatbar"> | ||
| 54 | − <p>Parle à Claude Code, directement sur le nœud de chaque app du Groupe KA.</p> | 48 | + <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select> | ||
| 55 | − <div class="hints"> | 49 | + <button id="chipModel" class="chip" title="Modèle">opus</button> | ||
| 56 | − <button class="hint">Quel est l'état de l'app ?</button> | 50 | + <button id="chipPerm" class="chip" title="Permissions">normal</button> | ||
| 57 | − <button class="hint">Montre les derniers commits</button> | 51 | + <button id="chipMore" class="chip ghost" title="Options de session">⋯</button> | ||
| 58 | − <button class="hint">Vérifie les erreurs PM2</button> | ||||
| 59 | </div> | 52 | </div> | ||
| 60 | − </div> | 53 | + <div id="sessMeta" class="sess-meta hidden"> | ||
| 54 | + <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span> | ||||
| 55 | + </div> | ||||
| 56 | + | ||||
| 57 | + <main id="messages" class="messages"></main> | ||||
| 58 | + | ||||
| 59 | + <div id="welcome" class="welcome"> | ||||
| 60 | + <div class="ka-mark big">Ka</div> | ||||
| 61 | + <h2>Console Claude Code</h2> | ||||
| 62 | + <p>Le vrai Claude Code, lancé directement sur le nœud de chaque app du Groupe KA.</p> | ||||
| 63 | + <div class="hints"> | ||||
| 64 | + <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button> | ||||
| 65 | + <button class="hint">Montre les 5 derniers commits</button> | ||||
| 66 | + <button class="hint">État PM2 de l'app</button> | ||||
| 67 | + </div> | ||||
| 68 | + </div> | ||||
| 69 | + | ||||
| 70 | + <footer class="composer"> | ||||
| 71 | + <div class="composer-inner"> | ||||
| 72 | + <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea> | ||||
| 73 | + <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button> | ||||
| 74 | + <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button> | ||||
| 75 | + </div> | ||||
| 76 | + <div id="slashHint" class="slash-hint hidden"></div> | ||||
| 77 | + </footer> | ||||
| 78 | + </section> | ||||
| 79 | + | ||||
| 80 | + <!-- ======== Vue SESSIONS ======== --> | ||||
| 81 | + <section id="view-sessions" class="view hidden"> | ||||
| 82 | + <div class="view-head"> | ||||
| 83 | + <h2>Sessions</h2> | ||||
| 84 | + <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button> | ||||
| 85 | + </div> | ||||
| 86 | + <div id="sessList" class="sess-list"></div> | ||||
| 87 | + </section> | ||||
| 61 | 88 | ||||
| 62 | − <footer class="composer"> | 89 | + <!-- ======== Vue ÉCOSYSTÈME ======== --> | ||
| 63 | − <div class="composer-inner"> | 90 | + <section id="view-eco" class="view hidden"> | ||
| 64 | − <textarea id="input" rows="1" placeholder="Demander à Claude…" enterkeyhint="send"></textarea> | 91 | + <div class="view-head"> | ||
| 65 | − <button id="stopBtn" class="send-btn stop hidden" title="Arrêter" aria-label="Arrêter"> | 92 | + <h2>Écosystème</h2> | ||
| 66 | − <svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg> | 93 | + <span id="ecoStamp" class="eco-stamp"></span> | ||
| 67 | − </button> | ||||
| 68 | − <button id="sendBtn" class="send-btn" title="Envoyer" aria-label="Envoyer"> | ||||
| 69 | − <svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg> | ||||
| 70 | − </button> | ||||
| 71 | </div> | 94 | </div> | ||
| 72 | − </footer> | 95 | + <div id="ecoOverview" class="eco-overview"></div> | ||
| 96 | + <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div> | ||||
| 97 | + </section> | ||||
| 73 | 98 | ||||
| 74 | − <!-- Tiroir conversations --> | 99 | + <!-- ======== Vue RÉGLAGES ======== --> | ||
| 75 | − <div id="drawer" class="drawer hidden"> | 100 | + <section id="view-settings" class="view hidden"> | ||
| 76 | − <div id="drawerBack" class="drawer-back"></div> | 101 | + <div class="view-head"><h2>Réglages</h2></div> | ||
| 77 | − <div class="drawer-panel"> | 102 | + <div class="set-card"> | ||
| 78 | − <div class="drawer-head"> | 103 | + <div class="set-label">Modèle par défaut</div> | ||
| 79 | − <span>Conversations</span> | 104 | + <div class="seg" id="segModel"> | ||
| 80 | − <button id="drawerClose" class="icon-btn" aria-label="Fermer"> | 105 | + <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button> | ||
| 81 | − <svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> | 106 | + </div> | ||
| 82 | − </button> | 107 | + </div> | ||
| 108 | + <div class="set-card"> | ||||
| 109 | + <div class="set-label">Permissions par défaut</div> | ||||
| 110 | + <div class="seg vert" id="segPerm"> | ||||
| 111 | + <button data-v="default">Normal — demande dans le chat</button> | ||||
| 112 | + <button data-v="acceptEdits">acceptEdits — éditions auto</button> | ||||
| 113 | + <button data-v="plan">Plan — proposer avant d'agir</button> | ||||
| 114 | + <button data-v="bypass">⚠ Tout autoriser (bypass)</button> | ||||
| 83 | </div> | 115 | </div> | ||
| 84 | − <div id="chatList" class="chat-list"></div> | 116 | + </div> | ||
| 85 | − <button id="logoutBtn" class="logout-btn">Se déconnecter</button> | 117 | + <div class="set-card"> | ||
| 118 | + <div class="set-label">Conversation active</div> | ||||
| 119 | + <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div> | ||||
| 120 | + <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div> | ||||
| 121 | + <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div> | ||||
| 122 | + <div class="set-actions"> | ||||
| 123 | + <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button> | ||||
| 124 | + <button id="btnCompact" class="btn-ghost">/compact — compacter</button> | ||||
| 125 | + </div> | ||||
| 126 | + </div> | ||||
| 127 | + <div class="set-card"> | ||||
| 128 | + <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div> | ||||
| 129 | + <div class="set-row"><span>Version</span><span class="mono">2.0.0</span></div> | ||||
| 130 | + </div> | ||||
| 131 | + <button id="logoutBtn" class="btn-danger">Se déconnecter</button> | ||||
| 132 | + </section> | ||||
| 133 | + | ||||
| 134 | + <!-- ======== Bottom nav ======== --> | ||||
| 135 | + <nav class="bottomnav"> | ||||
| 136 | + <button data-view="chat" class="nav-btn active"> | ||||
| 137 | + <svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg> | ||||
| 138 | + <span>Chat</span><i class="badge hidden"></i> | ||||
| 139 | + </button> | ||||
| 140 | + <button data-view="sessions" class="nav-btn"> | ||||
| 141 | + <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> | ||||
| 142 | + <span>Sessions</span><i class="badge hidden"></i> | ||||
| 143 | + </button> | ||||
| 144 | + <button data-view="eco" class="nav-btn"> | ||||
| 145 | + <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> | ||||
| 146 | + <span>Écosystème</span><i class="badge hidden"></i> | ||||
| 147 | + </button> | ||||
| 148 | + <button data-view="settings" class="nav-btn"> | ||||
| 149 | + <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> | ||||
| 150 | + <span>Réglages</span><i class="badge hidden"></i> | ||||
| 151 | + </button> | ||||
| 152 | + </nav> | ||||
| 153 | + | ||||
| 154 | + <!-- ======== Feuille détail (éco / options) ======== --> | ||||
| 155 | + <div id="sheet" class="sheet hidden"> | ||||
| 156 | + <div id="sheetBack" class="sheet-back"></div> | ||||
| 157 | + <div class="sheet-panel"> | ||||
| 158 | + <div class="sheet-grip"></div> | ||||
| 159 | + <div id="sheetBody" class="sheet-body"></div> | ||||
| 86 | </div> | 160 | </div> | ||
| 87 | </div> | 161 | </div> | ||
| 88 | </div> | 162 | </div> | ||
modified
public/style.css
+392 −285
@@ -1,413 +1,520 @@ | |||||
| 1 | +/* Admin-Ka — identité Groupe KA : crème + orange, néo-brutalisme doux */ | ||||
| 1 | :root { | 2 | :root { | ||
| 2 | − --bg: #f2f4f8; | 3 | + --bg: #F2F1EC; | ||
| 3 | − --surface: #ffffff; | 4 | + --card: #FCFBF8; | ||
| 4 | − --surface2: #f6f8fb; | 5 | + --card2: #F7F5EF; | ||
| 5 | − --border: #e4e8ef; | 6 | + --ink: #1a1611; | ||
| 6 | − --border-strong: #d4dae3; | 7 | + --muted: #6d675c; | ||
| 7 | − --text: #17203a; | 8 | + --line: #e3dfd4; | ||
| 8 | − --muted: #67708a; | 9 | + --orange: #F97316; | ||
| 9 | − --accent: #3556f0; | 10 | + --orange-deep: #e05f00; /* traits de graphique (contraste validé) */ | ||
| 10 | − --accent-soft: #eef1fe; | 11 | + --orange-soft: #fee9d8; | ||
| 11 | − --accent-grad: linear-gradient(135deg, #3556f0, #5a71f5); | 12 | + --good: #178a4c; | ||
| 12 | − --green: #17924a; | 13 | + --good-soft: #e2f3e8; | ||
| 13 | − --green-soft: #e7f7ee; | 14 | + --warn: #946300; | ||
| 14 | − --red: #d5303e; | 15 | + --warn-soft: #f7edd4; | ||
| 15 | − --red-soft: #fdeeef; | 16 | + --bad: #d5303e; | ||
| 16 | − --amber: #b26a00; | 17 | + --bad-soft: #fbe6e7; | ||
| 17 | − --amber-soft: #fff6e6; | 18 | + --grid: #e5e1d8; | ||
| 18 | − --shadow-sm: 0 1px 2px rgba(23, 32, 58, 0.06), 0 1px 3px rgba(23, 32, 58, 0.08); | 19 | + --r-xl: 26px; | ||
| 19 | − --shadow-md: 0 4px 16px rgba(23, 32, 58, 0.10); | 20 | + --r-lg: 20px; | ||
| 20 | − --shadow-lg: 0 12px 40px rgba(23, 32, 58, 0.16); | 21 | + --r-md: 14px; | ||
| 21 | − --r-lg: 18px; | 22 | + --r-sm: 10px; | ||
| 22 | − --r-md: 13px; | 23 | + --bshadow: 3px 3px 0 var(--ink); | ||
| 23 | − --r-sm: 9px; | 24 | + --bshadow-sm: 2px 2px 0 var(--ink); | ||
| 25 | + --z-banner: 140; | ||||
| 24 | --z-header: 100; | 26 | --z-header: 100; | ||
| 25 | − --z-composer: 100; | 27 | + --z-nav: 110; | ||
| 26 | − --z-welcome: 50; | 28 | + --z-composer: 105; | ||
| 27 | − --z-drawer: 200; | 29 | + --z-sheet: 200; | ||
| 28 | --z-login: 300; | 30 | --z-login: 300; | ||
| 29 | --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; | 31 | --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; | ||
| 32 | + --round: ui-rounded, -apple-system, BlinkMacSystemFont, "SF Pro Rounded", "Segoe UI", Roboto, sans-serif; | ||||
| 30 | } | 33 | } | ||
| 31 | * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } | 34 | * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } | ||
| 32 | html, body { | 35 | html, body { | ||
| 33 | margin: 0; padding: 0; | 36 | margin: 0; padding: 0; | ||
| 34 | − background: var(--bg); color: var(--text); | 37 | + background: var(--bg); color: var(--ink); | ||
| 35 | − font: 15.5px/1.55 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif; | 38 | + font: 15.5px/1.55 var(--round); | ||
| 36 | height: 100%; | 39 | height: 100%; | ||
| 37 | overscroll-behavior: none; | 40 | overscroll-behavior: none; | ||
| 38 | − text-rendering: optimizeLegibility; | ||||
| 39 | } | 41 | } | ||
| 40 | .hidden { display: none !important; } | 42 | .hidden { display: none !important; } | ||
| 41 | −button { font-family: inherit; } | 43 | +button { font-family: inherit; color: inherit; } | ||
| 44 | +.mono { font-family: var(--mono); font-size: 0.92em; } | ||||
| 45 | + | ||||
| 46 | +.ka-mark { | ||||
| 47 | + display: inline-flex; align-items: center; justify-content: center; | ||||
| 48 | + width: 34px; height: 34px; border-radius: 11px; | ||||
| 49 | + background: var(--orange); color: var(--ink); | ||||
| 50 | + border: 2px solid var(--ink); box-shadow: var(--bshadow-sm); | ||||
| 51 | + font-weight: 800; font-size: 15px; letter-spacing: -0.2px; | ||||
| 52 | + flex-shrink: 0; | ||||
| 53 | +} | ||||
| 54 | +.ka-mark.big { width: 68px; height: 68px; border-radius: 21px; font-size: 28px; box-shadow: var(--bshadow); margin: 0 auto; } | ||||
| 55 | +.tiret { color: var(--orange); font-weight: 800; } | ||||
| 56 | + | ||||
| 57 | +.btn-cta { | ||||
| 58 | + background: var(--orange); color: var(--ink); | ||||
| 59 | + border: 2px solid var(--ink); border-radius: 999px; | ||||
| 60 | + box-shadow: var(--bshadow); | ||||
| 61 | + font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer; | ||||
| 62 | + transition: transform 0.08s, box-shadow 0.08s; | ||||
| 63 | +} | ||||
| 64 | +.btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); } | ||||
| 65 | +.btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); } | ||||
| 66 | +.btn-ghost { | ||||
| 67 | + background: var(--card); border: 1.5px solid var(--ink); border-radius: 999px; | ||||
| 68 | + font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer; | ||||
| 69 | +} | ||||
| 70 | +.btn-danger { | ||||
| 71 | + display: block; margin: 18px auto calc(20px); | ||||
| 72 | + background: var(--card); border: 2px solid var(--bad); color: var(--bad); | ||||
| 73 | + border-radius: 999px; font-weight: 800; font-size: 14.5px; padding: 12px 26px; cursor: pointer; | ||||
| 74 | +} | ||||
| 42 | 75 | ||||
| 43 | /* ================= Login ================= */ | 76 | /* ================= Login ================= */ | ||
| 44 | .login { | 77 | .login { | ||
| 45 | position: fixed; inset: 0; z-index: var(--z-login); | 78 | position: fixed; inset: 0; z-index: var(--z-login); | ||
| 46 | − background: | 79 | + background: var(--bg); | ||
| 47 | − radial-gradient(1200px 600px at 50% -200px, #e3e9ff 0%, transparent 60%), | ||||
| 48 | − var(--bg); | ||||
| 49 | display: flex; flex-direction: column; align-items: center; justify-content: center; | 80 | display: flex; flex-direction: column; align-items: center; justify-content: center; | ||
| 50 | − padding: 24px; | 81 | + padding: 24px; gap: 18px; | ||
| 51 | } | 82 | } | ||
| 52 | .login-box { | 83 | .login-box { | ||
| 53 | width: 100%; max-width: 360px; | 84 | width: 100%; max-width: 360px; | ||
| 54 | − background: var(--surface); | 85 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-xl); | ||
| 55 | − border: 1px solid var(--border); | 86 | + box-shadow: 6px 6px 0 var(--ink); | ||
| 56 | − border-radius: 24px; | 87 | + padding: 34px 26px 26px; | ||
| 57 | − box-shadow: var(--shadow-lg); | 88 | + display: flex; flex-direction: column; gap: 13px; text-align: center; | ||
| 58 | − padding: 36px 28px 28px; | ||||
| 59 | − display: flex; flex-direction: column; gap: 12px; | ||||
| 60 | − text-align: center; | ||||
| 61 | −} | ||||
| 62 | −.login-mark { | ||||
| 63 | − width: 64px; height: 64px; margin: 0 auto 4px; | ||||
| 64 | − background: var(--accent-grad); color: #fff; | ||||
| 65 | − border-radius: 20px; | ||||
| 66 | − display: flex; align-items: center; justify-content: center; | ||||
| 67 | − font-size: 22px; font-weight: 800; letter-spacing: 0.5px; | ||||
| 68 | − box-shadow: 0 8px 24px rgba(53, 86, 240, 0.35); | ||||
| 69 | } | 89 | } | ||
| 70 | −.login-title { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.3px; } | 90 | +.login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; } | ||
| 71 | −.login-sub { margin: 0 0 10px; color: var(--muted); font-size: 13.5px; } | 91 | +.login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; } | ||
| 72 | .login input { | 92 | .login input { | ||
| 73 | − background: var(--surface2); border: 1.5px solid var(--border); color: var(--text); | 93 | + background: var(--bg); border: 2px solid var(--ink); color: var(--ink); | ||
| 74 | border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none; | 94 | border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none; | ||
| 75 | − transition: border-color 0.15s, box-shadow 0.15s; | 95 | + font-family: inherit; | ||
| 76 | −} | 96 | +} | ||
| 77 | −.login input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } | 97 | +.login input:focus { border-color: var(--orange); } | ||
| 78 | −.login button { | 98 | +.login-error { color: var(--bad); font-size: 13px; min-height: 18px; font-weight: 600; } | ||
| 79 | − background: var(--accent-grad); color: #fff; font-weight: 600; font-size: 16px; | 99 | +.login-foot { color: var(--muted); font-size: 12px; font-family: var(--mono); } | ||
| 80 | − border: none; border-radius: var(--r-md); padding: 14px; cursor: pointer; | ||||
| 81 | − box-shadow: 0 4px 14px rgba(53, 86, 240, 0.3); | ||||
| 82 | − transition: transform 0.1s; | ||||
| 83 | −} | ||||
| 84 | −.login button:active { transform: scale(0.98); } | ||||
| 85 | −.login-error { color: var(--red); font-size: 13px; min-height: 18px; } | ||||
| 86 | −.login-foot { margin-top: 20px; color: var(--muted); font-size: 12px; font-family: var(--mono); } | ||||
| 87 | 100 | ||||
| 88 | −/* ================= Layout ================= */ | 101 | +/* ================= Coquille ================= */ | ||
| 89 | −.app { display: flex; flex-direction: column; height: 100dvh; } | 102 | +.app { display: flex; flex-direction: column; height: 100dvh; position: relative; } | ||
| 90 | 103 | ||||
| 91 | .header { | 104 | .header { | ||
| 92 | position: sticky; top: 0; z-index: var(--z-header); | 105 | position: sticky; top: 0; z-index: var(--z-header); | ||
| 93 | − display: flex; align-items: center; gap: 8px; | 106 | + display: flex; align-items: center; justify-content: space-between; gap: 10px; | ||
| 94 | − background: rgba(255, 255, 255, 0.92); | 107 | + background: var(--bg); | ||
| 95 | − -webkit-backdrop-filter: saturate(180%) blur(14px); | 108 | + border-bottom: 2px solid var(--ink); | ||
| 96 | − backdrop-filter: saturate(180%) blur(14px); | 109 | + padding: 8px 14px; | ||
| 97 | − border-bottom: 1px solid var(--border); | ||||
| 98 | − padding: 8px 10px; | ||||
| 99 | padding-top: calc(8px + env(safe-area-inset-top)); | 110 | padding-top: calc(8px + env(safe-area-inset-top)); | ||
| 100 | } | 111 | } | ||
| 101 | −.header-mid { flex: 1; min-width: 0; } | 112 | +.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; } | ||
| 102 | −.project-row { | 113 | +.conn-pill { | ||
| 103 | − display: flex; align-items: center; gap: 8px; | 114 | + display: flex; align-items: center; gap: 6px; | ||
| 104 | − background: var(--surface2); | 115 | + border: 1.5px solid var(--ink); border-radius: 999px; | ||
| 105 | − border: 1px solid var(--border); | 116 | + background: var(--card); font-size: 11.5px; font-weight: 700; | ||
| 106 | − border-radius: 999px; | 117 | + padding: 4px 10px; | ||
| 107 | − padding: 6px 12px 6px 12px; | 118 | +} | ||
| 108 | −} | 119 | +.conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); } | ||
| 109 | −.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); flex-shrink: 0; transition: background 0.3s; } | 120 | +.conn-pill.on .conn-dot { background: var(--good); } | ||
| 110 | −.dot.on { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); } | 121 | +.conn-pill.mid .conn-dot { background: var(--warn); animation: pulse 1s infinite; } | ||
| 111 | −.project-sel { | 122 | +@keyframes pulse { 50% { opacity: 0.35; } } | ||
| 112 | − flex: 1; min-width: 0; | ||||
| 113 | − background: none; color: var(--text); border: none; | ||||
| 114 | − font-size: 15px; font-weight: 600; outline: none; | ||||
| 115 | − appearance: none; -webkit-appearance: none; | ||||
| 116 | − text-overflow: ellipsis; | ||||
| 117 | − padding-right: 14px; | ||||
| 118 | − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%2367708a' stroke-width='2.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | ||||
| 119 | − background-repeat: no-repeat; background-position: right center; background-size: 13px; | ||||
| 120 | −} | ||||
| 121 | −.icon-btn { | ||||
| 122 | − background: none; border: none; color: var(--text); | ||||
| 123 | − width: 40px; height: 40px; border-radius: 12px; cursor: pointer; flex-shrink: 0; | ||||
| 124 | − display: flex; align-items: center; justify-content: center; | ||||
| 125 | − transition: background 0.15s; | ||||
| 126 | −} | ||||
| 127 | −.icon-btn svg { width: 21px; height: 21px; } | ||||
| 128 | −.icon-btn:active { background: var(--surface2); } | ||||
| 129 | −.icon-btn.accent { color: var(--accent); background: var(--accent-soft); } | ||||
| 130 | −.mode-btn { | ||||
| 131 | − border: 1px solid var(--border); background: var(--green-soft); color: var(--green); | ||||
| 132 | − font-size: 12.5px; font-weight: 700; border-radius: 999px; padding: 7px 13px; | ||||
| 133 | − cursor: pointer; flex-shrink: 0; letter-spacing: 0.2px; | ||||
| 134 | −} | ||||
| 135 | −.mode-btn.auto { color: var(--amber); background: var(--amber-soft); } | ||||
| 136 | 123 | ||||
| 137 | −/* ================= Welcome ================= */ | 124 | +/* bannières notification / alerte */ | ||
| 138 | −.welcome { | 125 | +.banners { position: fixed; top: calc(52px + env(safe-area-inset-top)); left: 0; right: 0; z-index: var(--z-banner); display: flex; flex-direction: column; gap: 8px; padding: 8px 14px; pointer-events: none; } | ||
| 139 | − position: absolute; inset: 0; z-index: var(--z-welcome); | 126 | +.banner { | ||
| 140 | − display: flex; flex-direction: column; align-items: center; justify-content: center; | 127 | + pointer-events: auto; cursor: pointer; | ||
| 141 | − gap: 8px; padding: 32px; text-align: center; pointer-events: none; | 128 | + border: 2px solid var(--ink); border-radius: var(--r-md); | ||
| 129 | + box-shadow: var(--bshadow); padding: 11px 14px; | ||||
| 130 | + font-weight: 700; font-size: 13.5px; | ||||
| 131 | + display: flex; gap: 10px; align-items: center; | ||||
| 132 | + animation: drop-in 0.25s ease-out; | ||||
| 133 | +} | ||||
| 134 | +@keyframes drop-in { from { transform: translateY(-14px); opacity: 0; } to { transform: none; opacity: 1; } } | ||||
| 135 | +.banner.info { background: var(--orange-soft); } | ||||
| 136 | +.banner.good { background: var(--good-soft); } | ||||
| 137 | +.banner.bad { background: var(--bad-soft); } | ||||
| 138 | +.banner.warn { background: var(--warn-soft); } | ||||
| 139 | + | ||||
| 140 | +/* vues */ | ||||
| 141 | +.view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } | ||||
| 142 | +#view-sessions, #view-eco, #view-settings { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 14px 14px calc(86px + env(safe-area-inset-bottom)); } | ||||
| 143 | +.view-head { display: flex; align-items: center; justify-content: space-between; margin: 4px 2px 14px; } | ||||
| 144 | +.view-head h2 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px; } | ||||
| 145 | + | ||||
| 146 | +/* ================= Bottom nav ================= */ | ||||
| 147 | +.bottomnav { | ||||
| 148 | + position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav); | ||||
| 149 | + display: flex; | ||||
| 150 | + background: var(--card); | ||||
| 151 | + border-top: 2px solid var(--ink); | ||||
| 152 | + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); | ||||
| 153 | +} | ||||
| 154 | +.nav-btn { | ||||
| 155 | + position: relative; flex: 1; | ||||
| 156 | + background: none; border: none; cursor: pointer; | ||||
| 157 | + display: flex; flex-direction: column; align-items: center; gap: 2px; | ||||
| 158 | + color: var(--muted); font-size: 10.5px; font-weight: 700; | ||||
| 159 | + padding: 6px 2px; border-radius: var(--r-md); | ||||
| 142 | } | 160 | } | ||
| 143 | −.welcome > * { pointer-events: auto; } | 161 | +.nav-btn svg { width: 23px; height: 23px; } | ||
| 144 | −.welcome-mark { | 162 | +.nav-btn.active { color: var(--ink); } | ||
| 145 | − width: 72px; height: 72px; | 163 | +.nav-btn.active svg { color: var(--orange-deep); } | ||
| 146 | − background: var(--accent-grad); color: #fff; | 164 | +.nav-btn .badge { | ||
| 147 | − border-radius: 22px; | 165 | + position: absolute; top: 3px; right: calc(50% - 17px); | ||
| 148 | − display: flex; align-items: center; justify-content: center; | 166 | + width: 9px; height: 9px; border-radius: 50%; | ||
| 149 | − font-size: 25px; font-weight: 800; | 167 | + background: var(--orange); border: 1.5px solid var(--ink); | ||
| 150 | − box-shadow: 0 10px 30px rgba(53, 86, 240, 0.3); | ||||
| 151 | − margin-bottom: 8px; | ||||
| 152 | } | 168 | } | ||
| 153 | −.welcome h2 { margin: 0; font-size: 21px; letter-spacing: -0.3px; } | 169 | + | ||
| 154 | −.welcome p { margin: 0; color: var(--muted); font-size: 14.5px; max-width: 300px; } | 170 | +/* ================= Vue chat ================= */ | ||
| 155 | −.hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 16px; } | 171 | +.chatbar { | ||
| 156 | −.hint { | 172 | + display: flex; gap: 7px; align-items: center; | ||
| 157 | − background: var(--surface); border: 1px solid var(--border); | 173 | + padding: 9px 12px; overflow-x: auto; scrollbar-width: none; | ||
| 158 | − color: var(--text); font-size: 13.5px; | 174 | + border-bottom: 1.5px solid var(--line); | ||
| 159 | − border-radius: 999px; padding: 9px 15px; cursor: pointer; | 175 | + background: var(--bg); | ||
| 160 | − box-shadow: var(--shadow-sm); | 176 | +} | ||
| 177 | +.chatbar::-webkit-scrollbar { display: none; } | ||||
| 178 | +.pill-select { | ||||
| 179 | + appearance: none; -webkit-appearance: none; | ||||
| 180 | + background: var(--card); color: var(--ink); | ||||
| 181 | + border: 2px solid var(--ink); border-radius: 999px; | ||||
| 182 | + font-weight: 800; font-size: 13.5px; font-family: inherit; | ||||
| 183 | + padding: 7px 26px 7px 13px; | ||||
| 184 | + box-shadow: var(--bshadow-sm); | ||||
| 185 | + max-width: 46vw; text-overflow: ellipsis; | ||||
| 186 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%231a1611' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | ||||
| 187 | + background-repeat: no-repeat; background-position: right 9px center; background-size: 12px; | ||||
| 188 | +} | ||||
| 189 | +.chip { | ||||
| 190 | + border: 1.5px solid var(--ink); border-radius: 999px; | ||||
| 191 | + background: var(--card2); font-weight: 700; font-size: 12.5px; | ||||
| 192 | + padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; | ||||
| 193 | +} | ||||
| 194 | +.chip.orange { background: var(--orange-soft); } | ||||
| 195 | +.chip.warn { background: var(--warn-soft); color: var(--warn); } | ||||
| 196 | +.chip.ghost { border-style: dashed; color: var(--muted); } | ||||
| 197 | +.sess-meta { | ||||
| 198 | + display: flex; gap: 12px; flex-wrap: wrap; | ||||
| 199 | + padding: 6px 14px; font-size: 11px; font-family: var(--mono); color: var(--muted); | ||||
| 200 | + border-bottom: 1.5px solid var(--line); background: var(--card2); | ||||
| 161 | } | 201 | } | ||
| 162 | −.hint:active { background: var(--surface2); } | ||||
| 163 | 202 | ||||
| 164 | −/* ================= Messages ================= */ | ||||
| 165 | −.app { position: relative; } | ||||
| 166 | .messages { | 203 | .messages { | ||
| 167 | flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; | 204 | flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; | ||
| 168 | − padding: 16px 14px 24px; | 205 | + padding: 16px 13px calc(20px); | ||
| 169 | display: flex; flex-direction: column; gap: 12px; | 206 | display: flex; flex-direction: column; gap: 12px; | ||
| 170 | max-width: 780px; width: 100%; margin: 0 auto; | 207 | max-width: 780px; width: 100%; margin: 0 auto; | ||
| 171 | } | 208 | } | ||
| 172 | @keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } | 209 | @keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } | ||
| 173 | −.messages > * { animation: msg-in 0.22s ease-out; } | 210 | +.messages > * { animation: msg-in 0.2s ease-out; } | ||
| 174 | 211 | ||||
| 175 | .msg-user { | 212 | .msg-user { | ||
| 176 | − align-self: flex-end; max-width: 85%; | 213 | + align-self: flex-end; max-width: 86%; | ||
| 177 | − background: var(--accent-grad); color: #fff; | 214 | + background: var(--orange); color: var(--ink); | ||
| 178 | − border-radius: 20px 20px 6px 20px; | 215 | + border: 2px solid var(--ink); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg); | ||
| 179 | − padding: 11px 16px; | 216 | + box-shadow: var(--bshadow-sm); | ||
| 217 | + padding: 10px 15px; font-weight: 600; | ||||
| 180 | white-space: pre-wrap; word-break: break-word; | 218 | white-space: pre-wrap; word-break: break-word; | ||
| 181 | − box-shadow: 0 3px 10px rgba(53, 86, 240, 0.25); | ||||
| 182 | − font-size: 15.5px; | ||||
| 183 | } | 219 | } | ||
| 184 | .msg-claude { | 220 | .msg-claude { | ||
| 185 | align-self: stretch; | 221 | align-self: stretch; | ||
| 186 | − background: var(--surface); | 222 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | ||
| 187 | − border: 1px solid var(--border); | 223 | + box-shadow: var(--bshadow-sm); | ||
| 188 | − border-radius: var(--r-lg); | 224 | + padding: 13px 16px; word-break: break-word; | ||
| 189 | − padding: 13px 16px; | ||||
| 190 | − box-shadow: var(--shadow-sm); | ||||
| 191 | − word-break: break-word; | ||||
| 192 | } | 225 | } | ||
| 193 | .msg-claude .md > :first-child { margin-top: 0; } | 226 | .msg-claude .md > :first-child { margin-top: 0; } | ||
| 194 | .msg-claude .md > :last-child { margin-bottom: 0; } | 227 | .msg-claude .md > :last-child { margin-bottom: 0; } | ||
| 195 | .md p { margin: 0.55em 0; } | 228 | .md p { margin: 0.55em 0; } | ||
| 196 | .md pre { | 229 | .md pre { | ||
| 197 | − background: #f6f8fa; border: 1px solid var(--border); border-radius: var(--r-sm); | 230 | + background: #f2efe7; border: 1.5px solid var(--line); border-radius: var(--r-sm); | ||
| 198 | padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5; | 231 | padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5; | ||
| 199 | } | 232 | } | ||
| 200 | .md code { font-family: var(--mono); font-size: 0.88em; } | 233 | .md code { font-family: var(--mono); font-size: 0.88em; } | ||
| 201 | −.md :not(pre) > code { background: var(--accent-soft); color: #2c46c8; padding: 1px 6px; border-radius: 6px; } | 234 | +.md :not(pre) > code { background: var(--orange-soft); padding: 1px 6px; border-radius: 6px; } | ||
| 202 | −.md h1, .md h2, .md h3, .md h4 { font-size: 1.02em; margin: 0.9em 0 0.35em; } | 235 | +.md h1, .md h2, .md h3, .md h4 { font-size: 1.03em; margin: 0.9em 0 0.35em; } | ||
| 203 | .md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 13px; } | 236 | .md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 13px; } | ||
| 204 | −.md th, .md td { border: 1px solid var(--border); padding: 5px 9px; } | 237 | +.md th, .md td { border: 1.5px solid var(--line); padding: 5px 9px; } | ||
| 205 | −.md th { background: var(--surface2); } | 238 | +.md th { background: var(--card2); } | ||
| 206 | −.md a { color: var(--accent); } | 239 | +.md a { color: var(--orange-deep); font-weight: 700; } | ||
| 207 | .md ul, .md ol { padding-left: 1.35em; margin: 0.5em 0; } | 240 | .md ul, .md ol { padding-left: 1.35em; margin: 0.5em 0; } | ||
| 208 | −.md blockquote { margin: 0.6em 0; padding: 2px 12px; border-left: 3px solid var(--border-strong); color: var(--muted); } | 241 | +.md blockquote { margin: 0.6em 0; padding: 2px 12px; border-left: 3px solid var(--orange); color: var(--muted); } | ||
| 209 | −.md hr { border: none; border-top: 1px solid var(--border); } | ||||
| 210 | 242 | ||||
| 211 | .thinking { | 243 | .thinking { | ||
| 212 | color: var(--muted); font-style: italic; font-size: 13px; | 244 | color: var(--muted); font-style: italic; font-size: 13px; | ||
| 213 | − border-left: 2.5px solid var(--border-strong); padding-left: 12px; | 245 | + border-left: 3px solid var(--line); padding-left: 12px; | ||
| 214 | white-space: pre-wrap; word-break: break-word; | 246 | white-space: pre-wrap; word-break: break-word; | ||
| 215 | } | 247 | } | ||
| 216 | −details.thinking-box { padding: 2px 4px; } | 248 | +details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: pointer; font-weight: 600; } | ||
| 217 | −details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: pointer; } | ||||
| 218 | − | ||||
| 219 | .meta-line { | 249 | .meta-line { | ||
| 220 | align-self: center; text-align: center; | 250 | align-self: center; text-align: center; | ||
| 221 | color: var(--muted); font-size: 11px; font-family: var(--mono); | 251 | color: var(--muted); font-size: 11px; font-family: var(--mono); | ||
| 222 | − background: var(--surface2); border-radius: 999px; padding: 3px 12px; | 252 | + background: var(--card2); border: 1.5px solid var(--line); border-radius: 999px; padding: 3px 12px; | ||
| 223 | max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | 253 | max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | ||
| 224 | } | 254 | } | ||
| 255 | +.notice-line { align-self: center; color: var(--warn); font-size: 12px; font-weight: 700; } | ||||
| 225 | .error-box { | 256 | .error-box { | ||
| 226 | − background: var(--red-soft); border: 1px solid #f3c2c6; | 257 | + background: var(--bad-soft); border: 2px solid var(--bad); | ||
| 227 | − color: var(--red); border-radius: var(--r-md); padding: 11px 14px; | 258 | + color: var(--bad); border-radius: var(--r-md); padding: 11px 14px; | ||
| 228 | − font-size: 13px; white-space: pre-wrap; word-break: break-word; | 259 | + font-size: 13px; white-space: pre-wrap; word-break: break-word; font-weight: 600; | ||
| 229 | } | 260 | } | ||
| 230 | 261 | ||||
| 231 | −/* ================= Cartes outils ================= */ | 262 | +/* --- cartes outils --- */ | ||
| 232 | .tool { | 263 | .tool { | ||
| 233 | − background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); | 264 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md); | ||
| 234 | − overflow: hidden; font-size: 13px; | 265 | + overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm); | ||
| 235 | − box-shadow: var(--shadow-sm); | ||||
| 236 | } | 266 | } | ||
| 237 | .tool summary { | 267 | .tool summary { | ||
| 238 | display: flex; align-items: center; gap: 10px; | 268 | display: flex; align-items: center; gap: 10px; | ||
| 239 | − padding: 9px 13px; cursor: pointer; list-style: none; user-select: none; | 269 | + padding: 9px 12px; cursor: pointer; list-style: none; user-select: none; min-height: 44px; | ||
| 240 | − min-height: 44px; | ||||
| 241 | } | 270 | } | ||
| 242 | .tool summary::-webkit-details-marker { display: none; } | 271 | .tool summary::-webkit-details-marker { display: none; } | ||
| 243 | .tool .t-icon { | 272 | .tool .t-icon { | ||
| 244 | − flex-shrink: 0; width: 28px; height: 28px; border-radius: 8px; | 273 | + flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px; | ||
| 274 | + border: 1.5px solid var(--ink); | ||||
| 245 | display: flex; align-items: center; justify-content: center; | 275 | display: flex; align-items: center; justify-content: center; | ||
| 246 | − font-size: 14px; background: var(--surface2); | 276 | + font-size: 13px; background: var(--card2); | ||
| 247 | } | 277 | } | ||
| 248 | −.tool .t-icon.bash { background: #eceff5; } | 278 | +.tool .t-icon.bash { background: var(--ink); color: var(--orange); } | ||
| 249 | −.tool .t-icon.edit { background: var(--amber-soft); } | 279 | +.tool .t-icon.edit { background: var(--orange-soft); } | ||
| 250 | −.tool .t-icon.read { background: #e8f3fd; } | 280 | +.tool .t-icon.read { background: var(--card2); } | ||
| 251 | −.tool .t-icon.web { background: #e9f6f2; } | 281 | +.tool .t-icon.web { background: var(--good-soft); } | ||
| 252 | −.tool .t-name { font-weight: 700; font-family: var(--mono); font-size: 12.5px; flex-shrink: 0; color: var(--text); } | 282 | +.tool .t-name { font-weight: 800; font-family: var(--mono); font-size: 12.5px; flex-shrink: 0; } | ||
| 253 | .tool .t-sub { | 283 | .tool .t-sub { | ||
| 254 | color: var(--muted); font-family: var(--mono); font-size: 12px; | 284 | color: var(--muted); font-family: var(--mono); font-size: 12px; | ||
| 255 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; | 285 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; | ||
| 256 | } | 286 | } | ||
| 257 | −.tool .t-state { flex-shrink: 0; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 3px 8px; } | 287 | +.tool .t-state { flex-shrink: 0; font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 9px; border: 1.5px solid transparent; } | ||
| 258 | −.tool .t-state.pending { color: var(--amber); background: var(--amber-soft); } | 288 | +.tool .t-state.pending { color: var(--warn); background: var(--warn-soft); } | ||
| 259 | −.tool .t-state.ok { color: var(--green); background: var(--green-soft); } | 289 | +.tool .t-state.ok { color: var(--good); background: var(--good-soft); } | ||
| 260 | −.tool .t-state.err { color: var(--red); background: var(--red-soft); } | 290 | +.tool .t-state.err { color: var(--bad); background: var(--bad-soft); } | ||
| 261 | −.tool-body { border-top: 1px solid var(--border); padding: 9px 13px 11px; background: var(--surface2); } | 291 | +.tool-body { border-top: 2px solid var(--ink); padding: 9px 13px 11px; background: var(--card2); } | ||
| 262 | .tool-body pre { | 292 | .tool-body pre { | ||
| 263 | − margin: 6px 0; background: #ffffff; border: 1px solid var(--border); | 293 | + margin: 6px 0; background: var(--card); border: 1.5px solid var(--line); | ||
| 264 | border-radius: var(--r-sm); padding: 9px; | 294 | border-radius: var(--r-sm); padding: 9px; | ||
| 265 | overflow-x: auto; font-size: 12px; font-family: var(--mono); | 295 | overflow-x: auto; font-size: 12px; font-family: var(--mono); | ||
| 266 | white-space: pre-wrap; word-break: break-word; | 296 | white-space: pre-wrap; word-break: break-word; | ||
| 267 | } | 297 | } | ||
| 268 | −.tool-body .label { | 298 | +.tool-body .label { color: var(--muted); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; margin-top: 6px; } | ||
| 269 | − color: var(--muted); font-size: 10.5px; font-weight: 700; | 299 | +.see-more { background: none; border: none; color: var(--orange-deep); font-size: 12.5px; font-weight: 800; cursor: pointer; padding: 4px 0; } | ||
| 270 | − text-transform: uppercase; letter-spacing: 0.7px; margin-top: 6px; | ||||
| 271 | −} | ||||
| 272 | −.see-more { background: none; border: none; color: var(--accent); font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 4px 0; } | ||||
| 273 | 300 | ||||
| 274 | −/* diffs — style GitHub clair */ | 301 | +.diff { font-family: var(--mono); font-size: 12px; margin: 6px 0; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--card); } | ||
| 275 | −.diff { | ||||
| 276 | − font-family: var(--mono); font-size: 12px; margin: 6px 0; | ||||
| 277 | − border: 1px solid var(--border); border-radius: var(--r-sm); | ||||
| 278 | − overflow: hidden; background: #fff; | ||||
| 279 | −} | ||||
| 280 | .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; } | 302 | .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; } | ||
| 281 | −.diff .del { background: #ffebe9; color: #82071e; } | 303 | +.diff .del { background: #ffe4e1; color: #8c1622; } | ||
| 282 | −.diff .add { background: #e6ffec; color: #055d20; } | 304 | +.diff .add { background: #ddf3e4; color: #0c5326; } | ||
| 283 | 305 | ||||
| 284 | −/* todos */ | ||||
| 285 | .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; } | 306 | .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; } | ||
| 286 | .todos li { padding: 3px 0; } | 307 | .todos li { padding: 3px 0; } | ||
| 287 | .todos .done { color: var(--muted); text-decoration: line-through; } | 308 | .todos .done { color: var(--muted); text-decoration: line-through; } | ||
| 288 | −.todos .doing { color: var(--accent); font-weight: 600; } | 309 | +.todos .doing { color: var(--orange-deep); font-weight: 800; } | ||
| 289 | 310 | ||||
| 290 | −/* ================= Permission ================= */ | 311 | +/* --- permission --- */ | ||
| 291 | .perm { | 312 | .perm { | ||
| 292 | − background: var(--amber-soft); border: 1.5px solid #f0c36b; | 313 | + background: var(--warn-soft); border: 2px solid var(--ink); | ||
| 293 | border-radius: var(--r-lg); padding: 14px; font-size: 13.5px; | 314 | border-radius: var(--r-lg); padding: 14px; font-size: 13.5px; | ||
| 294 | − box-shadow: var(--shadow-md); | 315 | + box-shadow: 4px 4px 0 var(--ink); | ||
| 295 | } | 316 | } | ||
| 296 | −.perm-title { font-weight: 700; color: var(--amber); margin-bottom: 8px; font-size: 14px; } | 317 | +.perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; } | ||
| 297 | .perm pre { | 318 | .perm pre { | ||
| 298 | − background: #fff; border: 1px solid #eddcb8; border-radius: var(--r-sm); padding: 9px; | 319 | + background: var(--card); border: 1.5px solid var(--line); border-radius: var(--r-sm); padding: 9px; | ||
| 299 | overflow-x: auto; font-size: 12px; font-family: var(--mono); | 320 | overflow-x: auto; font-size: 12px; font-family: var(--mono); | ||
| 300 | white-space: pre-wrap; word-break: break-word; | 321 | white-space: pre-wrap; word-break: break-word; | ||
| 301 | max-height: 200px; overflow-y: auto; margin: 0; | 322 | max-height: 200px; overflow-y: auto; margin: 0; | ||
| 302 | } | 323 | } | ||
| 303 | −.perm-actions { display: flex; gap: 10px; margin-top: 12px; } | 324 | +.perm-actions { display: flex; gap: 8px; margin-top: 12px; } | ||
| 304 | .perm-actions button { | 325 | .perm-actions button { | ||
| 305 | − flex: 1; padding: 12px; border-radius: var(--r-md); border: none; | 326 | + flex: 1; padding: 11px 6px; border-radius: 999px; | ||
| 306 | − font-size: 15px; font-weight: 700; cursor: pointer; min-height: 46px; | 327 | + font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px; | ||
| 307 | −} | 328 | + border: 2px solid var(--ink); | ||
| 308 | −.perm-allow { background: var(--green); color: #fff; box-shadow: 0 3px 10px rgba(23, 146, 74, 0.3); } | 329 | +} | ||
| 309 | −.perm-deny { background: #fff; color: var(--red); border: 1.5px solid #f3c2c6 !important; } | 330 | +.perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); } | ||
| 310 | −.perm-resolved { color: var(--muted); font-size: 12.5px; margin-top: 10px; font-weight: 600; } | 331 | +.perm-always { background: var(--good-soft); color: var(--good); } | ||
| 332 | +.perm-deny { background: var(--card); color: var(--bad); } | ||||
| 333 | +.perm-resolved { color: var(--muted); font-size: 12.5px; margin-top: 10px; font-weight: 700; } | ||||
| 311 | 334 | ||||
| 312 | −/* ================= Indicateur travail ================= */ | ||||
| 313 | .working { | 335 | .working { | ||
| 314 | align-self: flex-start; | 336 | align-self: flex-start; | ||
| 315 | display: flex; align-items: center; gap: 10px; | 337 | display: flex; align-items: center; gap: 10px; | ||
| 316 | − color: var(--muted); font-size: 13.5px; | 338 | + font-size: 13.5px; font-weight: 700; | ||
| 317 | − background: var(--surface); border: 1px solid var(--border); | 339 | + background: var(--card); border: 2px solid var(--ink); | ||
| 318 | − border-radius: 999px; padding: 8px 16px; | 340 | + border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm); | ||
| 319 | − box-shadow: var(--shadow-sm); | ||||
| 320 | } | 341 | } | ||
| 321 | .dots { display: flex; gap: 4px; } | 342 | .dots { display: flex; gap: 4px; } | ||
| 322 | −.dots i { | 343 | +.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--ink); animation: bounce 1.1s ease-in-out infinite; } | ||
| 323 | − width: 6px; height: 6px; border-radius: 50%; background: var(--accent); | 344 | +.dots i:nth-child(2) { animation-delay: 0.14s; } | ||
| 324 | − animation: pulse 1.2s ease-in-out infinite; | 345 | +.dots i:nth-child(3) { animation-delay: 0.28s; } | ||
| 346 | +@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } } | ||||
| 347 | + | ||||
| 348 | +/* --- accueil --- */ | ||||
| 349 | +.welcome { | ||||
| 350 | + position: absolute; inset: 130px 0 140px; z-index: 5; | ||||
| 351 | + display: flex; flex-direction: column; align-items: center; justify-content: center; | ||||
| 352 | + gap: 9px; padding: 28px; text-align: center; pointer-events: none; | ||||
| 353 | +} | ||||
| 354 | +.welcome > * { pointer-events: auto; } | ||||
| 355 | +.welcome h2 { margin: 6px 0 0; font-size: 22px; font-weight: 800; letter-spacing: -0.4px; } | ||||
| 356 | +.welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; } | ||||
| 357 | +.hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; } | ||||
| 358 | +.hint { | ||||
| 359 | + background: var(--card); border: 1.5px solid var(--ink); | ||||
| 360 | + font-size: 13px; font-weight: 700; | ||||
| 361 | + border-radius: 999px; padding: 9px 14px; cursor: pointer; | ||||
| 362 | + box-shadow: var(--bshadow-sm); | ||||
| 325 | } | 363 | } | ||
| 326 | −.dots i:nth-child(2) { animation-delay: 0.15s; } | ||||
| 327 | −.dots i:nth-child(3) { animation-delay: 0.3s; } | ||||
| 328 | −@keyframes pulse { 0%, 100% { opacity: 0.25; transform: scale(0.85); } 40% { opacity: 1; transform: scale(1.1); } } | ||||
| 329 | 364 | ||||
| 330 | −/* ================= Composer ================= */ | 365 | +/* --- composer --- */ | ||
| 331 | .composer { | 366 | .composer { | ||
| 332 | position: sticky; bottom: 0; z-index: var(--z-composer); | 367 | position: sticky; bottom: 0; z-index: var(--z-composer); | ||
| 333 | − background: linear-gradient(to top, var(--bg) 65%, transparent); | 368 | + background: var(--bg); | ||
| 334 | − padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); | 369 | + padding: 8px 12px calc(72px + env(safe-area-inset-bottom)); | ||
| 335 | } | 370 | } | ||
| 336 | .composer-inner { | 371 | .composer-inner { | ||
| 337 | display: flex; align-items: flex-end; gap: 8px; | 372 | display: flex; align-items: flex-end; gap: 8px; | ||
| 338 | max-width: 780px; margin: 0 auto; | 373 | max-width: 780px; margin: 0 auto; | ||
| 339 | − background: var(--surface); | 374 | + background: var(--card); | ||
| 340 | − border: 1px solid var(--border); | 375 | + border: 2px solid var(--ink); border-radius: var(--r-xl); | ||
| 341 | − border-radius: 26px; | 376 | + box-shadow: var(--bshadow); | ||
| 342 | − padding: 7px 7px 7px 18px; | 377 | + padding: 6px 6px 6px 17px; | ||
| 343 | − box-shadow: var(--shadow-md); | ||||
| 344 | } | 378 | } | ||
| 345 | #input { | 379 | #input { | ||
| 346 | − flex: 1; background: none; color: var(--text); | 380 | + flex: 1; background: none; color: var(--ink); | ||
| 347 | − border: none; | 381 | + border: none; padding: 10px 0; font-size: 16px; line-height: 1.45; | ||
| 348 | − padding: 9px 0; font-size: 16px; line-height: 1.45; | 382 | + resize: none; outline: none; max-height: 140px; font-family: inherit; font-weight: 600; | ||
| 349 | − resize: none; outline: none; max-height: 140px; | ||||
| 350 | − font-family: inherit; | ||||
| 351 | } | 383 | } | ||
| 352 | −#input::placeholder { color: var(--muted); } | 384 | +#input::placeholder { color: var(--muted); font-weight: 600; } | ||
| 353 | .send-btn { | 385 | .send-btn { | ||
| 354 | − width: 40px; height: 40px; border-radius: 50%; border: none; | 386 | + width: 42px; height: 42px; border-radius: 50%; | ||
| 355 | − background: var(--accent-grad); color: #fff; | 387 | + border: 2px solid var(--ink); | ||
| 388 | + background: var(--orange); color: var(--ink); | ||||
| 356 | cursor: pointer; flex-shrink: 0; | 389 | cursor: pointer; flex-shrink: 0; | ||
| 357 | display: flex; align-items: center; justify-content: center; | 390 | display: flex; align-items: center; justify-content: center; | ||
| 358 | − box-shadow: 0 3px 10px rgba(53, 86, 240, 0.35); | 391 | + box-shadow: var(--bshadow-sm); | ||
| 359 | − transition: transform 0.1s, opacity 0.15s; | 392 | + transition: transform 0.08s; | ||
| 360 | } | 393 | } | ||
| 361 | .send-btn svg { width: 19px; height: 19px; } | 394 | .send-btn svg { width: 19px; height: 19px; } | ||
| 362 | −.send-btn:active { transform: scale(0.92); } | 395 | +.send-btn:active { transform: translate(1px, 1px); box-shadow: none; } | ||
| 363 | .send-btn:disabled { opacity: 0.35; } | 396 | .send-btn:disabled { opacity: 0.35; } | ||
| 364 | −.send-btn.stop { background: var(--red); box-shadow: 0 3px 10px rgba(213, 48, 62, 0.35); } | 397 | +.send-btn.stop { background: var(--bad); color: #fff; } | ||
| 365 | − | 398 | +.slash-hint { | ||
| 366 | −/* ================= Drawer ================= */ | 399 | + max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap; | ||
| 367 | −.drawer { position: fixed; inset: 0; z-index: var(--z-drawer); } | ||||
| 368 | −.drawer-back { position: absolute; inset: 0; background: rgba(23, 32, 58, 0.35); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); } | ||||
| 369 | −@keyframes slide-in { from { transform: translateX(-100%); } to { transform: none; } } | ||||
| 370 | −.drawer-panel { | ||||
| 371 | − position: absolute; top: 0; left: 0; bottom: 0; width: min(330px, 88vw); | ||||
| 372 | − background: var(--surface); | ||||
| 373 | − box-shadow: var(--shadow-lg); | ||||
| 374 | − display: flex; flex-direction: column; | ||||
| 375 | − padding-top: env(safe-area-inset-top); | ||||
| 376 | − animation: slide-in 0.22s ease-out; | ||||
| 377 | − border-radius: 0 20px 20px 0; | ||||
| 378 | −} | ||||
| 379 | −.drawer-head { | ||||
| 380 | − display: flex; align-items: center; justify-content: space-between; | ||||
| 381 | − padding: 14px 10px 12px 20px; border-bottom: 1px solid var(--border); | ||||
| 382 | − font-weight: 700; font-size: 17px; | ||||
| 383 | −} | ||||
| 384 | −.chat-list { flex: 1; overflow-y: auto; padding: 8px; } | ||||
| 385 | −.chat-item { | ||||
| 386 | − padding: 12px 12px; border-radius: var(--r-md); cursor: pointer; | ||||
| 387 | − display: flex; align-items: center; gap: 8px; | ||||
| 388 | − margin-bottom: 2px; | ||||
| 389 | −} | ||||
| 390 | −.chat-item:active { background: var(--surface2); } | ||||
| 391 | −.chat-item.active { background: var(--accent-soft); } | ||||
| 392 | −.chat-item .ci-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } | ||||
| 393 | −.chat-item .ci-title { font-size: 14.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||||
| 394 | −.chat-item .ci-meta { font-size: 11.5px; color: var(--muted); } | ||||
| 395 | −.chat-item .ci-running { color: var(--green); font-weight: 700; } | ||||
| 396 | −.ci-del { | ||||
| 397 | − background: none; border: none; color: var(--muted); cursor: pointer; | ||||
| 398 | − width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0; | ||||
| 399 | − display: flex; align-items: center; justify-content: center; font-size: 15px; | ||||
| 400 | −} | ||||
| 401 | −.ci-del:active { background: var(--red-soft); color: var(--red); } | ||||
| 402 | −.chat-empty { color: var(--muted); text-align: center; padding: 32px 16px; font-size: 14px; } | ||||
| 403 | −.logout-btn { | ||||
| 404 | − margin: 10px 16px calc(14px + env(safe-area-inset-bottom)); | ||||
| 405 | − background: var(--surface2); border: 1px solid var(--border); color: var(--red); | ||||
| 406 | − border-radius: var(--r-md); padding: 13px; font-size: 14.5px; font-weight: 600; cursor: pointer; | ||||
| 407 | } | 400 | } | ||
| 408 | − | 401 | +.slash-hint button { | ||
| 409 | −/* écrans plus larges */ | 402 | + border: 1.5px solid var(--ink); background: var(--card2); border-radius: 999px; | ||
| 410 | −@media (min-width: 700px) { | 403 | + font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer; | ||
| 411 | − .header { padding-left: 16px; padding-right: 16px; } | ||||
| 412 | − .messages { padding: 24px 20px 32px; } | ||||
| 413 | } | 404 | } | ||
| 405 | + | ||||
| 406 | +/* ================= Sessions ================= */ | ||||
| 407 | +.sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; } | ||||
| 408 | +.sess-card { | ||||
| 409 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | ||||
| 410 | + box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer; | ||||
| 411 | + display: flex; flex-direction: column; gap: 6px; | ||||
| 412 | +} | ||||
| 413 | +.sess-top { display: flex; align-items: center; gap: 8px; } | ||||
| 414 | +.sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||||
| 415 | +.sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; } | ||||
| 416 | +.sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; } | ||||
| 417 | +.sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); } | ||||
| 418 | +.sess-badge.idle { background: var(--card2); color: var(--muted); } | ||||
| 419 | +.sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); } | ||||
| 420 | +.sess-actions { display: flex; gap: 8px; margin-top: 4px; } | ||||
| 421 | +.sess-actions button { | ||||
| 422 | + border: 1.5px solid var(--ink); border-radius: 999px; background: var(--card2); | ||||
| 423 | + font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer; | ||||
| 424 | +} | ||||
| 425 | +.sess-actions .del { color: var(--bad); } | ||||
| 426 | +.sess-empty { color: var(--muted); text-align: center; padding: 40px 16px; font-weight: 600; } | ||||
| 427 | + | ||||
| 428 | +/* ================= Écosystème ================= */ | ||||
| 429 | +.eco-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); } | ||||
| 430 | +.eco-overview { | ||||
| 431 | + display: flex; gap: 10px; margin-bottom: 14px; | ||||
| 432 | + max-width: 780px; margin-left: auto; margin-right: auto; | ||||
| 433 | +} | ||||
| 434 | +.eco-stat { | ||||
| 435 | + flex: 1; background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | ||||
| 436 | + box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center; | ||||
| 437 | +} | ||||
| 438 | +.eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; } | ||||
| 439 | +.eco-stat .l { font-size: 10.5px; color: var(--muted); font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; } | ||||
| 440 | +.eco-stat.ok .v { color: var(--good); } | ||||
| 441 | +.eco-stat.bad .v { color: var(--bad); } | ||||
| 442 | +.eco-grid { | ||||
| 443 | + display: grid; grid-template-columns: 1fr; gap: 12px; | ||||
| 444 | + max-width: 780px; margin: 0 auto; width: 100%; | ||||
| 445 | +} | ||||
| 446 | +@media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } } | ||||
| 447 | +.eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; } | ||||
| 448 | +.site-card { | ||||
| 449 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | ||||
| 450 | + box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer; | ||||
| 451 | + display: flex; flex-direction: column; gap: 8px; | ||||
| 452 | + transition: transform 0.08s; | ||||
| 453 | +} | ||||
| 454 | +.site-card:active { transform: translate(2px, 2px); box-shadow: none; } | ||||
| 455 | +.site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); } | ||||
| 456 | +.site-top { display: flex; align-items: center; gap: 8px; } | ||||
| 457 | +.site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||||
| 458 | +.st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; display: flex; gap: 4px; align-items: center; } | ||||
| 459 | +.st-pill.up { background: var(--good-soft); color: var(--good); } | ||||
| 460 | +.st-pill.slow { background: var(--warn-soft); color: var(--warn); } | ||||
| 461 | +.st-pill.down { background: var(--bad); color: #fff; } | ||||
| 462 | +.site-mid { display: flex; align-items: flex-end; gap: 10px; } | ||||
| 463 | +.site-ms { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; } | ||||
| 464 | +.site-ms small { font-size: 11px; color: var(--muted); font-weight: 700; } | ||||
| 465 | +.spark { flex: 1; height: 30px; } | ||||
| 466 | +.site-foot { display: flex; gap: 9px; flex-wrap: wrap; font-size: 10.5px; color: var(--muted); font-family: var(--mono); } | ||||
| 467 | +.site-foot b { color: var(--ink); } | ||||
| 468 | + | ||||
| 469 | +/* feuille détail */ | ||||
| 470 | +.sheet { position: fixed; inset: 0; z-index: var(--z-sheet); } | ||||
| 471 | +.sheet-back { position: absolute; inset: 0; background: rgba(26, 22, 17, 0.45); } | ||||
| 472 | +@keyframes sheet-up { from { transform: translateY(60%); } to { transform: none; } } | ||||
| 473 | +.sheet-panel { | ||||
| 474 | + position: absolute; left: 0; right: 0; bottom: 0; | ||||
| 475 | + max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; | ||||
| 476 | + background: var(--bg); border: 2px solid var(--ink); border-bottom: none; | ||||
| 477 | + border-radius: var(--r-xl) var(--r-xl) 0 0; | ||||
| 478 | + padding: 8px 16px calc(20px + env(safe-area-inset-bottom)); | ||||
| 479 | + animation: sheet-up 0.22s ease-out; | ||||
| 480 | +} | ||||
| 481 | +.sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; } | ||||
| 482 | +.sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } | ||||
| 483 | +.sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; } | ||||
| 484 | +.sheet-sec { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; } | ||||
| 485 | +.sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); } | ||||
| 486 | +.kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; } | ||||
| 487 | +.kv b { font-family: var(--mono); font-weight: 700; } | ||||
| 488 | +.tile-row { display: flex; gap: 9px; } | ||||
| 489 | +.tile { flex: 1; background: var(--card2); border: 1.5px solid var(--line); border-radius: var(--r-md); padding: 8px 6px; text-align: center; } | ||||
| 490 | +.tile .v { font-size: 17px; font-weight: 800; } | ||||
| 491 | +.tile .l { font-size: 10px; color: var(--muted); font-weight: 800; text-transform: uppercase; } | ||||
| 492 | +.chart { width: 100%; } | ||||
| 493 | +.chart-tip { | ||||
| 494 | + position: absolute; pointer-events: none; transform: translate(-50%, -110%); | ||||
| 495 | + background: var(--ink); color: var(--bg); border-radius: 8px; | ||||
| 496 | + font-size: 11px; font-family: var(--mono); padding: 4px 8px; white-space: nowrap; | ||||
| 497 | +} | ||||
| 498 | +.inc-item { display: flex; gap: 8px; padding: 7px 0; border-bottom: 1.5px solid var(--line); font-size: 12.5px; align-items: baseline; } | ||||
| 499 | +.inc-item:last-child { border-bottom: none; } | ||||
| 500 | +.inc-dot { color: var(--bad); font-weight: 800; } | ||||
| 501 | +.inc-ok { color: var(--good); font-weight: 800; } | ||||
| 502 | + | ||||
| 503 | +/* ================= Réglages ================= */ | ||||
| 504 | +.set-card { | ||||
| 505 | + max-width: 780px; margin: 0 auto 12px; width: 100%; | ||||
| 506 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | ||||
| 507 | + box-shadow: var(--bshadow-sm); padding: 14px 16px; | ||||
| 508 | +} | ||||
| 509 | +.set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; } | ||||
| 510 | +.seg { display: flex; gap: 7px; } | ||||
| 511 | +.seg.vert { flex-direction: column; } | ||||
| 512 | +.seg button { | ||||
| 513 | + flex: 1; border: 1.5px solid var(--ink); border-radius: 999px; | ||||
| 514 | + background: var(--card2); font-weight: 700; font-size: 13.5px; | ||||
| 515 | + padding: 10px 12px; cursor: pointer; text-align: center; | ||||
| 516 | +} | ||||
| 517 | +.seg.vert button { text-align: left; padding-left: 16px; } | ||||
| 518 | +.seg button.on { background: var(--orange); font-weight: 800; box-shadow: var(--bshadow-sm); } | ||||
| 519 | +.set-row { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; font-size: 14px; font-weight: 600; } | ||||
| 520 | +.set-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } | ||||
added
server/monitor.js
+240 −0
@@ -0,0 +1,240 @@ | |||||
| 1 | +// monitor.js — collecteur Écosystème Groupe KA | ||||
| 2 | +// Health checks HTTPS (45 s) + certificats TLS, sweep des nœuds (5 min : | ||||
| 3 | +// charge, RAM, disque, PM2, dernier commit), incidents, historique SQLite, | ||||
| 4 | +// push temps réel via le WebSocket du backend. | ||||
| 5 | +import https from 'node:https'; | ||||
| 6 | +import path from 'node:path'; | ||||
| 7 | +import { execFile } from 'node:child_process'; | ||||
| 8 | +import { DatabaseSync } from 'node:sqlite'; | ||||
| 9 | + | ||||
| 10 | +// Sites du Groupe KA (source : cluster-deployments.json 2026-08-19) | ||||
| 11 | +export const SITES = [ | ||||
| 12 | + { app: 'lou-ka', label: 'Lou·Ka', domain: 'www.lou-ka.com', node: 'M3U96b', pm2: 'lou-ka-web', dir: '~/apps/lou-ka', deployed: '2026-08' }, | ||||
| 13 | + { app: 'immo-ka', label: 'Immo·Ka', domain: 'www.immo-ka.com', node: 'M4M64a', pm2: 'immo-ka-web', dir: '~/apps/immo-ka', deployed: '2026-08' }, | ||||
| 14 | + { app: 'vrai-prix', label: 'Vrai-Prix', domain: 'www.vrai-prix.com', node: 'M3U96a', pm2: 'vrai-prix', dir: '~/apps/vrai-prix', deployed: '2026-08' }, | ||||
| 15 | + { app: 'valoplex', label: 'ValoPlex', domain: 'www.valoplex.com', node: 'M3U96a', pm2: 'valoplex', dir: '~/apps/valoplex', deployed: '2026-08' }, | ||||
| 16 | + { app: 'auto-ka', label: 'Auto·Ka', domain: 'www.auto-ka.com', node: 'M4M64b', pm2: 'auto-ka-web', dir: '~/auto-ka', deployed: '2026-08' }, | ||||
| 17 | + { app: 'fabri-ka', label: 'Fabri·Ka', domain: 'www.fabri-ka.com', node: 'M4M64a', pm2: 'fabri-ka-web', dir: '~/fabri-ka', deployed: '2026-08' }, | ||||
| 18 | + { app: 'food-ka', label: 'Food·Ka', domain: 'www.food-ka.com', node: 'M4M64b', pm2: 'food-ka-web', dir: '~/apps/food-ka', deployed: '2026-08' }, | ||||
| 19 | + { app: 'resto-ka', label: 'Resto·Ka', domain: 'www.resto-ka.com', node: 'M3U96b', pm2: 'resto-ka', dir: '~/apps/resto-ka', deployed: '2026-08' }, | ||||
| 20 | + { app: 'sorti-ka', label: 'Sorti·Ka', domain: 'www.sorti-ka.com', node: 'M3U96a', pm2: 'sorti-ka-web', dir: '~/apps/sorti-ka', deployed: '2026-08' }, | ||||
| 21 | + { app: 'crea-ka', label: 'Créa·Ka', domain: 'www.crea-ka.com', node: 'M3U96b', pm2: 'crea-ka-web', dir: '~/apps/crea-ka', deployed: '2026-08' }, | ||||
| 22 | + { app: 'job-ka', label: 'Job·Ka', domain: 'www.job-ka.com', node: 'M3U96a', pm2: 'job-ka-web', dir: '~/apps/job-ka', deployed: '2026-08' }, | ||||
| 23 | + { app: 'trouve-ka', label: 'Trouve·Ka', domain: 'www.trouve-ka.com', node: 'M2M32', pm2: 'tk-web', dir: '~/trouve-ka', deployed: '2026-08' }, | ||||
| 24 | + { app: 'groupe-ka', label: 'Groupe·Ka', domain: 'www.groupe-ka.com', node: 'M3U96b', pm2: 'groupe-ka', dir: '~/apps/groupe-ka', deployed: '2026-08' }, | ||||
| 25 | +]; | ||||
| 26 | + | ||||
| 27 | +const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32']; | ||||
| 28 | +const SELF_NODE = 'M3U96a'; | ||||
| 29 | +const CHECK_MS = 45_000; | ||||
| 30 | +const SWEEP_MS = 5 * 60_000; | ||||
| 31 | +const SLOW_MS = 4000; // seuil "lent" (▲) | ||||
| 32 | +const FAILS_FOR_INCIDENT = 2; // échecs consécutifs avant incident | ||||
| 33 | + | ||||
| 34 | +let db, emit; | ||||
| 35 | +const consecFails = {}; | ||||
| 36 | + | ||||
| 37 | +export function startMonitor({ dataDir, onEvent }) { | ||||
| 38 | + emit = onEvent || (() => {}); | ||||
| 39 | + db = new DatabaseSync(path.join(dataDir, 'eco.sqlite')); | ||||
| 40 | + db.exec(` | ||||
| 41 | + CREATE TABLE IF NOT EXISTS checks(site TEXT, ts INTEGER, ms INTEGER, code INTEGER, ok INTEGER, err TEXT); | ||||
| 42 | + CREATE INDEX IF NOT EXISTS idx_checks ON checks(site, ts); | ||||
| 43 | + CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT); | ||||
| 44 | + CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER); | ||||
| 45 | + CREATE TABLE IF NOT EXISTS nodestats(node TEXT, ts INTEGER, load1 REAL, mem_used INTEGER, mem_total INTEGER, disk_pct INTEGER); | ||||
| 46 | + CREATE INDEX IF NOT EXISTS idx_nodestats ON nodestats(node, ts); | ||||
| 47 | + CREATE TABLE IF NOT EXISTS appstats(app TEXT, ts INTEGER, cpu REAL, mem INTEGER, status TEXT, restarts INTEGER, last_commit INTEGER); | ||||
| 48 | + CREATE INDEX IF NOT EXISTS idx_appstats ON appstats(app, ts); | ||||
| 49 | + `); | ||||
| 50 | + checkAll(); | ||||
| 51 | + sweepNodes(); | ||||
| 52 | + setInterval(checkAll, CHECK_MS); | ||||
| 53 | + setInterval(sweepNodes, SWEEP_MS); | ||||
| 54 | + setInterval(cleanup, 6 * 3600_000); | ||||
| 55 | +} | ||||
| 56 | + | ||||
| 57 | +// ---------- health checks ---------- | ||||
| 58 | +function checkOne(site) { | ||||
| 59 | + return new Promise((resolve) => { | ||||
| 60 | + const t0 = Date.now(); | ||||
| 61 | + const req = https.get({ host: site.domain, path: '/', timeout: 12_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => { | ||||
| 62 | + const ms = Date.now() - t0; | ||||
| 63 | + const cert = res.socket.getPeerCertificate?.(); | ||||
| 64 | + if (cert && cert.valid_to) { | ||||
| 65 | + db.prepare('INSERT INTO cert(site,expires,issuer,checked) VALUES(?,?,?,?) ON CONFLICT(site) DO UPDATE SET expires=excluded.expires,issuer=excluded.issuer,checked=excluded.checked') | ||||
| 66 | + .run(site.app, Date.parse(cert.valid_to), cert.issuer?.O || '', Date.now()); | ||||
| 67 | + } | ||||
| 68 | + res.resume(); | ||||
| 69 | + resolve({ site: site.app, ts: Date.now(), ms, code: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 400 ? 1 : 0, err: null }); | ||||
| 70 | + }); | ||||
| 71 | + req.on('timeout', () => { req.destroy(new Error('timeout')); }); | ||||
| 72 | + req.on('error', (e) => resolve({ site: site.app, ts: Date.now(), ms: Date.now() - t0, code: 0, ok: 0, err: e.message.slice(0, 120) })); | ||||
| 73 | + }); | ||||
| 74 | +} | ||||
| 75 | + | ||||
| 76 | +async function checkAll() { | ||||
| 77 | + const results = await Promise.all(SITES.map(checkOne)); | ||||
| 78 | + const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err) VALUES(?,?,?,?,?,?)'); | ||||
| 79 | + for (const r of results) { | ||||
| 80 | + ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err); | ||||
| 81 | + handleIncident(r); | ||||
| 82 | + } | ||||
| 83 | + emit({ type: 'eco', kind: 'checks', items: results }); | ||||
| 84 | +} | ||||
| 85 | + | ||||
| 86 | +function handleIncident(r) { | ||||
| 87 | + const open = db.prepare('SELECT id FROM incidents WHERE site=? AND ended IS NULL').get(r.site); | ||||
| 88 | + if (!r.ok) { | ||||
| 89 | + consecFails[r.site] = (consecFails[r.site] || 0) + 1; | ||||
| 90 | + if (consecFails[r.site] >= FAILS_FOR_INCIDENT && !open) { | ||||
| 91 | + db.prepare('INSERT INTO incidents(site,started,reason) VALUES(?,?,?)') | ||||
| 92 | + .run(r.site, r.ts, r.err || `HTTP ${r.code}`); | ||||
| 93 | + emit({ type: 'eco_alert', kind: 'down', site: r.site, reason: r.err || `HTTP ${r.code}`, ts: r.ts }); | ||||
| 94 | + } | ||||
| 95 | + } else { | ||||
| 96 | + consecFails[r.site] = 0; | ||||
| 97 | + if (open) { | ||||
| 98 | + db.prepare('UPDATE incidents SET ended=? WHERE id=?').run(r.ts, open.id); | ||||
| 99 | + emit({ type: 'eco_alert', kind: 'up', site: r.site, ts: r.ts }); | ||||
| 100 | + } | ||||
| 101 | + } | ||||
| 102 | +} | ||||
| 103 | + | ||||
| 104 | +// ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ---------- | ||||
| 105 | +function sweepNode(node) { | ||||
| 106 | + return new Promise((resolve) => { | ||||
| 107 | + const gitCmds = SITES.filter(s => s.node === node) | ||||
| 108 | + .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; '); | ||||
| 109 | + const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH | ||||
| 110 | +echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)" | ||||
| 111 | +echo "MEMTOTAL $(sysctl -n hw.memsize 2>/dev/null)" | ||||
| 112 | +echo "PAGESIZE $(sysctl -n hw.pagesize 2>/dev/null)" | ||||
| 113 | +vm_stat 2>/dev/null | egrep "active|wired|occupied" | tr -d '.' | ||||
| 114 | +echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')" | ||||
| 115 | +echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)" | ||||
| 116 | +${gitCmds}`; | ||||
| 117 | + const args = node === SELF_NODE | ||||
| 118 | + ? ['-c', script] | ||||
| 119 | + : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script]; | ||||
| 120 | + const bin = node === SELF_NODE ? '/bin/bash' : 'ssh'; | ||||
| 121 | + execFile(bin, args, { timeout: 45_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout) => { | ||||
| 122 | + if (err && !stdout) return resolve(null); | ||||
| 123 | + try { parseSweep(node, stdout); } catch {} | ||||
| 124 | + resolve(null); | ||||
| 125 | + }); | ||||
| 126 | + }); | ||||
| 127 | +} | ||||
| 128 | + | ||||
| 129 | +function parseSweep(node, out) { | ||||
| 130 | + const ts = Date.now(); | ||||
| 131 | + const load = out.match(/^LOAD \{?\s*([\d.]+)/m); | ||||
| 132 | + const memTotal = Number(out.match(/^MEMTOTAL (\d+)/m)?.[1] || 0); | ||||
| 133 | + const pageSize = Number(out.match(/^PAGESIZE (\d+)/m)?.[1] || 16384); | ||||
| 134 | + let pages = 0; | ||||
| 135 | + for (const m of out.matchAll(/Pages (?:active|wired down|occupied by compressor):\s+(\d+)/g)) pages += Number(m[1]); | ||||
| 136 | + const memUsed = pages * pageSize; | ||||
| 137 | + const disk = Number(out.match(/^DISK (\d+)/m)?.[1] || 0); | ||||
| 138 | + db.prepare('INSERT INTO nodestats(node,ts,load1,mem_used,mem_total,disk_pct) VALUES(?,?,?,?,?,?)') | ||||
| 139 | + .run(node, ts, load ? Number(load[1]) : 0, memUsed, memTotal, disk); | ||||
| 140 | + | ||||
| 141 | + const commits = {}; | ||||
| 142 | + for (const m of out.matchAll(/^GIT (\S+) (\d+)/gm)) commits[m[1]] = Number(m[2]) * 1000; | ||||
| 143 | + | ||||
| 144 | + const pm2raw = out.match(/^PM2JSON (\[.*\])\s*$/m); | ||||
| 145 | + const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)'); | ||||
| 146 | + if (pm2raw) { | ||||
| 147 | + let list = []; | ||||
| 148 | + try { list = JSON.parse(pm2raw[1]); } catch {} | ||||
| 149 | + for (const site of SITES.filter(s => s.node === node)) { | ||||
| 150 | + const p = list.find(x => x.name === site.pm2) || list.find(x => (x.name || '').startsWith(site.app)); | ||||
| 151 | + ins.run(site.app, ts, | ||||
| 152 | + p ? (p.monit?.cpu ?? 0) : 0, | ||||
| 153 | + p ? (p.monit?.memory ?? 0) : 0, | ||||
| 154 | + p ? (p.pm2_env?.status || 'inconnu') : 'introuvable', | ||||
| 155 | + p ? (p.pm2_env?.restart_time ?? 0) : 0, | ||||
| 156 | + commits[site.app] || 0); | ||||
| 157 | + } | ||||
| 158 | + } else { | ||||
| 159 | + for (const site of SITES.filter(s => s.node === node)) { | ||||
| 160 | + ins.run(site.app, ts, 0, 0, 'inconnu', 0, commits[site.app] || 0); | ||||
| 161 | + } | ||||
| 162 | + } | ||||
| 163 | +} | ||||
| 164 | + | ||||
| 165 | +async function sweepNodes() { | ||||
| 166 | + await Promise.all(NODES.map(sweepNode)); | ||||
| 167 | + emit({ type: 'eco', kind: 'sweep', ts: Date.now() }); | ||||
| 168 | +} | ||||
| 169 | + | ||||
| 170 | +function cleanup() { | ||||
| 171 | + const cut = Date.now() - 35 * 24 * 3600_000; | ||||
| 172 | + db.prepare('DELETE FROM checks WHERE ts < ?').run(cut); | ||||
| 173 | + db.prepare('DELETE FROM nodestats WHERE ts < ?').run(cut); | ||||
| 174 | + db.prepare('DELETE FROM appstats WHERE ts < ?').run(cut); | ||||
| 175 | +} | ||||
| 176 | + | ||||
| 177 | +// ---------- requêtes ---------- | ||||
| 178 | +function uptime(site, sinceMs) { | ||||
| 179 | + const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs); | ||||
| 180 | + return r.n ? Math.round((r.up / r.n) * 1000) / 10 : null; | ||||
| 181 | +} | ||||
| 182 | +function p95(site, sinceMs) { | ||||
| 183 | + const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs); | ||||
| 184 | + if (!rows.length) return null; | ||||
| 185 | + return rows[Math.min(rows.length - 1, Math.floor(rows.length * 0.95))].ms; | ||||
| 186 | +} | ||||
| 187 | + | ||||
| 188 | +export function ecoSummary() { | ||||
| 189 | + const out = []; | ||||
| 190 | + for (const s of SITES) { | ||||
| 191 | + const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app); | ||||
| 192 | + const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse(); | ||||
| 193 | + const avg = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(s.app, Date.now() - 24 * 3600_000); | ||||
| 194 | + const cert = db.prepare('SELECT expires FROM cert WHERE site=?').get(s.app); | ||||
| 195 | + const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app); | ||||
| 196 | + const stat = db.prepare('SELECT cpu,mem,status,restarts,last_commit,ts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app); | ||||
| 197 | + const nodeStat = db.prepare('SELECT load1,mem_used,mem_total,disk_pct,ts FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(s.node); | ||||
| 198 | + out.push({ | ||||
| 199 | + app: s.app, label: s.label, domain: s.domain, node: s.node, | ||||
| 200 | + last, spark, | ||||
| 201 | + uptime24: uptime(s.app, 24 * 3600_000), | ||||
| 202 | + uptime7: uptime(s.app, 7 * 24 * 3600_000), | ||||
| 203 | + uptime30: uptime(s.app, 30 * 24 * 3600_000), | ||||
| 204 | + avg24: avg?.a ? Math.round(avg.a) : null, | ||||
| 205 | + p95_24: p95(s.app, 24 * 3600_000), | ||||
| 206 | + certExpires: cert?.expires || null, | ||||
| 207 | + incident: inc || null, | ||||
| 208 | + proc: stat || null, | ||||
| 209 | + nodeStat: nodeStat || null, | ||||
| 210 | + }); | ||||
| 211 | + } | ||||
| 212 | + return { sites: out, ts: Date.now() }; | ||||
| 213 | +} | ||||
| 214 | + | ||||
| 215 | +export function ecoSite(app) { | ||||
| 216 | + const s = SITES.find(x => x.app === app); | ||||
| 217 | + if (!s) return null; | ||||
| 218 | + const since = Date.now() - 24 * 3600_000; | ||||
| 219 | + // ~200 seaux sur 24 h pour le graphique | ||||
| 220 | + const bucket = Math.ceil((24 * 3600_000) / 200); | ||||
| 221 | + const series = db.prepare(` | ||||
| 222 | + SELECT (ts/${bucket})*${bucket} AS t, AVG(ms) ms, MIN(ok) ok, COUNT(*) n | ||||
| 223 | + FROM checks WHERE site=? AND ts>? GROUP BY t ORDER BY t`).all(app, since); | ||||
| 224 | + const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30') | ||||
| 225 | + .all(app, Date.now() - 30 * 24 * 3600_000); | ||||
| 226 | + const errors = db.prepare('SELECT ts,code,err,ms FROM checks WHERE site=? AND ok=0 ORDER BY ts DESC LIMIT 20').all(app); | ||||
| 227 | + // uptime quotidien 14 jours pour la barre | ||||
| 228 | + const days = []; | ||||
| 229 | + for (let i = 13; i >= 0; i--) { | ||||
| 230 | + const d0 = new Date(); d0.setHours(0, 0, 0, 0); | ||||
| 231 | + const start = d0.getTime() - i * 24 * 3600_000; | ||||
| 232 | + const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>=? AND ts<?').get(app, start, start + 24 * 3600_000); | ||||
| 233 | + days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null }); | ||||
| 234 | + } | ||||
| 235 | + return { app, label: s.label, domain: s.domain, node: s.node, dir: s.dir, series, incidents, errors, days }; | ||||
| 236 | +} | ||||
| 237 | + | ||||
| 238 | +export function ecoIncidents() { | ||||
| 239 | + return db.prepare(`SELECT site,started,ended,reason FROM incidents ORDER BY started DESC LIMIT 50`).all(); | ||||
| 240 | +} | ||||
modified
server/server.js
+180 −81
@@ -1,4 +1,6 @@ | |||||
| 1 | −// admin-ka — backend : pont HTTP/WebSocket ⇄ Claude Code (stream-json) sur M3U96a | 1 | +// admin-ka v2 — backend : pont HTTP/WebSocket ⇄ Claude Code CLI (stream-json) | ||
| 2 | +// Sessions détachées (survivent à la déconnexion du client), permissions | ||||
| 3 | +// relayées en cartes, monitoring Écosystème temps réel. | ||||
| 2 | import http from 'node:http'; | 4 | import http from 'node:http'; | ||
| 3 | import crypto from 'node:crypto'; | 5 | import crypto from 'node:crypto'; | ||
| 4 | import fs from 'node:fs'; | 6 | import fs from 'node:fs'; | ||
@@ -7,6 +9,7 @@ import os from 'node:os'; | |||||
| 7 | import { spawn } from 'node:child_process'; | 9 | import { spawn } from 'node:child_process'; | ||
| 8 | import { fileURLToPath } from 'node:url'; | 10 | import { fileURLToPath } from 'node:url'; | ||
| 9 | import { WebSocketServer } from 'ws'; | 11 | import { WebSocketServer } from 'ws'; | ||
| 12 | +import { startMonitor, ecoSummary, ecoSite, ecoIncidents } from './monitor.js'; | ||||
| 10 | 13 | ||||
| 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| 12 | const APP_DIR = path.join(__dirname, '..'); | 15 | const APP_DIR = path.join(__dirname, '..'); | ||
@@ -20,7 +23,7 @@ fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true }); | |||||
| 20 | // ---------- config ---------- | 23 | // ---------- config ---------- | ||
| 21 | const CONFIG_PATH = path.join(DATA_DIR, 'config.json'); | 24 | const CONFIG_PATH = path.join(DATA_DIR, 'config.json'); | ||
| 22 | if (!fs.existsSync(CONFIG_PATH)) { | 25 | if (!fs.existsSync(CONFIG_PATH)) { | ||
| 23 | − console.error('config.json manquant — exécuter: npm run set-password -- <motdepasse>'); | 26 | + console.error('config.json manquant — exécuter: node server/set-password.js <motdepasse>'); | ||
| 24 | process.exit(1); | 27 | process.exit(1); | ||
| 25 | } | 28 | } | ||
| 26 | const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); | 29 | const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); | ||
@@ -28,7 +31,6 @@ const PORT = config.port || 3300; | |||||
| 28 | const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude'; | 31 | const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude'; | ||
| 29 | const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node'; | 32 | const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node'; | ||
| 30 | 33 | ||||
| 31 | −// clé API Anthropic pour claude headless (depuis ~/.claude/.env) | ||||
| 32 | function loadAnthropicKey() { | 34 | function loadAnthropicKey() { | ||
| 33 | try { | 35 | try { | ||
| 34 | const envFile = fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8'); | 36 | const envFile = fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8'); | ||
@@ -50,8 +52,8 @@ const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json'); | |||||
| 50 | let sessions = {}; | 52 | let sessions = {}; | ||
| 51 | try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {} | 53 | try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {} | ||
| 52 | function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); } | 54 | function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); } | ||
| 53 | −const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000; // 7 jours d'inactivité | 55 | +const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000; | ||
| 54 | −const SESSION_MAX_MS = 30 * 24 * 3600 * 1000; // 30 jours absolu | 56 | +const SESSION_MAX_MS = 30 * 24 * 3600 * 1000; | ||
| 55 | 57 | ||||
| 56 | function verifyPassword(pw) { | 58 | function verifyPassword(pw) { | ||
| 57 | const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex'); | 59 | const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex'); | ||
@@ -77,7 +79,6 @@ function getSession(req) { | |||||
| 77 | return m[1]; | 79 | return m[1]; | ||
| 78 | } | 80 | } | ||
| 79 | 81 | ||||
| 80 | −// rate limit login : 8 échecs / 15 min / IP | ||||
| 81 | const loginFails = new Map(); | 82 | const loginFails = new Map(); | ||
| 82 | function clientIp(req) { | 83 | function clientIp(req) { | ||
| 83 | const xf = req.headers['x-forwarded-for']; | 84 | const xf = req.headers['x-forwarded-for']; | ||
@@ -98,8 +99,25 @@ function loginFailed(ip) { | |||||
| 98 | const CHATS_PATH = path.join(DATA_DIR, 'chats.json'); | 99 | const CHATS_PATH = path.join(DATA_DIR, 'chats.json'); | ||
| 99 | let chats = []; | 100 | let chats = []; | ||
| 100 | try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {} | 101 | try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {} | ||
| 102 | +// migration v1 → v2 | ||||
| 103 | +for (const c of chats) { | ||||
| 104 | + if (!c.model) c.model = 'fable'; | ||||
| 105 | + if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default'; | ||||
| 106 | + if (!c.state) c.state = 'idle'; | ||||
| 107 | + if (c.totalCost === undefined) c.totalCost = 0; | ||||
| 108 | + if (!c.alwaysAllow) c.alwaysAllow = []; | ||||
| 109 | +} | ||||
| 101 | function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); } | 110 | function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); } | ||
| 102 | function getChat(id) { return chats.find(c => c.id === id); } | 111 | function getChat(id) { return chats.find(c => c.id === id); } | ||
| 112 | +function publicChat(c) { | ||||
| 113 | + return { | ||||
| 114 | + id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName, | ||||
| 115 | + model: c.model, permMode: c.permMode, state: c.state, | ||||
| 116 | + claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null, | ||||
| 117 | + totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0, | ||||
| 118 | + created: c.created, updated: c.updated, | ||||
| 119 | + }; | ||||
| 120 | +} | ||||
| 103 | function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); } | 121 | function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); } | ||
| 104 | function appendTranscript(chatId, event) { | 122 | function appendTranscript(chatId, event) { | ||
| 105 | fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {}); | 123 | fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {}); | ||
@@ -112,15 +130,8 @@ function readTranscript(chatId) { | |||||
| 112 | } catch { return []; } | 130 | } catch { return []; } | ||
| 113 | } | 131 | } | ||
| 114 | 132 | ||||
| 115 | −// ---------- projets ---------- | 133 | +// ---------- projets (12 apps Groupe KA) ---------- | ||
| 116 | −// Doctrine remote-first Groupe Ka : chaque app KA se modifie DIRECTEMENT sur son | ||||
| 117 | −// nœud de déploiement (repo de prod = source de vérité). Le backend lance donc | ||||
| 118 | −// claude via SSH sur le bon nœud pour ces projets. | ||||
| 119 | const SELF_NODE = 'M3U96a'; | 134 | const SELF_NODE = 'M3U96a'; | ||
| 120 | −const SELF_LAN_HOST = config.selfLanHost || 'MacStudio-de-simon-pierre-2.local'; | ||||
| 121 | −// Uniquement les 12 apps du Groupe KA (demande utilisateur 2026-08-19). | ||||
| 122 | −// node:null = app déployée sur M3U96a (exécution locale) ; sinon claude est | ||||
| 123 | −// lancé via SSH sur le nœud de déploiement, dans le repo de prod. | ||||
| 124 | const PROJECTS = [ | 135 | const PROJECTS = [ | ||
| 125 | { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' }, | 136 | { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' }, | ||
| 126 | { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' }, | 137 | { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' }, | ||
@@ -136,16 +147,25 @@ const PROJECTS = [ | |||||
| 136 | { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' }, | 147 | { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' }, | ||
| 137 | ]; | 148 | ]; | ||
| 138 | function listProjects() { | 149 | function listProjects() { | ||
| 139 | − return PROJECTS.map(p => ({ id: p.id, name: p.name + ' · ' + (p.node || SELF_NODE), node: p.node, dir: p.dir })); | 150 | + return PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir })); | ||
| 140 | −} | ||||
| 141 | −function findProject(id) { | ||||
| 142 | − return listProjects().find(p => p.id === id) || null; | ||||
| 143 | } | 151 | } | ||
| 152 | +function findProject(id) { return PROJECTS.find(p => p.id === id) || null; } | ||||
| 153 | + | ||||
| 154 | +// ---------- exécution Claude Code ---------- | ||||
| 155 | +const MODELS = ['fable', 'opus', 'sonnet', 'haiku']; | ||||
| 156 | +// mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions) | ||||
| 157 | +const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' }; | ||||
| 158 | +const DEFAULT_MODEL = 'fable'; | ||||
| 159 | +const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass']; | ||||
| 160 | +const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task'; | ||||
| 161 | + | ||||
| 162 | +const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`; | ||||
| 163 | + | ||||
| 164 | +const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; | ||||
| 144 | 165 | ||||
| 145 | −// ---------- processus Claude en cours ---------- | 166 | +// chatId -> { proc, permToken, stderr, remote } | ||
| 146 | −// chatId -> { proc, buffer:'', permToken, killTimer } | ||||
| 147 | const running = new Map(); | 167 | const running = new Map(); | ||
| 148 | −// requestId -> { chatId, resolve, tool, input, timer } | 168 | +// requestId -> { chatId, resolve, timer, input, toolKey } | ||
| 149 | const pendingPerms = new Map(); | 169 | const pendingPerms = new Map(); | ||
| 150 | 170 | ||||
| 151 | function broadcast(chatId, msg) { | 171 | function broadcast(chatId, msg) { | ||
@@ -154,35 +174,51 @@ function broadcast(chatId, msg) { | |||||
| 154 | if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data); | 174 | if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data); | ||
| 155 | } | 175 | } | ||
| 156 | } | 176 | } | ||
| 157 | − | 177 | +function broadcastAll(msg) { | ||
| 178 | + const data = JSON.stringify(msg); | ||||
| 179 | + for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data); | ||||
| 180 | +} | ||||
| 181 | +function broadcastEco(msg) { | ||||
| 182 | + const data = JSON.stringify(msg); | ||||
| 183 | + for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data); | ||||
| 184 | +} | ||||
| 158 | function emitAndLog(chatId, msg) { | 185 | function emitAndLog(chatId, msg) { | ||
| 159 | appendTranscript(chatId, msg); | 186 | appendTranscript(chatId, msg); | ||
| 160 | broadcast(chatId, msg); | 187 | broadcast(chatId, msg); | ||
| 161 | } | 188 | } | ||
| 189 | +function setChatState(chat, state) { | ||||
| 190 | + chat.state = state; | ||||
| 191 | + chat.updated = Date.now(); | ||||
| 192 | + saveChats(); | ||||
| 193 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | ||||
| 194 | +} | ||||
| 162 | 195 | ||||
| 163 | −const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task'; | 196 | +function permKey(tool, input) { | ||
| 164 | − | 197 | + if (tool === 'Bash') { | ||
| 165 | −// Doctrine Groupe KA imposée à chaque session lancée par la console | 198 | + const first = String(input?.command || '').trim().split(/\s+/)[0] || '?'; | ||
| 166 | −const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`; | 199 | + return 'Bash:' + first; | ||
| 167 | − | 200 | + } | ||
| 168 | −// quoting shell sûr pour la commande ssh distante | 201 | + return tool; | ||
| 169 | −const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; | 202 | +} | ||
| 170 | 203 | ||||
| 171 | −function startClaude(chat, prompt, mode) { | 204 | +function startClaude(chat, prompt) { | ||
| 172 | if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.'); | 205 | if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.'); | ||
| 173 | const permToken = crypto.randomBytes(16).toString('hex'); | 206 | const permToken = crypto.randomBytes(16).toString('hex'); | ||
| 207 | + const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL; | ||||
| 208 | + const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default'; | ||||
| 174 | let proc; | 209 | let proc; | ||
| 175 | 210 | ||||
| 176 | const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', | 211 | const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', | ||
| 177 | − '--append-system-prompt', SYSTEM_APPEND]; | 212 | + '--model', CLI_MODEL[model], '--append-system-prompt', SYSTEM_APPEND]; | ||
| 178 | if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId); | 213 | if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId); | ||
| 179 | 214 | ||||
| 180 | if (!chat.node) { | 215 | if (!chat.node) { | ||
| 181 | − // --- exécution locale (projet sur M3U96a) --- | 216 | + // exécution locale (app déployée sur M3U96a) | ||
| 182 | const args = [...baseArgs]; | 217 | const args = [...baseArgs]; | ||
| 183 | − if (mode === 'auto') { | 218 | + if (permMode === 'bypass') { | ||
| 184 | args.push('--dangerously-skip-permissions'); | 219 | args.push('--dangerously-skip-permissions'); | ||
| 185 | } else { | 220 | } else { | ||
| 221 | + args.push('--permission-mode', permMode); | ||||
| 186 | const mcpCfg = { | 222 | const mcpCfg = { | ||
| 187 | mcpServers: { | 223 | mcpServers: { | ||
| 188 | adminka: { | 224 | adminka: { | ||
@@ -204,21 +240,18 @@ function startClaude(chat, prompt, mode) { | |||||
| 204 | proc = spawn(CLAUDE_BIN, args, { | 240 | proc = spawn(CLAUDE_BIN, args, { | ||
| 205 | cwd: chat.dir, | 241 | cwd: chat.dir, | ||
| 206 | env: { | 242 | env: { | ||
| 207 | − ...process.env, | 243 | + ...process.env, ANTHROPIC_API_KEY, | ||
| 208 | − ANTHROPIC_API_KEY, | 244 | + PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME, | ||
| 209 | − PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', | ||||
| 210 | − HOME, | ||||
| 211 | }, | 245 | }, | ||
| 212 | stdio: ['pipe', 'pipe', 'pipe'], | 246 | stdio: ['pipe', 'pipe', 'pipe'], | ||
| 247 | + detached: false, | ||||
| 213 | }); | 248 | }); | ||
| 214 | } else { | 249 | } else { | ||
| 215 | − // --- exécution distante : claude tourne SUR le nœud de déploiement de l'app, | 250 | + // exécution distante : claude tourne SUR le nœud de déploiement de l'app, | ||
| 216 | − // --- dans le repo de prod (source de vérité). Le prompt passe par stdin. | 251 | + // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée). | ||
| 217 | − // --- Les demandes de permission reviennent par un tunnel SSH inverse | 252 | + const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6']; | ||
| 218 | − // --- (l'IPv4 LAN inter-nœuds n'est pas routée partout). | ||||
| 219 | − const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15']; | ||||
| 220 | let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' '); | 253 | let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' '); | ||
| 221 | − if (mode === 'auto') { | 254 | + if (permMode === 'bypass') { | ||
| 222 | cmd += ' --dangerously-skip-permissions'; | 255 | cmd += ' --dangerously-skip-permissions'; | ||
| 223 | } else { | 256 | } else { | ||
| 224 | const revPort = 21000 + Math.floor(Math.random() * 3000); | 257 | const revPort = 21000 + Math.floor(Math.random() * 3000); | ||
@@ -236,7 +269,7 @@ function startClaude(chat, prompt, mode) { | |||||
| 236 | }, | 269 | }, | ||
| 237 | }; | 270 | }; | ||
| 238 | const cfgFile = `/tmp/adminka-mcp-${permToken}.json`; | 271 | const cfgFile = `/tmp/adminka-mcp-${permToken}.json`; | ||
| 239 | − cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`; | 272 | + cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`; | ||
| 240 | } | 273 | } | ||
| 241 | const remoteCmd = [ | 274 | const remoteCmd = [ | ||
| 242 | `cd ${chat.dir}`, | 275 | `cd ${chat.dir}`, | ||
@@ -255,8 +288,9 @@ function startClaude(chat, prompt, mode) { | |||||
| 255 | 288 | ||||
| 256 | const state = { proc, permToken, stderr: '', remote: chat.node || null }; | 289 | const state = { proc, permToken, stderr: '', remote: chat.node || null }; | ||
| 257 | running.set(chat.id, state); | 290 | running.set(chat.id, state); | ||
| 258 | − audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, mode, prompt: prompt.slice(0, 2000) }); | 291 | + audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) }); | ||
| 259 | emitAndLog(chat.id, { type: 'status', state: 'running' }); | 292 | emitAndLog(chat.id, { type: 'status', state: 'running' }); | ||
| 293 | + setChatState(chat, 'running'); | ||||
| 260 | 294 | ||||
| 261 | let buf = ''; | 295 | let buf = ''; | ||
| 262 | proc.stdout.on('data', (d) => { | 296 | proc.stdout.on('data', (d) => { | ||
@@ -274,27 +308,30 @@ function startClaude(chat, prompt, mode) { | |||||
| 274 | proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); }); | 308 | proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); }); | ||
| 275 | proc.on('close', (code) => { | 309 | proc.on('close', (code) => { | ||
| 276 | running.delete(chat.id); | 310 | running.delete(chat.id); | ||
| 277 | − // refuser les permissions encore en attente pour ce chat | ||||
| 278 | for (const [rid, p] of pendingPerms) { | 311 | for (const [rid, p] of pendingPerms) { | ||
| 279 | − if (p.chatId === chat.id) { p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); } | 312 | + if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); } | ||
| 280 | } | 313 | } | ||
| 281 | if (code !== 0 && code !== null) { | 314 | if (code !== 0 && code !== null) { | ||
| 282 | emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` }); | 315 | emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` }); | ||
| 283 | } | 316 | } | ||
| 284 | emitAndLog(chat.id, { type: 'status', state: 'idle' }); | 317 | emitAndLog(chat.id, { type: 'status', state: 'idle' }); | ||
| 285 | − chat.updated = Date.now(); saveChats(); | 318 | + setChatState(chat, 'idle'); | ||
| 319 | + broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName }); | ||||
| 286 | }); | 320 | }); | ||
| 287 | proc.on('error', (err) => { | 321 | proc.on('error', (err) => { | ||
| 288 | running.delete(chat.id); | 322 | running.delete(chat.id); | ||
| 289 | emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message }); | 323 | emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message }); | ||
| 290 | emitAndLog(chat.id, { type: 'status', state: 'idle' }); | 324 | emitAndLog(chat.id, { type: 'status', state: 'idle' }); | ||
| 325 | + setChatState(chat, 'idle'); | ||||
| 291 | }); | 326 | }); | ||
| 292 | } | 327 | } | ||
| 293 | 328 | ||||
| 294 | function handleClaudeEvent(chat, ev) { | 329 | function handleClaudeEvent(chat, ev) { | ||
| 295 | − if (ev.type === 'system' && ev.subtype === 'init' && ev.session_id) { | 330 | + if (ev.type === 'system' && ev.subtype === 'init') { | ||
| 296 | − chat.claudeSessionId = ev.session_id; | 331 | + if (ev.session_id) chat.claudeSessionId = ev.session_id; | ||
| 332 | + if (ev.model) chat.lastModel = ev.model; | ||||
| 297 | saveChats(); | 333 | saveChats(); | ||
| 334 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | ||||
| 298 | } | 335 | } | ||
| 299 | if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { | 336 | if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { | ||
| 300 | for (const block of ev.message.content) { | 337 | for (const block of ev.message.content) { | ||
@@ -304,10 +341,14 @@ function handleClaudeEvent(chat, ev) { | |||||
| 304 | } | 341 | } | ||
| 305 | } | 342 | } | ||
| 306 | if (ev.type === 'result') { | 343 | if (ev.type === 'result') { | ||
| 344 | + chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0); | ||||
| 345 | + chat.turns = (chat.turns || 0) + (ev.num_turns || 0); | ||||
| 346 | + const u = ev.usage || {}; | ||||
| 347 | + chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); | ||||
| 348 | + saveChats(); | ||||
| 307 | audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns }); | 349 | audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns }); | ||
| 308 | − chat.updated = Date.now(); saveChats(); | 350 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | ||
| 309 | } | 351 | } | ||
| 310 | − // ne jamais persister les stream_event (volumineux, reconstruits depuis les messages complets) | ||||
| 311 | if (ev.type === 'stream_event') { | 352 | if (ev.type === 'stream_event') { | ||
| 312 | broadcast(chat.id, { type: 'claude', event: ev }); | 353 | broadcast(chat.id, { type: 'claude', event: ev }); | ||
| 313 | } else { | 354 | } else { | ||
@@ -352,24 +393,35 @@ const server = http.createServer(async (req, res) => { | |||||
| 352 | 393 | ||||
| 353 | // --- endpoint interne de permission (long-poll par perm-mcp.js) --- | 394 | // --- endpoint interne de permission (long-poll par perm-mcp.js) --- | ||
| 354 | if (p === '/internal/perm' && req.method === 'POST') { | 395 | if (p === '/internal/perm' && req.method === 'POST') { | ||
| 355 | − // accessible depuis le LAN (perm-mcp des nœuds distants) — authentifié par le | ||||
| 356 | − // token de run (32 hex aléatoires, valable seulement pendant l'exécution) | ||||
| 357 | let body; | 396 | let body; | ||
| 358 | try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } | 397 | try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } | ||
| 359 | let chatId = null; | 398 | let chatId = null; | ||
| 360 | for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid; | 399 | for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid; | ||
| 361 | if (!chatId) return json(res, 403, { error: 'bad token' }); | 400 | if (!chatId) return json(res, 403, { error: 'bad token' }); | ||
| 401 | + const chat = getChat(chatId); | ||||
| 402 | + const toolKey = permKey(body.tool_name, body.input); | ||||
| 403 | + | ||||
| 404 | + // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation | ||||
| 405 | + if (chat && (chat.alwaysAllow || []).includes(toolKey)) { | ||||
| 406 | + audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey }); | ||||
| 407 | + emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input }); | ||||
| 408 | + return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} }); | ||||
| 409 | + } | ||||
| 410 | + | ||||
| 362 | const requestId = crypto.randomBytes(8).toString('hex'); | 411 | const requestId = crypto.randomBytes(8).toString('hex'); | ||
| 363 | const p2 = new Promise((resolve) => { | 412 | const p2 = new Promise((resolve) => { | ||
| 364 | const timer = setTimeout(() => { | 413 | const timer = setTimeout(() => { | ||
| 365 | pendingPerms.delete(requestId); | 414 | pendingPerms.delete(requestId); | ||
| 366 | emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' }); | 415 | emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' }); | ||
| 367 | − resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 10 min)" }); | 416 | + if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running'); | ||
| 368 | − }, 10 * 60 * 1000); | 417 | + resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" }); | ||
| 369 | − pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input }); | 418 | + }, 30 * 60 * 1000); | ||
| 419 | + pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey }); | ||||
| 370 | }); | 420 | }); | ||
| 371 | audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) }); | 421 | audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) }); | ||
| 372 | emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input }); | 422 | emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input }); | ||
| 423 | + if (chat) setChatState(chat, 'waiting_perm'); | ||||
| 424 | + broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name }); | ||||
| 373 | const decision = await p2; | 425 | const decision = await p2; | ||
| 374 | return json(res, 200, decision); | 426 | return json(res, 200, decision); | ||
| 375 | } | 427 | } | ||
@@ -403,11 +455,10 @@ const server = http.createServer(async (req, res) => { | |||||
| 403 | // --- API authentifiée --- | 455 | // --- API authentifiée --- | ||
| 404 | if (p.startsWith('/api/')) { | 456 | if (p.startsWith('/api/')) { | ||
| 405 | if (!getSession(req)) return json(res, 401, { error: 'auth' }); | 457 | if (!getSession(req)) return json(res, 401, { error: 'auth' }); | ||
| 406 | − if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname() }); | 458 | + if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '2.0.0' }); | ||
| 407 | if (p === '/api/projects') return json(res, 200, { projects: listProjects() }); | 459 | if (p === '/api/projects') return json(res, 200, { projects: listProjects() }); | ||
| 408 | if (p === '/api/chats' && req.method === 'GET') { | 460 | if (p === '/api/chats' && req.method === 'GET') { | ||
| 409 | − const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100) | 461 | + const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat); | ||
| 410 | − .map(({ id, title, projectId, projectName, mode, updated, created }) => ({ id, title, projectId, projectName, mode, updated, created, running: running.has(id) })); | ||||
| 411 | return json(res, 200, { chats: list }); | 462 | return json(res, 200, { chats: list }); | ||
| 412 | } | 463 | } | ||
| 413 | if (p.startsWith('/api/chats/') && req.method === 'DELETE') { | 464 | if (p.startsWith('/api/chats/') && req.method === 'DELETE') { | ||
@@ -420,6 +471,12 @@ const server = http.createServer(async (req, res) => { | |||||
| 420 | } | 471 | } | ||
| 421 | return json(res, 400, { error: 'introuvable ou en cours' }); | 472 | return json(res, 400, { error: 'introuvable ou en cours' }); | ||
| 422 | } | 473 | } | ||
| 474 | + if (p === '/api/eco/summary') return json(res, 200, ecoSummary()); | ||||
| 475 | + if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() }); | ||||
| 476 | + if (p.startsWith('/api/eco/site/')) { | ||||
| 477 | + const d = ecoSite(p.split('/')[4]); | ||||
| 478 | + return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' }); | ||||
| 479 | + } | ||||
| 423 | return json(res, 404, { error: 'not found' }); | 480 | return json(res, 404, { error: 'not found' }); | ||
| 424 | } | 481 | } | ||
| 425 | 482 | ||||
@@ -449,6 +506,7 @@ server.on('upgrade', (req, socket, head) => { | |||||
| 449 | 506 | ||||
| 450 | wss.on('connection', (ws) => { | 507 | wss.on('connection', (ws) => { | ||
| 451 | ws.chatId = null; | 508 | ws.chatId = null; | ||
| 509 | + ws.eco = false; | ||||
| 452 | ws.isAlive = true; | 510 | ws.isAlive = true; | ||
| 453 | ws.on('pong', () => { ws.isAlive = true; }); | 511 | ws.on('pong', () => { ws.isAlive = true; }); | ||
| 454 | ws.on('message', (raw) => { | 512 | ws.on('message', (raw) => { | ||
@@ -458,6 +516,7 @@ wss.on('connection', (ws) => { | |||||
| 458 | ws.send(JSON.stringify({ type: 'error', message: e.message })); | 516 | ws.send(JSON.stringify({ type: 'error', message: e.message })); | ||
| 459 | } | 517 | } | ||
| 460 | }); | 518 | }); | ||
| 519 | + // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours. | ||||
| 461 | }); | 520 | }); | ||
| 462 | 521 | ||||
| 463 | setInterval(() => { | 522 | setInterval(() => { | ||
@@ -472,9 +531,10 @@ function handleWsMessage(ws, msg) { | |||||
| 472 | const chat = getChat(msg.chatId); | 531 | const chat = getChat(msg.chatId); | ||
| 473 | if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' })); | 532 | if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' })); | ||
| 474 | ws.chatId = chat.id; | 533 | ws.chatId = chat.id; | ||
| 534 | + // rejoue tout l'historique persisté puis raccroche le live | ||||
| 475 | ws.send(JSON.stringify({ | 535 | ws.send(JSON.stringify({ | ||
| 476 | type: 'history', | 536 | type: 'history', | ||
| 477 | − chat: { id: chat.id, title: chat.title, projectId: chat.projectId, projectName: chat.projectName, mode: chat.mode }, | 537 | + chat: publicChat(chat), | ||
| 478 | events: readTranscript(chat.id), | 538 | events: readTranscript(chat.id), | ||
| 479 | running: running.has(chat.id), | 539 | running: running.has(chat.id), | ||
| 480 | })); | 540 | })); | ||
@@ -483,29 +543,49 @@ function handleWsMessage(ws, msg) { | |||||
| 483 | if (msg.type === 'start') { | 543 | if (msg.type === 'start') { | ||
| 484 | const prompt = String(msg.prompt || '').trim(); | 544 | const prompt = String(msg.prompt || '').trim(); | ||
| 485 | if (!prompt) return; | 545 | if (!prompt) return; | ||
| 486 | − const mode = msg.mode === 'auto' ? 'auto' : 'safe'; | ||||
| 487 | let chat = msg.chatId ? getChat(msg.chatId) : null; | 546 | let chat = msg.chatId ? getChat(msg.chatId) : null; | ||
| 488 | if (!chat) { | 547 | if (!chat) { | ||
| 489 | − const proj = findProject(msg.project) || listProjects()[0]; | 548 | + const proj = findProject(msg.project) || PROJECTS[0]; | ||
| 490 | chat = { | 549 | chat = { | ||
| 491 | id: crypto.randomBytes(8).toString('hex'), | 550 | id: crypto.randomBytes(8).toString('hex'), | ||
| 492 | title: prompt.slice(0, 60), | 551 | title: prompt.slice(0, 60), | ||
| 493 | − projectId: proj.id, | 552 | + projectId: proj.id, projectName: proj.name, | ||
| 494 | − projectName: proj.name, | 553 | + node: proj.node, dir: proj.dir, | ||
| 495 | − node: proj.node, | 554 | + model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL, | ||
| 496 | − dir: proj.dir, | 555 | + permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default', | ||
| 497 | − mode, | 556 | + claudeSessionId: null, state: 'idle', | ||
| 498 | − claudeSessionId: null, | 557 | + totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [], | ||
| 499 | − created: Date.now(), | 558 | + created: Date.now(), updated: Date.now(), | ||
| 500 | − updated: Date.now(), | ||||
| 501 | }; | 559 | }; | ||
| 502 | chats.push(chat); saveChats(); | 560 | chats.push(chat); saveChats(); | ||
| 503 | − ws.send(JSON.stringify({ type: 'chat_created', chat: { id: chat.id, title: chat.title, projectId: chat.projectId, projectName: chat.projectName, mode: chat.mode } })); | 561 | + ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) })); | ||
| 562 | + } else { | ||||
| 563 | + if (MODELS.includes(msg.model)) chat.model = msg.model; | ||||
| 564 | + if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode; | ||||
| 565 | + saveChats(); | ||||
| 504 | } | 566 | } | ||
| 505 | − chat.mode = mode; saveChats(); | ||||
| 506 | ws.chatId = chat.id; | 567 | ws.chatId = chat.id; | ||
| 507 | emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() }); | 568 | emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() }); | ||
| 508 | − startClaude(chat, prompt, mode); | 569 | + startClaude(chat, prompt); | ||
| 570 | + return; | ||||
| 571 | + } | ||||
| 572 | + if (msg.type === 'set_opts') { | ||||
| 573 | + const chat = getChat(msg.chatId); | ||||
| 574 | + if (!chat) return; | ||||
| 575 | + if (MODELS.includes(msg.model)) chat.model = msg.model; | ||||
| 576 | + if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode; | ||||
| 577 | + saveChats(); | ||||
| 578 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | ||||
| 579 | + return; | ||||
| 580 | + } | ||||
| 581 | + if (msg.type === 'clear') { | ||||
| 582 | + const chat = getChat(msg.chatId); | ||||
| 583 | + if (!chat || running.has(chat.id)) return; | ||||
| 584 | + chat.claudeSessionId = null; | ||||
| 585 | + chat.contextTokens = 0; | ||||
| 586 | + saveChats(); | ||||
| 587 | + emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' }); | ||||
| 588 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | ||||
| 509 | return; | 589 | return; | ||
| 510 | } | 590 | } | ||
| 511 | if (msg.type === 'perm') { | 591 | if (msg.type === 'perm') { | ||
@@ -513,22 +593,41 @@ function handleWsMessage(ws, msg) { | |||||
| 513 | if (!pending) return; | 593 | if (!pending) return; | ||
| 514 | clearTimeout(pending.timer); | 594 | clearTimeout(pending.timer); | ||
| 515 | pendingPerms.delete(msg.requestId); | 595 | pendingPerms.delete(msg.requestId); | ||
| 516 | − audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow }); | 596 | + const chat = getChat(pending.chatId); | ||
| 517 | − emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow }); | 597 | + if (msg.allow && msg.always && chat) { | ||
| 598 | + if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey); | ||||
| 599 | + saveChats(); | ||||
| 600 | + } | ||||
| 601 | + audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always }); | ||||
| 602 | + emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always }); | ||||
| 603 | + if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running'); | ||||
| 518 | pending.resolve(msg.allow | 604 | pending.resolve(msg.allow | ||
| 519 | ? { behavior: 'allow', updatedInput: pending.input || {} } | 605 | ? { behavior: 'allow', updatedInput: pending.input || {} } | ||
| 520 | : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" }); | 606 | : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" }); | ||
| 521 | return; | 607 | return; | ||
| 522 | } | 608 | } | ||
| 523 | if (msg.type === 'interrupt') { | 609 | if (msg.type === 'interrupt') { | ||
| 524 | − if (ws.chatId) interrupt(ws.chatId); | 610 | + if (msg.chatId) interrupt(msg.chatId); | ||
| 611 | + else if (ws.chatId) interrupt(ws.chatId); | ||||
| 525 | return; | 612 | return; | ||
| 526 | } | 613 | } | ||
| 614 | + if (msg.type === 'eco_sub') { ws.eco = true; return; } | ||||
| 615 | + if (msg.type === 'eco_unsub') { ws.eco = false; return; } | ||||
| 527 | } | 616 | } | ||
| 528 | 617 | ||||
| 529 | −// '::' (dual-stack v4+v6) : ngrok (localhost) + perm-mcp des nœuds distants via | 618 | +// ---------- monitoring Écosystème ---------- | ||
| 530 | −// LAN (.local peut résoudre en IPv6 link-local). L'API est protégée par mot de | 619 | +startMonitor({ | ||
| 531 | −// passe ; /internal/perm par token de run. | 620 | + dataDir: DATA_DIR, | ||
| 621 | + onEvent: (ev) => { | ||||
| 622 | + if (ev.type === 'eco_alert') broadcastAll(ev); // alertes → tout le monde | ||||
| 623 | + else broadcastEco(ev); // ticks → abonnés à l'onglet | ||||
| 624 | + }, | ||||
| 625 | +}); | ||||
| 626 | + | ||||
| 627 | +// au démarrage, les chats marqués "running" par un ancien processus sont orphelins | ||||
| 628 | +for (const c of chats) if (c.state !== 'idle') c.state = 'idle'; | ||||
| 629 | +saveChats(); | ||||
| 630 | + | ||||
| 532 | server.listen(PORT, '::', () => { | 631 | server.listen(PORT, '::', () => { | ||
| 533 | − console.log(`admin-ka backend sur port ${PORT} (dual-stack)`); | 632 | + console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`); | ||
| 534 | }); | 633 | }); | ||
| 535 | 634 | ||||