mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Refonte de la section pour la rendre utilisable sans connaissances techniques : - suppression du badge a cote du titre et de tous les details (commit, retard git, arch CUDA, nombre de jobs, chemins) ; une seule ligne 'Moteur actif : …' - deux cartes claires : Version rapide (precompile, conseillee) / Version optimisee (compilee) ; un clic installe, active ou bascule - bascule instantanee entre deux versions deja installees (nouvel endpoint /api/llamacpp/use : repointe BIN + redemarre, sans recompiler) - carte active : '✓ Actif' + 'mettre a jour' ; progression et logs replies sous 'details'
561 lines
17 KiB
Go
561 lines
17 KiB
Go
// 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,
|
|
}
|
|
// Mode « binaires précompilés » : installé ? utilisé par la config ?
|
|
pbTag, _ := prebuiltVersion()
|
|
pbBin := prebuiltServerBin()
|
|
out["prebuilt"] = map[string]any{
|
|
"tag": pbTag,
|
|
"bin": pbBin,
|
|
"in_use": pbBin != "" && samePath(pbBin, cfgBin),
|
|
}
|
|
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})
|
|
}
|
|
|
|
// handleLlamacppPrebuiltCheck interroge la dernière release officielle de
|
|
// llama.cpp et la compare à la version précompilée installée. Synchrone.
|
|
func handleLlamacppPrebuiltCheck(w http.ResponseWriter, r *http.Request) {
|
|
tag, assets, err := fetchLlamaLatest()
|
|
if err != nil {
|
|
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
main, _, label, _, err := pickPrebuilt(assets)
|
|
if err != nil {
|
|
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
cur, _ := prebuiltVersion()
|
|
sendJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"latest": tag,
|
|
"current": cur,
|
|
"variant": label,
|
|
"size_mb": main.Size / 1_000_000,
|
|
"update": cur != tag || prebuiltServerBin() == "",
|
|
})
|
|
}
|
|
|
|
// handleLlamacppPrebuilt lance le job de téléchargement / mise à jour des
|
|
// binaires précompilés officiels (pas de compilation).
|
|
func handleLlamacppPrebuilt(w http.ResponseWriter, r *http.Request) {
|
|
if err := startLcJob("prebuilt", lcRunPrebuilt); err != nil {
|
|
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// lcRunPrebuilt : télécharge les binaires officiels, pointe BIN dessus, en
|
|
// stoppant le service pendant le remplacement (binaire verrouillé en cours
|
|
// d'exécution) puis en le relançant.
|
|
func lcRunPrebuilt() {
|
|
svcWasUp := serviceIsActive()
|
|
if svcWasUp {
|
|
lcPhase("arrêt du service le temps de l'installation…")
|
|
if err := serviceAction("stop"); err != nil {
|
|
lcAppend("[warn] impossible d'arrêter le service : " + err.Error())
|
|
}
|
|
}
|
|
bin, err := prebuiltInstall(lcAppend, lcPhase)
|
|
if err == nil {
|
|
if serr := SetConfigKey("BIN", bin); serr != nil {
|
|
err = fmt.Errorf("binaires installés mais échec écriture BIN dans config.env : %w", serr)
|
|
} else {
|
|
lcAppend("BIN mis à jour dans " + confPath())
|
|
}
|
|
}
|
|
if svcWasUp {
|
|
lcPhase("redémarrage du service…")
|
|
if serr := serviceAction("start"); serr != nil {
|
|
lcAppend("[warn] redémarrage du service échoué : " + serr.Error())
|
|
}
|
|
}
|
|
if err != nil {
|
|
lcFail(err)
|
|
return
|
|
}
|
|
tag, _ := prebuiltVersion()
|
|
lcDone("binaires précompilés installés (" + tag + ")")
|
|
}
|
|
|
|
// handleLlamacppUse bascule BIN entre deux versions DÉJÀ installées, sans
|
|
// rien recompiler : "fast" = binaires précompilés, "opt" = build local. Le
|
|
// service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que
|
|
// quand la version cible existe déjà (sinon elle lance un job d'installation).
|
|
func handleLlamacppUse(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
var bin string
|
|
switch req.Mode {
|
|
case "fast":
|
|
bin = prebuiltServerBin()
|
|
case "opt":
|
|
bin = llamaServerBin(llamacppRepoDir())
|
|
default:
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu"})
|
|
return
|
|
}
|
|
if bin == "" {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette version n'est pas installée"})
|
|
return
|
|
}
|
|
if err := SetConfigKey("BIN", bin); err != nil {
|
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if serviceIsActive() {
|
|
_ = serviceAction("restart")
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|