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'); }
+}