From 7ac571974ffaecaf75c99419b8166c336487a863 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 22 Jul 2026 13:34:56 +0200 Subject: [PATCH] gestion du backend llama.cpp depuis l'UI web : statut (commit, retard, binaire, accelerateur detecte), verification des maj (git fetch), install / update / recompilation en job asynchrone avec phases, compteur de fichiers compiles et logs de build streames (poll /api/llamacpp/job) ; le service est arrete pendant le build puis relance, BIN repointe automatiquement --- internal/jean/backend_build.go | 62 +++- internal/jean/ui/index.html | 163 ++++++++- internal/jean/ui/src/index.tmpl.html | 15 + internal/jean/ui/src/js/06-settings.js | 2 +- internal/jean/ui/src/js/10-llamacpp.js | 140 ++++++++ internal/jean/ui/src/styles.css | 6 + internal/jean/web_llamacpp.go | 452 +++++++++++++++++++++++++ internal/jean/web_server.go | 5 + 8 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 internal/jean/ui/src/js/10-llamacpp.js create mode 100644 internal/jean/web_llamacpp.go diff --git a/internal/jean/backend_build.go b/internal/jean/backend_build.go index 4438a50..18ac68a 100644 --- a/internal/jean/backend_build.go +++ b/internal/jean/backend_build.go @@ -17,6 +17,52 @@ import ( "time" ) +// buildSink est un collecteur de lignes optionnel : quand il est posé (jobs +// web, voir web_llamacpp.go), runStep/runBuildStep y dupliquent leur sortie en +// plus du terminal / des fichiers de log. nil en usage CLI normal. +var ( + buildSinkMu sync.Mutex + buildSink func(string) +) + +func setBuildSink(f func(string)) { + buildSinkMu.Lock() + buildSink = f + buildSinkMu.Unlock() +} + +func emitBuildLine(line string) { + buildSinkMu.Lock() + f := buildSink + buildSinkMu.Unlock() + if f != nil { + f(line) + } +} + +// sinkWriter découpe un flux en lignes et les pousse vers emitBuildLine. +// Sert à téer la sortie des commandes de runStep quand un sink est actif. +// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle) +type sinkWriter struct { + mu sync.Mutex + buf []byte +} + +func (s *sinkWriter) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.buf = append(s.buf, p...) + for { + i := strings.IndexByte(string(s.buf), '\n') + if i < 0 { + break + } + emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r")) + s.buf = s.buf[i+1:] + } + return len(p), nil +} + func detectBuildPlan() buildPlan { p := buildPlan{backend: "cpu", jobs: numJobs()} // Flags communs : Release + tuning natif pour la machine de build. @@ -415,8 +461,19 @@ func runStepEnv(name, dir, extraEnv, bin string, args ...string) error { fmt.Printf("\n%s %s %s\n", cyan("▶"), name, dim(strings.Join(args, " "))) cmd := exec.Command(bin, args...) cmd.Dir = dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + // Tee vers le sink de build (jobs web) en plus du terminal. + var out io.Writer = os.Stdout + var errw io.Writer = os.Stderr + buildSinkMu.Lock() + sinkOn := buildSink != nil + buildSinkMu.Unlock() + if sinkOn { + sw := &sinkWriter{} + out = io.MultiWriter(os.Stdout, sw) + errw = io.MultiWriter(os.Stderr, sw) + } + cmd.Stdout = out + cmd.Stderr = errw cmd.Stdin = os.Stdin if extraEnv != "" { env := os.Environ() @@ -498,6 +555,7 @@ func runBuildStep(name, dir, extraEnv, bin, logPath string, args ...string) erro if logf != nil { fmt.Fprintln(logf, line) } + emitBuildLine(line) mu.Lock() if f := compiledFile(line); f != "" { count++ diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 58b4428..5874b93 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -290,6 +290,12 @@ button:disabled{opacity:.5;cursor:not-allowed} h2,details>summary{font-size:10px} #menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))} } +/* --- Backend llama.cpp (gestion install/update depuis l'UI) --------------- */ +.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} +.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} +.lc-logbar.open #lc-log-caret{transform:rotate(90deg)} +#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0} +.lc-spin{display:inline-block;animation:spin 1s linear infinite}
@@ -388,6 +394,21 @@ button:disabled{opacity:.5;cursor:not-allowed} +
Backend llama.cpp +
…
+
+ + + + +
+
+ +
Service
@@ -944,7 +965,7 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; } function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; } @@ -1570,4 +1591,144 @@ async function send(){ loadAll(); setInterval(loadStatus, 5000); setInterval(loadVram, 3000); +// Backend llama.cpp : statut, vérification des maj, install / update / rebuild +// en job asynchrone côté serveur (logs + progression pollés sur /api/llamacpp/job). +let lcState = null, lcPoll = null, lcLogNext = 0; + +function lcBadge(html){ document.getElementById('lc-badge').innerHTML = html; } + +async function loadLlamacpp(){ + let s; + try{ s = await jget('/api/llamacpp'); }catch(_){ return; } + lcState = s; + const st = document.getElementById('lc-status'); + const parts = []; + if(!s.installed){ + parts.push('llama.cpp n\'est pas encore installé sur cette machine.'); + parts.push('L\'installation clone le dépôt, détecte le GPU et compile llama-server automatiquement.'); + lcBadge('absent'); + } else { + const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c])); + parts.push('commit '+esc(s.commit)+''+(s.branch?' ('+esc(s.branch)+')':'')); + if(s.commit_date) parts.push(''+esc(String(s.commit_date).slice(0,16))+' — '+esc(s.commit_msg)); + if(s.bin){ + parts.push('binaire : ✓ compilé'+(s.in_use?' · utilisé par la config':' · non utilisé par la config (BIN pointe ailleurs)')); + } else { + parts.push('binaire : absent (pas encore compilé)'); + } + if(s.behind > 0) lcBadge('maj dispo'); + else if(s.bin) lcBadge('ok'); + else lcBadge('à compiler'); + } + if(s.plan){ + const p = s.plan; + parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); + } + st.innerHTML = parts.join('
'); + document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; + document.getElementById('lc-check').style.display = s.installed ? '' : 'none'; + document.getElementById('lc-update').style.display = (s.installed && (s.behind>0 || !s.bin)) ? '' : 'none'; + document.getElementById('lc-rebuild').style.display = s.installed ? '' : 'none'; + // Job en cours (page rechargée pendant un build) → on raccroche le polling. + if(s.job && s.job.exists && s.job.running && !lcPoll){ + document.getElementById('lc-details').open = true; + lcStartPolling(); + } +} + +async function lcCheck(){ + const btn = document.getElementById('lc-check'); + const msg = document.getElementById('lc-check-msg'); + btn.disabled = true; btn.textContent = '⏳ vérification…'; + try{ + const r = await jpost('/api/llamacpp/check'); + if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } + if(r.behind > 0){ + msg.innerHTML = ''+r.behind+' commit(s) de retard sur origin/'+r.branch+'
'+ + 'dernier distant : '+r.remote+' — '+String(r.remote_msg||'').replace(/[<>&]/g,''); + document.getElementById('lc-update').style.display = ''; + lcBadge('maj dispo'); + } else if(!r.has_binary){ + msg.innerHTML = 'sources à jour mais binaire absent — lance une recompilation.'; + } else { + msg.innerHTML = '✓ à jour ('+r.local+')'; + } + } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } +} + +async function lcInstall(){ + if(!await askConfirm('Cloner llama.cpp puis compiler llama-server pour cette machine (détection GPU automatique). La compilation peut prendre de longues minutes (surtout en CUDA).', {title:'Installer llama.cpp ?', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/install', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +async function lcUpdate(clean){ + const msg = clean + ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' + : 'Mettre à jour llama.cpp (git pull) et recompiler. Le service sera arrêté pendant le build puis redémarré.'; + if(!await askConfirm(msg, {title: clean ? 'Recompiler llama.cpp ?' : 'Mettre à jour llama.cpp ?', okText: clean ? 'Recompiler' : 'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/update', {clean: !!clean}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +function lcSetButtons(disabled){ + ['lc-install','lc-check','lc-update','lc-rebuild'].forEach(id=>{ + const b=document.getElementById(id); if(b) b.disabled = disabled; + }); +} + +function lcStartPolling(){ + const jobBox = document.getElementById('lc-job'); + jobBox.style.display = ''; + document.getElementById('lc-log').textContent = ''; + lcLogNext = 0; + lcSetButtons(true); + lcBadge('⏳ build'); + if(lcPoll) clearInterval(lcPoll); + lcPoll = setInterval(lcPollJob, 1000); + lcPollJob(); +} + +async function lcPollJob(){ + let j; + try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + if(!j.exists) return; + const phaseEl = document.getElementById('lc-job-phase'); + // Logs incrémentaux (offset absolu — le serveur tronque le début si besoin). + if(j.lines && j.lines.length){ + const pre = document.getElementById('lc-log'); + const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; + pre.textContent += j.lines.join('\n') + '\n'; + if(stick) pre.scrollTop = pre.scrollHeight; + } + if(typeof j.next === 'number') lcLogNext = j.next; + if(j.running){ + phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; + return; + } + // Terminé. + clearInterval(lcPoll); lcPoll = null; + lcSetButtons(false); + if(j.error){ + phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; + // Ouvre les logs pour que l'erreur réelle soit visible tout de suite. + const pre = document.getElementById('lc-log'); + if(pre.hasAttribute('hidden')) lcToggleLog(); + pre.scrollTop = pre.scrollHeight; + toast('build llama.cpp en échec'); + } else { + phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; + toast('llama.cpp : '+(j.phase||'terminé')); + } + loadAll(); // rafraîchit tout (config, statut service… et ce panneau) +} + +function lcToggleLog(){ + const pre = document.getElementById('lc-log'); + const bar = document.querySelector('.lc-logbar'); + if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; } + else { pre.setAttribute('hidden',''); bar.classList.remove('open'); } +} diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 9d50b61..f00ed94 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -116,6 +116,21 @@
+
Backend llama.cpp +
…
+
+ + + + +
+
+ +
Service
diff --git a/internal/jean/ui/src/js/06-settings.js b/internal/jean/ui/src/js/06-settings.js index f1e6b23..4fd803e 100644 --- a/internal/jean/ui/src/js/06-settings.js +++ b/internal/jean/ui/src/js/06-settings.js @@ -216,5 +216,5 @@ async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); } -async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs()]); } +async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadApiKey(),loadPrefs(),loadLlamacpp()]); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/jean/ui/src/js/10-llamacpp.js b/internal/jean/ui/src/js/10-llamacpp.js new file mode 100644 index 0000000..54520c8 --- /dev/null +++ b/internal/jean/ui/src/js/10-llamacpp.js @@ -0,0 +1,140 @@ +// Backend llama.cpp : statut, vérification des maj, install / update / rebuild +// en job asynchrone côté serveur (logs + progression pollés sur /api/llamacpp/job). +let lcState = null, lcPoll = null, lcLogNext = 0; + +function lcBadge(html){ document.getElementById('lc-badge').innerHTML = html; } + +async function loadLlamacpp(){ + let s; + try{ s = await jget('/api/llamacpp'); }catch(_){ return; } + lcState = s; + const st = document.getElementById('lc-status'); + const parts = []; + if(!s.installed){ + parts.push('llama.cpp n\'est pas encore installé sur cette machine.'); + parts.push('L\'installation clone le dépôt, détecte le GPU et compile llama-server automatiquement.'); + lcBadge('absent'); + } else { + const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c])); + parts.push('commit '+esc(s.commit)+''+(s.branch?' ('+esc(s.branch)+')':'')); + if(s.commit_date) parts.push(''+esc(String(s.commit_date).slice(0,16))+' — '+esc(s.commit_msg)); + if(s.bin){ + parts.push('binaire : ✓ compilé'+(s.in_use?' · utilisé par la config':' · non utilisé par la config (BIN pointe ailleurs)')); + } else { + parts.push('binaire : absent (pas encore compilé)'); + } + if(s.behind > 0) lcBadge('maj dispo'); + else if(s.bin) lcBadge('ok'); + else lcBadge('à compiler'); + } + if(s.plan){ + const p = s.plan; + parts.push('accélérateur détecté : '+p.backend.toUpperCase()+''+(p.arch?' (arch '+p.arch+')':'')+' · '+p.jobs+' jobs'); + } + st.innerHTML = parts.join('
'); + document.getElementById('lc-install').style.display = s.installed ? 'none' : ''; + document.getElementById('lc-check').style.display = s.installed ? '' : 'none'; + document.getElementById('lc-update').style.display = (s.installed && (s.behind>0 || !s.bin)) ? '' : 'none'; + document.getElementById('lc-rebuild').style.display = s.installed ? '' : 'none'; + // Job en cours (page rechargée pendant un build) → on raccroche le polling. + if(s.job && s.job.exists && s.job.running && !lcPoll){ + document.getElementById('lc-details').open = true; + lcStartPolling(); + } +} + +async function lcCheck(){ + const btn = document.getElementById('lc-check'); + const msg = document.getElementById('lc-check-msg'); + btn.disabled = true; btn.textContent = '⏳ vérification…'; + try{ + const r = await jpost('/api/llamacpp/check'); + if(!r.ok){ msg.innerHTML = ''+(r.error||'erreur')+''; return; } + if(r.behind > 0){ + msg.innerHTML = ''+r.behind+' commit(s) de retard sur origin/'+r.branch+'
'+ + 'dernier distant : '+r.remote+' — '+String(r.remote_msg||'').replace(/[<>&]/g,''); + document.getElementById('lc-update').style.display = ''; + lcBadge('maj dispo'); + } else if(!r.has_binary){ + msg.innerHTML = 'sources à jour mais binaire absent — lance une recompilation.'; + } else { + msg.innerHTML = '✓ à jour ('+r.local+')'; + } + } finally { btn.disabled = false; btn.textContent = '↥ vérifier les maj'; } +} + +async function lcInstall(){ + if(!await askConfirm('Cloner llama.cpp puis compiler llama-server pour cette machine (détection GPU automatique). La compilation peut prendre de longues minutes (surtout en CUDA).', {title:'Installer llama.cpp ?', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/install', {}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +async function lcUpdate(clean){ + const msg = clean + ? 'Recompiler llama.cpp from scratch (build/ supprimé). Le service sera arrêté pendant le build puis redémarré.' + : 'Mettre à jour llama.cpp (git pull) et recompiler. Le service sera arrêté pendant le build puis redémarré.'; + if(!await askConfirm(msg, {title: clean ? 'Recompiler llama.cpp ?' : 'Mettre à jour llama.cpp ?', okText: clean ? 'Recompiler' : 'Mettre à jour'})) return; + const r = await jpost('/api/llamacpp/update', {clean: !!clean}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + lcStartPolling(); +} + +function lcSetButtons(disabled){ + ['lc-install','lc-check','lc-update','lc-rebuild'].forEach(id=>{ + const b=document.getElementById(id); if(b) b.disabled = disabled; + }); +} + +function lcStartPolling(){ + const jobBox = document.getElementById('lc-job'); + jobBox.style.display = ''; + document.getElementById('lc-log').textContent = ''; + lcLogNext = 0; + lcSetButtons(true); + lcBadge('⏳ build'); + if(lcPoll) clearInterval(lcPoll); + lcPoll = setInterval(lcPollJob, 1000); + lcPollJob(); +} + +async function lcPollJob(){ + let j; + try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + if(!j.exists) return; + const phaseEl = document.getElementById('lc-job-phase'); + // Logs incrémentaux (offset absolu — le serveur tronque le début si besoin). + if(j.lines && j.lines.length){ + const pre = document.getElementById('lc-log'); + const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; + pre.textContent += j.lines.join('\n') + '\n'; + if(stick) pre.scrollTop = pre.scrollHeight; + } + if(typeof j.next === 'number') lcLogNext = j.next; + if(j.running){ + phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; + return; + } + // Terminé. + clearInterval(lcPoll); lcPoll = null; + lcSetButtons(false); + if(j.error){ + phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; + // Ouvre les logs pour que l'erreur réelle soit visible tout de suite. + const pre = document.getElementById('lc-log'); + if(pre.hasAttribute('hidden')) lcToggleLog(); + pre.scrollTop = pre.scrollHeight; + toast('build llama.cpp en échec'); + } else { + phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; + toast('llama.cpp : '+(j.phase||'terminé')); + } + loadAll(); // rafraîchit tout (config, statut service… et ce panneau) +} + +function lcToggleLog(){ + const pre = document.getElementById('lc-log'); + const bar = document.querySelector('.lc-logbar'); + if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; } + else { pre.setAttribute('hidden',''); bar.classList.remove('open'); } +} diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css index fb40d2f..8fb570d 100644 --- a/internal/jean/ui/src/styles.css +++ b/internal/jean/ui/src/styles.css @@ -271,3 +271,9 @@ button:disabled{opacity:.5;cursor:not-allowed} h2,details>summary{font-size:10px} #menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))} } +/* --- Backend llama.cpp (gestion install/update depuis l'UI) --------------- */ +.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;font-weight:600;padding:6px 0;margin-top:4px} +.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} +.lc-logbar.open #lc-log-caret{transform:rotate(90deg)} +#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0} +.lc-spin{display:inline-block;animation:spin 1s linear infinite} diff --git a/internal/jean/web_llamacpp.go b/internal/jean/web_llamacpp.go new file mode 100644 index 0000000..0224fa5 --- /dev/null +++ b/internal/jean/web_llamacpp.go @@ -0,0 +1,452 @@ +// web_llamacpp.go — pilotage du backend llama.cpp depuis l'UI web : +// statut (commit, retard, binaire, plan de build), vérification des mises à +// jour (git fetch), et jobs asynchrones install / update / rebuild dont la +// progression et les logs sont pollés par le client. +// +// GET /api/llamacpp → statut complet (dépôt, commit, binaire, plan) +// POST /api/llamacpp/check → git fetch + retard sur origin (réseau) +// POST /api/llamacpp/install → job d'installation {force?} +// POST /api/llamacpp/update → job de mise à jour {clean?} +// GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur) +package jean + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// lcJob est LE job llama.cpp en cours (un seul à la fois). Les lignes de log +// sont indexées de façon absolue (base = index de Lines[0]) pour que le client +// puisse poller « à partir de N » même quand on tronque le début. +type lcJob struct { + Action string // "install" | "update" + Phase string + Compiled int // fichiers compilés (progression du build) + Running bool + Err string + StartedAt int64 + EndedAt int64 + OldCommit string + NewCommit string + lines []string + base int +} + +var ( + lcMu sync.Mutex + lcCur *lcJob +) + +const lcMaxLines = 4000 + +// lcAppend ajoute une ligne au log du job courant et met à jour la phase / le +// compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le +// spinner du CLI). Appelée par le sink de build (emitBuildLine). +func lcAppend(line string) { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur == nil { + return + } + lcCur.lines = append(lcCur.lines, line) + if len(lcCur.lines) > lcMaxLines { + drop := len(lcCur.lines) - lcMaxLines + lcCur.lines = append([]string(nil), lcCur.lines[drop:]...) + lcCur.base += drop + } + if f := compiledFile(line); f != "" { + lcCur.Compiled++ + lcCur.Phase = fmt.Sprintf("compilation… %d fichiers", lcCur.Compiled) + } else if p := phaseLabel(line); p != "" { + lcCur.Phase = p + } +} + +// lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…) +// et la trace aussi dans le log. +func lcPhase(phase string) { + lcMu.Lock() + if lcCur != nil { + lcCur.Phase = phase + lcCur.lines = append(lcCur.lines, "▶ "+phase) + } + lcMu.Unlock() +} + +// startLcJob démarre un job (install ou update) si aucun n'est en cours. +func startLcJob(action string, run func()) error { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur != nil && lcCur.Running { + return fmt.Errorf("un job %s est déjà en cours", lcCur.Action) + } + lcCur = &lcJob{Action: action, Running: true, Phase: "démarrage…", StartedAt: time.Now().Unix()} + setBuildSink(lcAppend) + go func() { + defer func() { + setBuildSink(nil) + lcMu.Lock() + lcCur.Running = false + lcCur.EndedAt = time.Now().Unix() + lcMu.Unlock() + }() + run() + }() + return nil +} + +func lcFail(err error) { + lcMu.Lock() + if lcCur != nil { + lcCur.Err = err.Error() + lcCur.Phase = "échec" + lcCur.lines = append(lcCur.lines, "✗ "+err.Error()) + } + lcMu.Unlock() +} + +func lcDone(msg string) { + lcMu.Lock() + if lcCur != nil { + lcCur.Phase = msg + lcCur.lines = append(lcCur.lines, "✓ "+msg) + } + lcMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// handleLlamacpp renvoie le statut complet du backend llama.cpp : dépôt, +// commit courant, retard connu (sans fetch réseau), binaire compilé et son +// usage dans config.env, plan de build détecté, et le job éventuel. +func handleLlamacpp(w http.ResponseWriter, r *http.Request) { + repo := llamacppRepoDir() + out := map[string]any{ + "repo": repo, + "installed": isDir(filepath.Join(repo, ".git")), + } + if out["installed"].(bool) { + out["branch"] = gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + out["commit"] = gitOutput(repo, "rev-parse", "--short", "HEAD") + out["commit_date"] = gitOutput(repo, "log", "-1", "--format=%ci") + out["commit_msg"] = gitOutput(repo, "log", "-1", "--format=%s") + if br, _ := out["branch"].(string); br != "" && br != "HEAD" { + if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+br); behind != "" { + n, _ := strconv.Atoi(behind) + out["behind"] = n + } + } + } + bin := llamaServerBin(repo) + out["bin"] = bin + cfgBin := ReadConfig()["BIN"] + out["config_bin"] = cfgBin + out["in_use"] = bin != "" && samePath(bin, cfgBin) + + plan := detectBuildPlan() + out["plan"] = map[string]any{ + "backend": plan.backend, + "arch": plan.cudaArch, + "jobs": plan.jobs, + } + out["job"] = lcJobSnapshot(0, false) + sendJSON(w, 200, out) +} + +// samePath compare deux chemins en neutralisant séparateurs, symlinks et casse +// (Windows). +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + norm := func(p string) string { + if real, err := filepath.EvalSymlinks(p); err == nil { + p = real + } + p = filepath.Clean(p) + return strings.ToLower(filepath.ToSlash(p)) + } + return norm(a) == norm(b) +} + +// handleLlamacppCheck fait un vrai git fetch puis renvoie le retard sur origin +// et le dernier commit distant. Synchrone (quelques secondes réseau). +func handleLlamacppCheck(w http.ResponseWriter, r *http.Request) { + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + sendJSON(w, 200, map[string]any{"ok": false, "error": "llama.cpp n'est pas installé"}) + return + } + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": "git fetch a échoué : " + err.Error()}) + return + } + behind := 0 + if b := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); b != "" { + behind, _ = strconv.Atoi(b) + } + sendJSON(w, 200, map[string]any{ + "ok": true, + "behind": behind, + "local": gitOutput(repo, "rev-parse", "--short", "HEAD"), + "remote": gitOutput(repo, "rev-parse", "--short", "origin/"+branch), + "remote_date": gitOutput(repo, "log", "-1", "--format=%ci", "origin/"+branch), + "remote_msg": gitOutput(repo, "log", "-1", "--format=%s", "origin/"+branch), + "branch": branch, + "has_binary": llamaServerBin(repo) != "", + "needs_rebuild": behind > 0 || llamaServerBin(repo) == "", + }) +} + +// handleLlamacppInstall lance le job d'installation (clone + build + BIN). +func handleLlamacppInstall(w http.ResponseWriter, r *http.Request) { + var req struct { + Force bool `json:"force"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := startLcJob("install", func() { lcRunInstall(req.Force) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleLlamacppUpdate lance le job de mise à jour (pull + rebuild + restart). +// {clean:true} force une recompilation from scratch même sans nouveau commit. +func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) { + var req struct { + Clean bool `json:"clean"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := startLcJob("update", func() { lcRunUpdate(req.Clean) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis +// l'offset absolu ?from=N (le client mémorise `next` et enchaîne). +func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { + from, _ := strconv.Atoi(r.URL.Query().Get("from")) + sendJSON(w, 200, lcJobSnapshot(from, true)) +} + +// lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie +// juste l'entête (imbriquée dans /api/llamacpp). +func lcJobSnapshot(from int, withLines bool) map[string]any { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur == nil { + return map[string]any{"exists": false} + } + j := lcCur + out := map[string]any{ + "exists": true, + "action": j.Action, + "running": j.Running, + "phase": j.Phase, + "compiled": j.Compiled, + "error": j.Err, + "started_at": j.StartedAt, + "ended_at": j.EndedAt, + "old": j.OldCommit, + "new": j.NewCommit, + } + if withLines { + start := from - j.base + if start < 0 { + start = 0 + } + if start > len(j.lines) { + start = len(j.lines) + } + out["lines"] = append([]string(nil), j.lines[start:]...) + out["next"] = j.base + len(j.lines) + } + return out +} + +// --------------------------------------------------------------------------- +// Corps des jobs (miroir web de llamacppInstall / llamacppUpdate, sans stdout +// interactif : tout passe par lcPhase / le sink de build) +// --------------------------------------------------------------------------- + +func lcRunInstall(force bool) { + repo := defaultRepoDir() + + lcPhase("vérification des outils (git, cmake, compilateur)…") + if err := requireTools("git", "cmake"); err != nil { + lcFail(err) + return + } + if err := ensureCompiler(); err != nil { + lcFail(err) + return + } + ensureAccelerator() + + if isDir(filepath.Join(repo, ".git")) { + if !force { + // Dépôt déjà là : on bascule sur une mise à jour (même intention). + lcPhase("dépôt déjà présent — bascule en mise à jour") + lcRunUpdate(false) + return + } + lcPhase("suppression du dépôt existant (--force)…") + if err := os.RemoveAll(repo); err != nil { + lcFail(err) + return + } + } + if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil { + lcFail(err) + return + } + + lcPhase("clone de llama.cpp…") + if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil { + lcFail(fmt.Errorf("git clone a échoué : %w", err)) + return + } + + if !lcBuildAndSwitch(repo, true) { + return + } + lcDone("installation terminée") +} + +func lcRunUpdate(clean bool) { + lcPhase("vérification des outils (git, cmake, compilateur)…") + if err := requireTools("git", "cmake"); err != nil { + lcFail(err) + return + } + if err := ensureCompiler(); err != nil { + lcFail(err) + return + } + ensureAccelerator() + + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + lcFail(fmt.Errorf("aucun dépôt llama.cpp (%s) — lance d'abord l'installation", repo)) + return + } + + oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + lcMu.Lock() + lcCur.OldCommit = oldCommit + lcMu.Unlock() + + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + + lcPhase("git fetch origin…") + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + lcFail(fmt.Errorf("git fetch a échoué : %w", err)) + return + } + localRev := gitOutput(repo, "rev-parse", "HEAD") + remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch) + if localRev != "" && localRev == remoteRev && !clean && llamaServerBin(repo) != "" { + lcDone("déjà à jour (" + oldCommit + ") — rien à faire") + return + } + if localRev != remoteRev { + lcPhase("git pull origin/" + branch + "…") + if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil { + lcFail(fmt.Errorf("git pull a échoué (modifs locales ?) : %w", err)) + return + } + } + newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + lcMu.Lock() + lcCur.NewCommit = newCommit + lcMu.Unlock() + + // Le binaire en cours d'exécution ne peut pas être réécrit → stop du service + // pendant le build, redémarrage après (même en échec). + svcWasUp := serviceIsActive() + if svcWasUp { + lcPhase("arrêt du service le temps du build…") + if err := serviceAction("stop"); err != nil { + lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) + } + } + + ok := lcBuildAndSwitch(repo, clean) + if svcWasUp { + lcPhase("redémarrage du service…") + if err := serviceAction("start"); err != nil { + lcAppend("[warn] redémarrage du service échoué : " + err.Error()) + } + } + if !ok { + return + } + if oldCommit == newCommit { + lcDone("recompilé (" + newCommit + ")") + } else { + lcDone("mis à jour : " + oldCommit + " → " + newCommit) + } +} + +// lcBuildAndSwitch détecte le plan, compile llama-server et pointe BIN dessus. +// Renvoie false (job en échec) si une étape casse. +func lcBuildAndSwitch(repo string, clean bool) bool { + plan := detectBuildPlan() + lcAppend(fmt.Sprintf("plan de build : backend=%s arch=%s jobs=%d", plan.backend, plan.cudaArch, plan.jobs)) + lcPhase("configuration CMake…") + if err := buildLlamacpp(repo, plan, clean); err != nil { + lcAppendLogTail(filepath.Join(repo, "configure.log"), filepath.Join(repo, "build.log")) + lcFail(err) + return false + } + bin := llamaServerBin(repo) + if bin == "" { + lcFail(fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))) + return false + } + lcAppend("binaire compilé : " + bin) + if err := SetConfigKey("BIN", bin); err != nil { + lcFail(fmt.Errorf("build ok mais échec écriture BIN dans config.env : %w", err)) + return false + } + lcAppend("BIN mis à jour dans " + confPath()) + return true +} + +// lcAppendLogTail remonte la fin des logs de build dans le job pour que +// l'erreur réelle soit visible dans l'UI sans aller chercher les fichiers. +func lcAppendLogTail(paths ...string) { + for _, p := range paths { + b, err := os.ReadFile(p) + if err != nil { + continue + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) > 30 { + lines = lines[len(lines)-30:] + } + lcAppend("--- fin de " + filepath.Base(p) + " ---") + for _, l := range lines { + lcAppend(l) + } + } +} diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 121e059..8209c35 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -88,6 +88,11 @@ func newWebMux() *http.ServeMux { api("/api/models/download", handleModelDownload) api("/api/models/download/status", handleModelDownloadStatus) api("/api/backends", handleBackends) + api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp + api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin + api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN + api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart + api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job api("/api/presets", handlePresets) api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave)