mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Reprise d'AJEAN : le serveur pousse une notification vers les navigateurs abonnés, directement via leur service de push — donc app fermée et téléphone verrouillé, là où une notification côté page ne peut rien (un onglet caché relâche son flux SSE). Deux déclencheurs : la fin d'un tour utilisateur (sauf interruption par le bouton stop : celui qui a coupé est devant l'écran) et — ajout propre à Loki — la fin d'une TÂCHE PLANIFIÉE, succès comme échec. C'est le cas qui compte le plus : une tâche tourne justement quand personne ne regarde. Clés VAPID générées à la première demande et rangées dans la base ; abonnements persistés et purgés quand le service de push répond 404/410. Corps de notification générique, sans extrait de réponse : elle transite par Apple ou Google. /sw.js et /manifest.webmanifest sont servis à la racine (un service worker doit venir de l'origine) ; le worker ne fait QUE recevoir les push, sans cache — mettre l'UI en cache servirait une interface périmée après une mise à jour de l'image. Interrupteur dans Réglages → Mode agent, à armer sur chaque appareil. L'UI dit ce qui manque plutôt que d'échouer : HTTPS requis, notifications bloquées, ou iPhone à ajouter d'abord à l'écran d'accueil. Nouvelle dépendance : github.com/SherClockHolmes/webpush-go (RFC 8291). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
300 lines
11 KiB
Go
300 lines
11 KiB
Go
package loki
|
|
|
|
// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte.
|
|
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki
|
|
// (dossier de travail par discussion, modes de la machine).
|
|
//
|
|
// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer
|
|
// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec
|
|
// la conversation : son verrou de génération (conv.Generating), qui garantit
|
|
// qu'une seule inférence tourne à la fois — llama-server est lancé en
|
|
// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais
|
|
// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne
|
|
// gardant que le texte final comme compte-rendu.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la
|
|
// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu).
|
|
// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou
|
|
// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas
|
|
// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet).
|
|
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
|
|
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
|
|
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
|
|
if !healthCheck() {
|
|
return "", errModelLoading
|
|
}
|
|
// Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop
|
|
// du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de
|
|
// fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
c.mu.Lock()
|
|
if c.Generating {
|
|
c.mu.Unlock()
|
|
return "", ErrBusy
|
|
}
|
|
c.Generating = true
|
|
c.cancel = cancel
|
|
c.runningTaskID = taskID
|
|
c.runningTaskName = taskName
|
|
c.mu.Unlock()
|
|
// Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI
|
|
// c.epoch : la discussion ouverte reste totalement à l'écart.
|
|
defer func() {
|
|
c.mu.Lock()
|
|
c.Generating = false
|
|
c.cancel = nil
|
|
c.runningTaskID = ""
|
|
c.runningTaskName = ""
|
|
c.mu.Unlock()
|
|
}()
|
|
|
|
// Dossier de travail de la tâche : le sien, pas celui de la discussion
|
|
// ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui
|
|
// tient un fichier de suivi le retrouve la fois suivante.
|
|
setTaskWorkspace(taskWorkspaceDir(taskID))
|
|
defer setTaskWorkspace("")
|
|
|
|
if temperature == 0 {
|
|
temperature = 0.7
|
|
}
|
|
msgs := []Message{{Role: "user", Content: prompt}}
|
|
// Même préambule que la vraie génération : consigne personnelle + préambule
|
|
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
|
|
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
|
|
// contexte (conscience du mode autonome + mémoire du passage précédent),
|
|
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
|
|
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
|
|
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
|
|
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
|
|
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
|
|
final := append(projectSystemMessages(), msgs...)
|
|
sys := readSysPrompt()
|
|
note := taskContextNote(taskName, lastReport)
|
|
switch {
|
|
case sys != "" && note != "":
|
|
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...)
|
|
case sys != "":
|
|
final = append([]Message{{Role: "system", Content: sys}}, final...)
|
|
case note != "":
|
|
final = append([]Message{{Role: "system", Content: note}}, final...)
|
|
}
|
|
|
|
var content strings.Builder
|
|
_, err := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
|
if ev.Content != "" {
|
|
content.WriteString(ev.Content)
|
|
}
|
|
return true
|
|
})
|
|
// Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul
|
|
// son compte-rendu survit.
|
|
return strings.TrimSpace(content.String()), err
|
|
}
|
|
|
|
// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la
|
|
// racine du dossier de travail — jamais dans le dossier d'une discussion, dont
|
|
// il partagerait le sort à la suppression.
|
|
func taskWorkspaceDir(taskID string) string {
|
|
root := agentWorkspace()
|
|
if taskID == "" {
|
|
return root
|
|
}
|
|
dir := filepath.Join(root, "tasks", taskID)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return root
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// taskContextNote construit la note de contexte injectée en tête du fil d'une
|
|
// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en
|
|
// face), puis — s'il existe — le compte-rendu du passage précédent, pour la
|
|
// continuité.
|
|
func taskContextNote(taskName, lastReport string) string {
|
|
var b strings.Builder
|
|
b.WriteString("[Tâche planifiée")
|
|
if taskName != "" {
|
|
b.WriteString(" « " + taskName + " »")
|
|
}
|
|
b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " +
|
|
"présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " +
|
|
"termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.")
|
|
if r := strings.TrimSpace(lastReport); r != "" {
|
|
if len(r) > reportMax {
|
|
r = r[:reportMax] + "…"
|
|
}
|
|
b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " +
|
|
"appuie-toi dessus, ne le répète pas tel quel) :\n" + r)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// reportMax borne la taille du compte-rendu conservé : le texte final peut être
|
|
// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante.
|
|
const reportMax = 4000
|
|
|
|
// runTask exécute une tâche et met à jour son état persisté (dernier passage,
|
|
// prochain passage, succès/échec, compte-rendu).
|
|
func runTask(t Task) {
|
|
start := time.Now()
|
|
|
|
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
|
|
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
|
|
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
|
|
if t.Kind == "script" {
|
|
runScriptTask(t)
|
|
return
|
|
}
|
|
|
|
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
|
|
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
|
|
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
|
|
if err := ensureTaskPreset(t.Preset); err != nil {
|
|
recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err))
|
|
return
|
|
}
|
|
|
|
// Projet visé : on force le projet vu par memoryDir() et par les trackers, le
|
|
// temps du passage. Sans ça, une veille rattachée au projet « NAS » écrirait
|
|
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
|
|
// inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà
|
|
// rendu la main quand on libère.
|
|
setProjectOverride(taskProjectOf(t))
|
|
defer setProjectOverride("")
|
|
|
|
report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0)
|
|
|
|
// Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste
|
|
// un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé)
|
|
// pour qu'elle soit réessayée au tick suivant.
|
|
if err == ErrBusy || err == errModelLoading {
|
|
return
|
|
}
|
|
recordTaskEnd(t.ID, start, report, err)
|
|
}
|
|
|
|
// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine
|
|
// (qui débloque les outils), puis on applique les réglages propres à la tâche pour
|
|
// la mémoire et le web. Le web reste borné à ce que la machine offre réellement
|
|
// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer.
|
|
//
|
|
// Le mode code reste OFF : c'est un mode de discussion (rôles, critères,
|
|
// vérification, puces dans le fil) qui n'a pas de sens sans personne en face.
|
|
func taskCaps(t Task) Caps {
|
|
// Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide,
|
|
// que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) —
|
|
// une tâche sans agent se serait donc vu offrir les outils mem_*.
|
|
c := Caps{Agent: agentEnabled(), Mem: MemOff}
|
|
if !c.Agent {
|
|
return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens
|
|
}
|
|
if t.NoWeb {
|
|
c.Internet = false
|
|
} else {
|
|
c.Internet = internetEnabled() && crawlReachable()
|
|
}
|
|
if t.NoMem {
|
|
c.Mem = MemOff
|
|
} else if m := memMode(); m == MemOff {
|
|
// La tâche veut la mémoire mais la machine l'a coupée globalement : on donne
|
|
// au moins les outils à la demande, sans l'injection proactive.
|
|
c.Mem = MemOnDemand
|
|
} else {
|
|
c.Mem = m
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà
|
|
// actif, puis attend que le modèle ait rechargé. Vide = rien à faire.
|
|
func ensureTaskPreset(id string) error {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
list, err := ListPresets()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var target *Preset
|
|
for i := range list {
|
|
if list[i].ID == id {
|
|
target = &list[i]
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
return fmt.Errorf("preset introuvable : %s", id)
|
|
}
|
|
if target.Active {
|
|
return nil
|
|
}
|
|
if err := SwitchToPreset(target.Path); err != nil {
|
|
return err
|
|
}
|
|
// Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros
|
|
// modèle prend un moment).
|
|
deadline := time.Now().Add(4 * time.Minute)
|
|
for time.Now().Before(deadline) {
|
|
if healthCheck() {
|
|
return nil
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset")
|
|
}
|
|
|
|
// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution :
|
|
// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la
|
|
// tâche juste avant d'écrire pour ne pas écraser une modification concurrente
|
|
// (bascule ou édition depuis l'interface pendant l'exécution).
|
|
func recordTaskEnd(id string, start time.Time, report string, err error) {
|
|
now := time.Now()
|
|
cur, ok := getTask(id)
|
|
if !ok {
|
|
return // supprimée entre-temps : rien à écrire
|
|
}
|
|
cur.LastDurMs = now.Sub(start).Milliseconds()
|
|
cur.LastRun = now.UnixMilli()
|
|
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
|
|
if errors.Is(err, context.Canceled) {
|
|
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
|
|
// « interrompue » sans allumer l'indicateur rouge d'erreur.
|
|
cur.LastOK = true
|
|
cur.LastError = ""
|
|
cur.LastReport = "(interrompue manuellement)"
|
|
} else if err != nil {
|
|
cur.LastOK = false
|
|
cur.LastError = err.Error()
|
|
} else {
|
|
cur.LastOK = true
|
|
cur.LastError = ""
|
|
if len(report) > reportMax {
|
|
report = report[:reportMax] + "…"
|
|
}
|
|
cur.LastReport = report
|
|
}
|
|
_ = saveTask(cur)
|
|
// Notification Web Push : une tâche planifiée tourne SANS personne devant
|
|
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
|
|
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
|
|
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
|
|
if hasPushSubs() && !errors.Is(err, context.Canceled) {
|
|
title, body := "Loki · tâche terminée", cur.Name
|
|
if err != nil {
|
|
title = "Loki · tâche en échec"
|
|
}
|
|
go sendPushToAll(title, body)
|
|
}
|
|
}
|