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

This commit is contained in:
nathaninline committed 2026-07-22 13:34:56 +02:00
1 parent 29f249be09
commit 7ac571974f
8 files changed
+841 -4

No files matched your search

+60 -2
View File
@@ -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++
+162 -1
View File
@@ -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}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<div id="backdrop" onclick="toggleSide()"></div>
@@ -388,6 +394,21 @@ button:disabled{opacity:.5;cursor:not-allowed}
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</details>
<details id="lc-details"><summary>Backend llama.cpp <span id="lc-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
<div id="lc-status" class="muted" style="font-size:12px;line-height:1.6">…</div>
<div class="row" id="lc-actions" style="margin-top:8px;flex-wrap:wrap">
<button id="lc-install" onclick="lcInstall()" style="display:none;border-color:var(--accent);color:var(--accent)">⬇ installer</button>
<button id="lc-check" onclick="lcCheck()">↥ vérifier les maj</button>
<button id="lc-update" onclick="lcUpdate(false)" style="display:none;border-color:var(--accent);color:var(--accent)">⬆ mettre à jour</button>
<button id="lc-rebuild" onclick="lcUpdate(true)" style="display:none">🔧 recompiler</button>
</div>
<div id="lc-check-msg" class="muted" style="font-size:11px;margin-top:6px;line-height:1.5"></div>
<div id="lc-job" style="display:none;margin-top:8px">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> logs du build</div>
<pre id="lc-log" hidden></pre>
</div>
</details>
<details><summary>Service</summary>
<div class="row">
<button onclick="act('start')">start</button>
@@ -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('<span style="color:var(--dim)">llama.cpp n\'est pas encore installé sur cette machine.</span>');
parts.push('<span style="color:var(--dim)">L\'installation clone le dépôt, détecte le GPU et compile <code>llama-server</code> automatiquement.</span>');
lcBadge('<span class="tag" style="opacity:.6">absent</span>');
} else {
const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
parts.push('commit <b style="color:var(--text)">'+esc(s.commit)+'</b>'+(s.branch?' <span style="opacity:.7">('+esc(s.branch)+')</span>':''));
if(s.commit_date) parts.push('<span style="opacity:.8">'+esc(String(s.commit_date).slice(0,16))+'</span> — '+esc(s.commit_msg));
if(s.bin){
parts.push('binaire : <span style="color:var(--ok)">✓ compilé</span>'+(s.in_use?' · <span style="color:var(--accent)">utilisé par la config</span>':' · <span style="opacity:.7">non utilisé par la config (BIN pointe ailleurs)</span>'));
} else {
parts.push('binaire : <span style="color:var(--err)">absent (pas encore compilé)</span>');
}
if(s.behind > 0) lcBadge('<span class="tag" style="color:var(--accent);border-color:var(--accent)">maj dispo</span>');
else if(s.bin) lcBadge('<span class="tag ok">ok</span>');
else lcBadge('<span class="tag err">à compiler</span>');
}
if(s.plan){
const p = s.plan;
parts.push('accélérateur détecté : <b style="color:var(--text)">'+p.backend.toUpperCase()+'</b>'+(p.arch?' <span style="opacity:.7">(arch '+p.arch+')</span>':'')+' · '+p.jobs+' jobs');
}
st.innerHTML = parts.join('<br>');
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 = '<span style="color:var(--err)">'+(r.error||'erreur')+'</span>'; return; }
if(r.behind > 0){
msg.innerHTML = '<b style="color:var(--accent)">'+r.behind+' commit(s) de retard</b> sur origin/'+r.branch+'<br>'+
'dernier distant : <b>'+r.remote+'</b> — '+String(r.remote_msg||'').replace(/[<>&]/g,'');
document.getElementById('lc-update').style.display = '';
lcBadge('<span class="tag" style="color:var(--accent);border-color:var(--accent)">maj dispo</span>');
} else if(!r.has_binary){
msg.innerHTML = 'sources à jour mais <b>binaire absent</b> — lance une recompilation.';
} else {
msg.innerHTML = '<span style="color:var(--ok)">✓ à jour</span> ('+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('<span class="tag" style="color:var(--accent);border-color:var(--accent)"><span class="lc-spin">⏳</span> build</span>');
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 = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
return;
}
// Terminé.
clearInterval(lcPoll); lcPoll = null;
lcSetButtons(false);
if(j.error){
phaseEl.innerHTML = '<span style="color:var(--err)">✗ '+String(j.error).replace(/[<>&]/g,'')+'</span>';
// 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 = '<span style="color:var(--ok)">✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+'</span>';
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'); }
}
</script></body></html>
+15
View File
@@ -116,6 +116,21 @@
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</details>
<details id="lc-details"><summary>Backend llama.cpp <span id="lc-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
<div id="lc-status" class="muted" style="font-size:12px;line-height:1.6">…</div>
<div class="row" id="lc-actions" style="margin-top:8px;flex-wrap:wrap">
<button id="lc-install" onclick="lcInstall()" style="display:none;border-color:var(--accent);color:var(--accent)">⬇ installer</button>
<button id="lc-check" onclick="lcCheck()">↥ vérifier les maj</button>
<button id="lc-update" onclick="lcUpdate(false)" style="display:none;border-color:var(--accent);color:var(--accent)">⬆ mettre à jour</button>
<button id="lc-rebuild" onclick="lcUpdate(true)" style="display:none">🔧 recompiler</button>
</div>
<div id="lc-check-msg" class="muted" style="font-size:11px;margin-top:6px;line-height:1.5"></div>
<div id="lc-job" style="display:none;margin-top:8px">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> logs du build</div>
<pre id="lc-log" hidden></pre>
</div>
</details>
<details><summary>Service</summary>
<div class="row">
<button onclick="act('start')">start</button>
+1 -1
View File
@@ -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); }
+140
View File
@@ -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('<span style="color:var(--dim)">llama.cpp n\'est pas encore installé sur cette machine.</span>');
parts.push('<span style="color:var(--dim)">L\'installation clone le dépôt, détecte le GPU et compile <code>llama-server</code> automatiquement.</span>');
lcBadge('<span class="tag" style="opacity:.6">absent</span>');
} else {
const esc = t=>String(t||'').replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
parts.push('commit <b style="color:var(--text)">'+esc(s.commit)+'</b>'+(s.branch?' <span style="opacity:.7">('+esc(s.branch)+')</span>':''));
if(s.commit_date) parts.push('<span style="opacity:.8">'+esc(String(s.commit_date).slice(0,16))+'</span> — '+esc(s.commit_msg));
if(s.bin){
parts.push('binaire : <span style="color:var(--ok)">✓ compilé</span>'+(s.in_use?' · <span style="color:var(--accent)">utilisé par la config</span>':' · <span style="opacity:.7">non utilisé par la config (BIN pointe ailleurs)</span>'));
} else {
parts.push('binaire : <span style="color:var(--err)">absent (pas encore compilé)</span>');
}
if(s.behind > 0) lcBadge('<span class="tag" style="color:var(--accent);border-color:var(--accent)">maj dispo</span>');
else if(s.bin) lcBadge('<span class="tag ok">ok</span>');
else lcBadge('<span class="tag err">à compiler</span>');
}
if(s.plan){
const p = s.plan;
parts.push('accélérateur détecté : <b style="color:var(--text)">'+p.backend.toUpperCase()+'</b>'+(p.arch?' <span style="opacity:.7">(arch '+p.arch+')</span>':'')+' · '+p.jobs+' jobs');
}
st.innerHTML = parts.join('<br>');
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 = '<span style="color:var(--err)">'+(r.error||'erreur')+'</span>'; return; }
if(r.behind > 0){
msg.innerHTML = '<b style="color:var(--accent)">'+r.behind+' commit(s) de retard</b> sur origin/'+r.branch+'<br>'+
'dernier distant : <b>'+r.remote+'</b> — '+String(r.remote_msg||'').replace(/[<>&]/g,'');
document.getElementById('lc-update').style.display = '';
lcBadge('<span class="tag" style="color:var(--accent);border-color:var(--accent)">maj dispo</span>');
} else if(!r.has_binary){
msg.innerHTML = 'sources à jour mais <b>binaire absent</b> — lance une recompilation.';
} else {
msg.innerHTML = '<span style="color:var(--ok)">✓ à jour</span> ('+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('<span class="tag" style="color:var(--accent);border-color:var(--accent)"><span class="lc-spin">⏳</span> build</span>');
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 = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
return;
}
// Terminé.
clearInterval(lcPoll); lcPoll = null;
lcSetButtons(false);
if(j.error){
phaseEl.innerHTML = '<span style="color:var(--err)">✗ '+String(j.error).replace(/[<>&]/g,'')+'</span>';
// 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 = '<span style="color:var(--ok)">✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+'</span>';
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'); }
}
+6
View File
@@ -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}
+452
View File
@@ -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)
}
}
}
+5
View File
@@ -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)