Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)

Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
This commit is contained in:
Claude committed 2026-08-20 09:31:59 +00:00
1 parent 03ae361ade
commit 0f58ad7b49
25 files changed
+2441 -15

No files matched your search

+11
View File
@@ -169,6 +169,17 @@ Héritées d'AJEAN :
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes `npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
lancements suivants partent du cache en quelques secondes. lancements suivants partent du cache en quelques secondes.
- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une
fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une
expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit
pas dans le fil, travaille dans son propre dossier (`workspace/tasks/<id>/`) et
livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le
compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au
passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt.
- **Presets** de configuration par modèle, bench, auto-détection GPU. - **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`, - **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces `min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
+28 -1
View File
@@ -56,6 +56,12 @@ type Conversation struct {
Generating bool `json:"-"` Generating bool `json:"-"`
cancel context.CancelFunc // annule la génération en cours (/stop) cancel context.CancelFunc // annule la génération en cours (/stop)
epoch int // incrémenté à chaque reset → invalide les abonnés epoch int // incrémenté à chaque reset → invalide les abonnés
// Tâche planifiée en cours (tasks_run.go). Le verrou de génération est le
// SEUL point partagé entre une tâche de fond et le chat : ces deux champs
// disent à l'interface POURQUOI elle est occupée, au lieu de laisser croire
// à une génération fantôme dans la discussion ouverte.
runningTaskID string
runningTaskName string
} }
var conv = func() *Conversation { var conv = func() *Conversation {
@@ -286,7 +292,14 @@ func (c *Conversation) state() map[string]any {
turns++ turns++
} }
} }
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns} st := map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
// Occupé PAR UNE TÂCHE : l'interface l'annonce (« tâche en cours ») plutôt que
// d'afficher un tour qui n'apparaîtra jamais dans le fil.
if c.runningTaskID != "" {
st["task_id"] = c.runningTaskID
st["task_name"] = c.runningTaskName
}
return st
} }
// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions : // isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
@@ -301,6 +314,20 @@ func (c *Conversation) isGenerating() bool {
// ErrBusy : une génération est déjà en cours (un seul tour à la fois). // ErrBusy : une génération est déjà en cours (un seul tour à la fois).
var ErrBusy = fmt.Errorf("génération en cours") var ErrBusy = fmt.Errorf("génération en cours")
// busyReason explique le refus à l'utilisateur. Sans ça, un message envoyé
// pendant qu'une TÂCHE planifiée tourne renvoyait « génération en cours » alors
// que le fil est vide et que rien ne bouge à l'écran — incompréhensible. On
// nomme la tâche et on dit comment reprendre la main.
func (c *Conversation) busyReason() error {
c.mu.Lock()
name := c.runningTaskName
c.mu.Unlock()
if name == "" {
return ErrBusy
}
return fmt.Errorf("la tâche planifiée « %s » occupe le modèle — attends la fin ou arrête-la (bouton stop)", name)
}
// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce // errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce
// n'est pas un défaut mais une attente, et le message doit le dire. // n'est pas un défaut mais une attente, et le message doit le dire.
var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.") var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.")
+2 -2
View File
@@ -161,7 +161,7 @@ func machineSystemPrompt(caps Caps) string {
if u, err := user.Current(); err == nil { if u, err := user.Current(); err == nil {
who = u.Username who = u.Username
} }
cwd := convWorkspace() cwd := agentCwd()
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH)) b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
@@ -211,7 +211,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On // such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
// tout refuser. // tout refuser.
if ws := convWorkspace(); ws != "" { if ws := agentCwd(); ws != "" {
if err := os.MkdirAll(ws, 0o755); err == nil { if err := os.MkdirAll(ws, 0o755); err == nil {
cmd.Dir = ws cmd.Dir = ws
} }
+28 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic"
) )
// Dossier de travail du mode agent. // Dossier de travail du mode agent.
@@ -21,7 +22,8 @@ import (
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif // agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour // est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec // qu'un fichier appartienne à la discussion où il est né et disparaisse avec
// elle. La racine reste la borne de sécurité des téléchargements. // elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine
// reste la borne de sécurité des téléchargements.
const workspaceEnv = "LOKI_WORKSPACE" const workspaceEnv = "LOKI_WORKSPACE"
@@ -73,6 +75,30 @@ func workspaceCandidates() []string {
return c return c
} }
// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée
// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses
// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte
// — et changeraient de place en cours de route si l'utilisateur en changeait.
//
// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas
// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois
// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent
// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des
// messages — continue de passer par convWorkspace() et reste donc sur sa
// discussion, même pendant qu'une tâche écrit ailleurs.
var taskWS atomic.Value // string
func setTaskWorkspace(dir string) { taskWS.Store(dir) }
// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en
// cours s'il y en a une, sinon celui de la discussion ouverte.
func agentCwd() string {
if d, _ := taskWS.Load().(string); d != "" {
return d
}
return convWorkspace()
}
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ; // resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la // "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
// discussion ouverte. // discussion ouverte.
@@ -89,5 +115,5 @@ func resolveAgentPath(p string) string {
if filepath.IsAbs(p) { if filepath.IsAbs(p) {
return p return p
} }
return filepath.Join(convWorkspace(), filepath.FromSlash(p)) return filepath.Join(agentCwd(), filepath.FromSlash(p))
} }
+2 -2
View File
@@ -74,7 +74,7 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
cctx, cancel := context.WithTimeout(ctx, timeout) cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(cctx, "git", args...) cmd := exec.CommandContext(cctx, "git", args...)
cmd.Dir = convWorkspace() cmd.Dir = agentCwd()
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process // Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
// serveur n'a personne pour y répondre, il faut échouer vite et le dire. // serveur n'a personne pour y répondre, il faut échouer vite et le dire.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes") cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
@@ -137,7 +137,7 @@ func toolGitClone(ctx context.Context, args map[string]any) string {
if strings.Contains(dir, "..") || filepath.IsAbs(dir) { if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
return "[erreur] dossier cible invalide" return "[erreur] dossier cible invalide"
} }
target := filepath.Join(convWorkspace(), dir) target := filepath.Join(agentCwd(), dir)
if _, err := os.Stat(target); err == nil { if _, err := os.Stat(target); err == nil {
return "[erreur] le dossier " + dir + " existe déjà" return "[erreur] le dossier " + dir + " existe déjà"
} }
+1 -1
View File
@@ -83,7 +83,7 @@ func toolBashBg(args map[string]any) string {
if reason := dangerousCommand(command); reason != "" { if reason := dangerousCommand(command); reason != "" {
return refusedCommandResult(reason) return refusedCommandResult(reason)
} }
ws := convWorkspace() ws := agentCwd()
logDir := filepath.Join(ws, ".loki", "jobs") logDir := filepath.Join(ws, ".loki", "jobs")
if err := os.MkdirAll(logDir, 0o755); err != nil { if err := os.MkdirAll(logDir, 0o755); err != nil {
return "[erreur] " + err.Error() return "[erreur] " + err.Error()
+1 -1
View File
@@ -64,7 +64,7 @@ func looksLikeCode(text string) bool {
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion. // convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
func convHasRepo() bool { func convHasRepo() bool {
ws := convWorkspace() ws := agentCwd()
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil { if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
return true return true
} }
+1 -1
View File
@@ -74,7 +74,7 @@ func refusedCommandResult(reason string) string {
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode // le dossier de la discussion courante. Utilisé par les outils fichiers en mode
// code uniquement. // code uniquement.
func codePathAllowed(abs string) bool { func codePathAllowed(abs string) bool {
ws := convWorkspace() ws := agentCwd()
if ws == "" { if ws == "" {
return false return false
} }
+2 -2
View File
@@ -178,7 +178,7 @@ func toolGrep(args map[string]any, codeMode bool) string {
if err != nil { if err != nil {
return "[erreur] motif invalide : " + err.Error() return "[erreur] motif invalide : " + err.Error()
} }
root := convWorkspace() root := agentCwd()
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." { if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
root = resolveAgentPath(p) root = resolveAgentPath(p)
} }
@@ -246,7 +246,7 @@ func toolGlob(args map[string]any, codeMode bool) string {
if strings.TrimSpace(pattern) == "" { if strings.TrimSpace(pattern) == "" {
return "[erreur] motif vide" return "[erreur] motif vide"
} }
root := convWorkspace() root := agentCwd()
re, err := globToRegexp(pattern) re, err := globToRegexp(pattern)
if err != nil { if err != nil {
return "[erreur] motif invalide : " + err.Error() return "[erreur] motif invalide : " + err.Error()
+1 -1
View File
@@ -128,7 +128,7 @@ func lspDiagnosticsFor(absPath string) string {
if _, err := exec.LookPath(lang.Command[0]); err != nil { if _, err := exec.LookPath(lang.Command[0]); err != nil {
return "" // serveur non installé : le mode code marche sans return "" // serveur non installé : le mode code marche sans
} }
root := convWorkspace() root := agentCwd()
// Le dépôt cloné est la vraie racine projet quand le fichier est dedans : // Le dépôt cloné est la vraie racine projet quand le fichier est dedans :
// gopls a besoin du go.mod, tsserver du tsconfig. // gopls a besoin du go.mod, tsserver du tsconfig.
if r := projectRootFor(absPath, root); r != "" { if r := projectRootFor(absPath, root); r != "" {
+1
View File
@@ -33,6 +33,7 @@ const (
bkPrefs = "prefs" // préférences de l'UI web bkPrefs = "prefs" // préférences de l'UI web
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
bkChat = "chat" // conversation partagée bkChat = "chat" // conversation partagée
bkTasks = "tasks" // tâches planifiées (une entrée JSON par tâche)
) )
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré. // La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
+102
View File
@@ -0,0 +1,102 @@
package loki
// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule,
// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque
// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans
// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres
// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une
// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused).
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID).
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
Prompt string `json:"prompt"`
Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs
TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure
Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif)
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
// (tâches créées avant ces champs) garde le comportement historique : accès.
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche
Enabled bool `json:"enabled"`
LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais
NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer
LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms
LastOK bool `json:"last_ok"`
LastError string `json:"last_error"`
LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court)
Running bool `json:"-"` // transitoire : une exécution est en cours
}
// newTaskID génère un identifiant court et unique.
func newTaskID() string {
var b [6]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable.
func listTasks() []Task {
var out []Task
_ = view(bkTasks, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var t Task
if json.Unmarshal(v, &t) == nil {
out = append(out, t)
}
return nil
})
})
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].ID < out[j].ID
})
return out
}
// getTask lit une tâche par ID. Renvoie false si absente.
func getTask(id string) (Task, bool) {
var t Task
ok := getJSON(bkTasks, id, &t)
return t, ok
}
// saveTask enregistre (crée ou remplace) une tâche.
func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) }
// deleteTask supprime une tâche par ID.
func deleteTask(id string) error { return putBytes(bkTasks, id, nil) }
// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On
// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base
// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un
// moteur.
func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son
// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est
// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un
// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou).
func computeNextRun(schedule, tz string, from time.Time) int64 {
next, err := nextAfter(schedule, tz, from)
if err != nil {
return 0
}
return next.UnixMilli()
}
+251
View File
@@ -0,0 +1,251 @@
package loki
// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes :
//
// - Intervalle : "@every <durée>" (ex. "@every 2h", "@every 30m"), parsé par
// time.ParseDuration, borné à ≥ 1 minute.
// - Cron : 5 champs "min hour dom month dow" avec *, listes a,b, plages a-b et
// pas */n (ex. "0 9 * * 1-5" = 9h du lundi au vendredi).
//
// Aucune dépendance externe : Go n'a pas de lib cron et on n'en ajoute pas pour
// si peu. nextAfter avance minute par minute jusqu'au prochain match — largement
// assez rapide pour une poignée de tâches évaluées une fois par minute.
import (
"fmt"
"strconv"
"strings"
"time"
)
// minInterval borne la fréquence : sous la minute, le scheduler (ticker 60 s) ne
// pourrait de toute façon pas suivre, et une tâche qui lance une inférence toutes
// les quelques secondes n'a pas de sens.
const minInterval = time.Minute
// loc résout un fuseau IANA (ex. « Europe/Paris »). Vide ou introuvable → heure
// locale du serveur. Nécessaire parce que le serveur tourne souvent en UTC : sans
// ça, « tous les jours à 23h » et les expressions cron se calculeraient en UTC et
// se déclencheraient à la mauvaise heure pour l'utilisateur.
func loc(tz string) *time.Location {
if tz == "" {
return time.Local
}
l, err := time.LoadLocation(tz)
if err != nil {
return time.Local
}
return l
}
// nextAfter renvoie le prochain instant (strictement après `from`) où `schedule`
// se déclenche, dans le fuseau tz. Erreur si le schedule est invalide.
//
// Formes acceptées :
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
// - expression cron 5 champs.
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
s := strings.TrimSpace(schedule)
if s == "" {
return time.Time{}, fmt.Errorf("fréquence vide")
}
if rest, ok := cutPrefix(s, "@every"); ok {
return nextEvery(strings.TrimSpace(rest), tz, from)
}
return nextCron(s, tz, from)
}
// nextEvery gère les intervalles « @every … », avec ou sans ancre horaire.
func nextEvery(spec, tz string, from time.Time) (time.Time, error) {
durPart, timePart, hasTime := strings.Cut(spec, "@")
durPart = strings.TrimSpace(durPart)
// Jours : time.ParseDuration ne connaît pas « d ». On les gère à part, ce qui
// permet aussi l'ancre horaire (« tous les N jours à HH:MM »).
if n, ok := cutSuffix(durPart, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 1 {
return time.Time{}, fmt.Errorf("nombre de jours invalide : %q", durPart)
}
if !hasTime {
return from.Add(time.Duration(days) * 24 * time.Hour), nil
}
hh, mm, err := parseHHMM(timePart)
if err != nil {
return time.Time{}, err
}
l := loc(tz)
now := from.In(l)
cand := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, l)
for !cand.After(now) {
cand = cand.AddDate(0, 0, days)
}
return cand, nil
}
d, err := time.ParseDuration(durPart)
if err != nil {
return time.Time{}, fmt.Errorf("intervalle invalide (ex. « 2h », « 30m ») : %w", err)
}
if d < minInterval {
return time.Time{}, fmt.Errorf("intervalle minimum : 1 minute")
}
return from.Add(d), nil
}
// parseHHMM lit « 23:00 » / « 9:30 » en heures/minutes.
func parseHHMM(s string) (int, int, error) {
hs, ms, ok := strings.Cut(strings.TrimSpace(s), ":")
if !ok {
return 0, 0, fmt.Errorf("heure invalide (attendu HH:MM) : %q", s)
}
h, err1 := strconv.Atoi(strings.TrimSpace(hs))
m, err2 := strconv.Atoi(strings.TrimSpace(ms))
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, fmt.Errorf("heure hors bornes : %q", s)
}
return h, m, nil
}
// cutSuffix : strings.CutSuffix (Go 1.20+) réécrit pour rester lisible ici.
func cutSuffix(s, suffix string) (string, bool) {
if strings.HasSuffix(s, suffix) {
return s[:len(s)-len(suffix)], true
}
return "", false
}
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
func validateSchedule(schedule, tz string) error {
_, err := nextAfter(schedule, tz, time.Now())
return err
}
// cronSpec = les 5 champs compilés en ensembles de valeurs autorisées.
type cronSpec struct {
min, hour, dom, month, dow map[int]bool
}
// nextCron parse une expression cron 5 champs et cherche le prochain match à la
// minute, en partant de la minute qui suit `from`. Une borne (~366 j) évite une
// boucle infinie sur une expression jamais satisfiable (ex. « 31 février »).
func nextCron(expr, tz string, from time.Time) (time.Time, error) {
spec, err := parseCron(expr)
if err != nil {
return time.Time{}, err
}
// On repart de la minute suivante, dans le fuseau visé (l'heure murale de
// l'expression cron est celle de l'utilisateur, pas celle du serveur).
t := from.In(loc(tz)).Truncate(time.Minute).Add(time.Minute)
limit := t.Add(366 * 24 * time.Hour)
for ; t.Before(limit); t = t.Add(time.Minute) {
if spec.matches(t) {
return t, nil
}
}
return time.Time{}, fmt.Errorf("fréquence jamais satisfaite : %q", expr)
}
func (s cronSpec) matches(t time.Time) bool {
return s.min[t.Minute()] &&
s.hour[t.Hour()] &&
s.dom[t.Day()] &&
s.month[int(t.Month())] &&
s.dow[int(t.Weekday())]
}
func parseCron(expr string) (cronSpec, error) {
f := strings.Fields(expr)
if len(f) != 5 {
return cronSpec{}, fmt.Errorf("cron : 5 champs attendus « min heure jour mois jour-semaine », reçu %d", len(f))
}
var s cronSpec
var err error
if s.min, err = parseField(f[0], 0, 59); err != nil {
return cronSpec{}, fmt.Errorf("champ minute : %w", err)
}
if s.hour, err = parseField(f[1], 0, 23); err != nil {
return cronSpec{}, fmt.Errorf("champ heure : %w", err)
}
if s.dom, err = parseField(f[2], 1, 31); err != nil {
return cronSpec{}, fmt.Errorf("champ jour-du-mois : %w", err)
}
if s.month, err = parseField(f[3], 1, 12); err != nil {
return cronSpec{}, fmt.Errorf("champ mois : %w", err)
}
// Jour de la semaine : 0 ET 7 valent dimanche. On normalise 7 → 0.
if s.dow, err = parseField(f[4], 0, 7); err != nil {
return cronSpec{}, fmt.Errorf("champ jour-semaine : %w", err)
}
if s.dow[7] {
s.dow[0] = true
}
return s, nil
}
// parseField compile un champ cron (*, a, a-b, a,b, */n, a-b/n) en l'ensemble des
// valeurs autorisées dans [lo, hi].
func parseField(field string, lo, hi int) (map[int]bool, error) {
out := map[int]bool{}
for _, part := range strings.Split(field, ",") {
rng := part
step := 1
if base, st, ok := strings.Cut(part, "/"); ok {
n, err := strconv.Atoi(st)
if err != nil || n < 1 {
return nil, fmt.Errorf("pas invalide : %q", part)
}
step = n
rng = base
}
start, end := lo, hi
if rng != "*" {
if a, b, ok := strings.Cut(rng, "-"); ok {
var err error
if start, err = atoiRange(a, lo, hi); err != nil {
return nil, err
}
if end, err = atoiRange(b, lo, hi); err != nil {
return nil, err
}
} else {
v, err := atoiRange(rng, lo, hi)
if err != nil {
return nil, err
}
start, end = v, v
}
}
if start > end {
return nil, fmt.Errorf("plage inversée : %q", part)
}
for v := start; v <= end; v += step {
out[v] = true
}
}
if len(out) == 0 {
return nil, fmt.Errorf("champ vide")
}
return out, nil
}
func atoiRange(s string, lo, hi int) (int, error) {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0, fmt.Errorf("valeur invalide : %q", s)
}
if v < lo || v > hi {
return 0, fmt.Errorf("valeur %d hors bornes [%d-%d]", v, lo, hi)
}
return v, nil
}
// cutPrefix reconnaît le préfixe "@every" (insensible à la casse) et renvoie le
// reste. strings.CutPrefix existe (Go 1.20+) mais est sensible à la casse.
func cutPrefix(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
return s[len(prefix):], true
}
return "", false
}
+142
View File
@@ -0,0 +1,142 @@
package loki
import (
"testing"
"time"
)
func TestNextAfterInterval(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
got, err := nextAfter("@every 2h", "", from)
if err != nil {
t.Fatalf("erreur inattendue : %v", err)
}
want := from.Add(2 * time.Hour)
if !got.Equal(want) {
t.Fatalf("@every 2h : got %v, want %v", got, want)
}
// Casse tolérée sur le préfixe.
if _, err := nextAfter("@EVERY 30m", "", from); err != nil {
t.Fatalf("@EVERY 30m devrait être valide : %v", err)
}
// Sous la minute → refusé.
if _, err := nextAfter("@every 10s", "", from); err == nil {
t.Fatalf("@every 10s aurait dû être refusé")
}
if _, err := nextAfter("@every pouet", "", from); err == nil {
t.Fatalf("durée invalide aurait dû être refusée")
}
}
func TestNextEveryDays(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
// Sans ancre horaire : intervalle glissant de N*24h.
got, err := nextAfter("@every 3d", "", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
if want := from.Add(72 * time.Hour); !got.Equal(want) {
t.Fatalf("@every 3d : got %v, want %v", got, want)
}
}
func TestNextEveryDayAtTime(t *testing.T) {
// « tous les 1 jour à 23:00 », fuseau Europe/Paris (CEST = UTC+2 en août).
from := time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) // 22h à Paris
got, err := nextAfter("@every 1d@23:00", "Europe/Paris", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
// 23:00 Paris le 15 = 21:00 UTC.
want := time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("@every 1d@23:00 : got %v, want %v", got.UTC(), want)
}
// Après 23:00 Paris → le lendemain.
from2 := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) // 23h30 Paris
got2, _ := nextAfter("@every 1d@23:00", "Europe/Paris", from2)
want2 := time.Date(2026, 8, 16, 21, 0, 0, 0, time.UTC)
if !got2.Equal(want2) {
t.Fatalf("lendemain : got %v, want %v", got2.UTC(), want2)
}
}
func TestNextCronEveryQuarter(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 7, 30, 0, time.UTC)
got, err := nextAfter("*/15 * * * *", "", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 15, 10, 15, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("*/15 : got %v, want %v", got, want)
}
}
func TestNextCronWeekdayMorning(t *testing.T) {
// 2026-08-15 est un samedi. « 0 9 * * 1-5 » → lundi 17 août 09:00.
from := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
got, err := nextAfter("0 9 * * 1-5", "UTC", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("0 9 * * 1-5 : got %v, want %v", got, want)
}
}
func TestNextCronInParisTZ(t *testing.T) {
// « 0 9 * * * » à Paris = 07:00 UTC en été.
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
got, err := nextAfter("0 9 * * *", "Europe/Paris", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 15, 7, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("cron TZ : got %v, want %v", got.UTC(), want)
}
}
func TestNextCronSundayBothForms(t *testing.T) {
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) // samedi
got, err := nextAfter("0 0 * * 7", "UTC", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("dow=7 : got %v, want %v", got, want)
}
}
func TestParseCronInvalid(t *testing.T) {
cases := []string{
"",
"* * * *", // 4 champs
"* * * * * *", // 6 champs
"60 * * * *", // minute hors bornes
"* 24 * * *", // heure hors bornes
"5-1 * * * *", // plage inversée
"*/0 * * * *", // pas nul
"abc * * * *", // non numérique
"@every", // durée absente
"@every 1d@25:00", // heure hors bornes
"@every 0d@10:00", // zéro jour
}
for _, c := range cases {
if err := validateSchedule(c, ""); err == nil {
t.Errorf("schedule %q aurait dû être refusé", c)
}
}
}
func TestValidateScheduleOK(t *testing.T) {
cases := []string{"@every 1m", "@every 2h", "@every 3d", "@every 1d@23:00", "0 */2 * * *", "30 8 * * 1-5", "0 0 1 * *"}
for _, c := range cases {
if err := validateSchedule(c, "Europe/Paris"); err != nil {
t.Errorf("schedule %q aurait dû être valide : %v", c, err)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
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).
final := msgs
sys := readSysPrompt()
note := taskContextNote(taskName, lastReport)
switch {
case sys != "" && note != "":
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...)
case sys != "":
final = append([]Message{{Role: "system", Content: sys}}, msgs...)
case note != "":
final = append([]Message{{Role: "system", Content: note}}, msgs...)
}
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()
// 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
}
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)
}
+72
View File
@@ -0,0 +1,72 @@
package loki
// tasks_sched.go — la boucle de planification. Une seule goroutine, un tick par
// minute, dans le process qui possède la conversation et l'UI (ajean-ui /
// `ajean link`). Voir StartTaskScheduler, câblée dans newWebMux (web_server.go).
import (
"sync"
"time"
)
var taskSchedOnce sync.Once
// StartTaskScheduler démarre la boucle de planification (idempotent : sûr à
// appeler à chaque construction du mux, ne lance qu'une goroutine).
func StartTaskScheduler() {
taskSchedOnce.Do(func() { go taskSchedLoop() })
}
// taskSchedLoop réveille les tâches dues, une par tick (elles sont de toute façon
// sérialisées par le gate de génération). Le tick d'une minute suffit : c'est la
// résolution de la planification (voir tasks_cron.go).
func taskSchedLoop() {
// Premier passage immédiat pour amorcer les NextRun manquants (après un
// redémarrage du process, ou pour une tâche fraîchement créée sans NextRun).
primeNextRuns()
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
tickTasks(time.Now())
}
}
// primeNextRuns renseigne NextRun de toute tâche activée qui n'en a pas (0),
// relativement à maintenant — sinon une tâche à NextRun=0 serait considérée « due »
// en permanence, ou jamais planifiée selon le sens de la comparaison.
func primeNextRuns() {
now := time.Now()
for _, t := range listTasks() {
if t.Enabled && t.NextRun == 0 {
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
_ = saveTask(t)
}
}
}
// tickTasks lance la première tâche due (si l'interrupteur maître n'est pas en
// pause). Une seule par tick : lancer plusieurs inférences en parallèle est de
// toute façon impossible (gate de génération), et étaler leur départ évite qu'une
// rafale de tâches monopolise le modèle d'un coup.
func tickTasks(now time.Time) {
if tasksPaused() {
return
}
nowMs := now.UnixMilli()
for _, t := range listTasks() {
if !t.Enabled {
continue
}
if t.NextRun == 0 {
// Priming raté (schedule invalide) ou tâche neuve : on tente de le poser,
// sans la déclencher ce tour-ci.
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
_ = saveTask(t)
continue
}
if t.NextRun <= nowMs {
runTask(t) // met à jour LastRun/NextRun/… et persiste
return
}
}
}
+322
View File
@@ -0,0 +1,322 @@
package loki
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// Aller-retour complet en base : ce qui est enregistré se relit à l'identique,
// la liste est triée par nom (affichage stable) et la suppression efface bien.
func TestTasksCRUD(t *testing.T) {
testHome(t)
if got := listTasks(); len(got) != 0 {
t.Fatalf("base neuve : %d tâches, attendu 0", len(got))
}
a := Task{ID: newTaskID(), Name: "zèbre", Prompt: "p", Schedule: "@every 2h", Enabled: true}
b := Task{ID: newTaskID(), Name: "abeille", Prompt: "p", Schedule: "0 9 * * 1-5"}
if err := saveTask(a); err != nil {
t.Fatal(err)
}
if err := saveTask(b); err != nil {
t.Fatal(err)
}
list := listTasks()
if len(list) != 2 || list[0].Name != "abeille" || list[1].Name != "zèbre" {
t.Fatalf("liste non triée par nom : %+v", list)
}
got, ok := getTask(a.ID)
if !ok || got.Prompt != "p" || got.Schedule != "@every 2h" || !got.Enabled {
t.Fatalf("relecture incorrecte : %+v (ok=%v)", got, ok)
}
if err := deleteTask(a.ID); err != nil {
t.Fatal(err)
}
if _, ok := getTask(a.ID); ok {
t.Error("tâche supprimée toujours présente")
}
if len(listTasks()) != 1 {
t.Error("la suppression n'a pas retiré la tâche de la liste")
}
}
// L'interrupteur maître est un FREIN : base neuve = tâches actives, sans
// dépendre d'une écriture préalable.
func TestTasksPauseDefautActif(t *testing.T) {
testHome(t)
if tasksPaused() {
t.Error("base neuve : les tâches devraient être actives")
}
if err := setTasksPaused(true); err != nil {
t.Fatal(err)
}
if !tasksPaused() {
t.Error("pause non enregistrée")
}
_ = setTasksPaused(false)
if tasksPaused() {
t.Error("reprise non enregistrée")
}
}
// Le dossier de travail d'une tâche lui appartient : jamais celui d'une
// discussion (qui disparaît avec elle), et stable d'un passage à l'autre — une
// tâche qui tient un fichier de suivi doit le retrouver.
func TestTaskWorkspaceIsole(t *testing.T) {
testHome(t)
dir := taskWorkspaceDir("abc123")
if dir == agentWorkspace() || dir == convWorkspace() {
t.Fatalf("la tâche partage le dossier d'une discussion : %s", dir)
}
if filepath.Base(dir) != "abc123" || filepath.Base(filepath.Dir(dir)) != "tasks" {
t.Errorf("emplacement inattendu : %s", dir)
}
if again := taskWorkspaceDir("abc123"); again != dir {
t.Errorf("dossier instable entre deux passages : %s puis %s", dir, again)
}
// La bascule ne doit toucher QUE les outils : ce que voit l'utilisateur
// (panneau Fichiers, dépôts) reste sur sa discussion.
setTaskWorkspace(dir)
defer setTaskWorkspace("")
if agentCwd() != dir {
t.Errorf("agentCwd = %s, attendu %s", agentCwd(), dir)
}
if convWorkspace() == dir {
t.Error("convWorkspace suit la tâche : le panneau Fichiers changerait de dossier en cours de route")
}
}
// Les accès d'une tâche : l'agent commande tout, la tâche ne peut que RETIRER
// (no_mem/no_web), et le mode code reste hors sujet sans personne en face.
func TestTaskCaps(t *testing.T) {
testHome(t)
if c := taskCaps(Task{}); c.Agent || c.Internet || c.Mem != MemOff {
t.Errorf("agent coupé : aucun outil attendu, obtenu %+v", c)
}
if err := setAgentEnabled(true); err != nil {
t.Fatal(err)
}
c := taskCaps(Task{})
if !c.Agent {
t.Fatal("mode agent actif : la tâche doit avoir les outils")
}
if c.Code {
t.Error("le mode code n'a pas de sens pour une tâche autonome")
}
// Par défaut, la tâche suit le mode mémoire de la machine.
if c.Mem != memMode() {
t.Errorf("mémoire = %q, attendu celle de la machine (%q)", c.Mem, memMode())
}
// Machine mémoire COUPÉE mais tâche qui la demande : elle garde au moins les
// outils à la demande, sans l'injection proactive.
if err := setMemMode(MemOff); err != nil {
t.Fatal(err)
}
if got := taskCaps(Task{}).Mem; got != MemOnDemand {
t.Errorf("mémoire = %q, attendu %q", got, MemOnDemand)
}
if c := taskCaps(Task{NoMem: true, NoWeb: true}); c.Mem != MemOff || c.Internet {
t.Errorf("refus explicite non respecté : %+v", c)
}
// Web : borné à ce que la machine offre réellement — une tâche ne peut pas
// l'inventer (aucun serveur Crawl4AI joignable ici).
if c := taskCaps(Task{}); c.Internet {
t.Error("accès web accordé alors que la machine ne l'offre pas")
}
}
// Fin d'exécution : succès, échec et interruption volontaire n'écrivent pas la
// même chose — et une interruption n'est PAS un échec (pas d'indicateur rouge).
func TestRecordTaskEnd(t *testing.T) {
testHome(t)
base := Task{ID: "t1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true}
if err := saveTask(base); err != nil {
t.Fatal(err)
}
start := time.Now().Add(-2 * time.Second)
recordTaskEnd("t1", start, strings.Repeat("x", reportMax+50), nil)
got, _ := getTask("t1")
if !got.LastOK || got.LastError != "" {
t.Errorf("succès mal enregistré : %+v", got)
}
if len(got.LastReport) != reportMax+len("…") {
t.Errorf("compte-rendu non borné : %d caractères", len(got.LastReport))
}
if got.LastRun == 0 || got.NextRun <= got.LastRun {
t.Errorf("horodatages incohérents : last=%d next=%d", got.LastRun, got.NextRun)
}
if got.LastDurMs < 1000 {
t.Errorf("durée non mesurée : %d ms", got.LastDurMs)
}
recordTaskEnd("t1", start, "", errors.New("boum"))
got, _ = getTask("t1")
if got.LastOK || got.LastError != "boum" {
t.Errorf("échec mal enregistré : %+v", got)
}
recordTaskEnd("t1", start, "", context.Canceled)
got, _ = getTask("t1")
if !got.LastOK || got.LastError != "" || !strings.Contains(got.LastReport, "interrompue") {
t.Errorf("interruption prise pour un échec : %+v", got)
}
// Tâche supprimée entre-temps : on n'en ressuscite pas une.
recordTaskEnd("disparue", start, "r", nil)
if _, ok := getTask("disparue"); ok {
t.Error("recordTaskEnd a recréé une tâche supprimée")
}
}
// Le tic ne déclenche rien quand l'interrupteur maître est en pause, et pose le
// NextRun manquant sans lancer la tâche pour autant.
func TestTickTasks(t *testing.T) {
testHome(t)
now := time.Now()
due := Task{ID: "due", Name: "due", Prompt: "p", Schedule: "@every 1h", Enabled: true,
NextRun: now.Add(-time.Minute).UnixMilli()}
if err := saveTask(due); err != nil {
t.Fatal(err)
}
_ = setTasksPaused(true)
tickTasks(now)
if got, _ := getTask("due"); got.LastRun != 0 {
t.Error("une tâche s'est exécutée alors que tout est suspendu")
}
_ = setTasksPaused(false)
// NextRun absent : le tic le pose (relatif à maintenant) sans exécuter.
neuve := Task{ID: "neuve", Name: "neuve", Prompt: "p", Schedule: "@every 2h", Enabled: true}
if err := saveTask(neuve); err != nil {
t.Fatal(err)
}
primeNextRuns()
got, _ := getTask("neuve")
if got.NextRun == 0 || got.LastRun != 0 {
t.Errorf("amorçage incorrect : next=%d last=%d", got.NextRun, got.LastRun)
}
if d := time.UnixMilli(got.NextRun).Sub(now); d < 110*time.Minute || d > 130*time.Minute {
t.Errorf("prochain passage à %v, attendu ~2 h", d)
}
// Tâche désactivée : jamais amorcée, jamais lancée.
off := Task{ID: "off", Name: "off", Prompt: "p", Schedule: "@every 1h"}
if err := saveTask(off); err != nil {
t.Fatal(err)
}
primeNextRuns()
if got, _ := getTask("off"); got.NextRun != 0 {
t.Error("une tâche désactivée a été planifiée")
}
}
// Une tâche épinglée sur un preset qui n'existe plus échoue PROPREMENT : elle
// est marquée en erreur (et surtout, elle ne tourne pas sur le mauvais modèle).
func TestRunTaskPresetIntrouvable(t *testing.T) {
testHome(t)
tk := Task{ID: "p1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true, Preset: "fantome"}
if err := saveTask(tk); err != nil {
t.Fatal(err)
}
runTask(tk)
got, _ := getTask("p1")
if got.LastOK || !strings.Contains(got.LastError, "preset") {
t.Errorf("échec de bascule mal signalé : %+v", got)
}
}
// La note de contexte dit à l'IA qu'elle tourne seule, et lui redonne son
// compte-rendu précédent — borné, pour ne pas manger le contexte.
func TestTaskContextNote(t *testing.T) {
n := taskContextNote("Veille mails", "")
if !strings.Contains(n, "Veille mails") || !strings.Contains(n, "sans utilisateur") {
t.Errorf("note incomplète : %q", n)
}
if strings.Contains(n, "dernière exécution") {
t.Error("premier passage : il n'y a pas de compte-rendu à rappeler")
}
long := taskContextNote("t", strings.Repeat("y", reportMax+100))
if !strings.Contains(long, "dernière exécution") {
t.Error("le compte-rendu précédent doit être réinjecté")
}
if len(long) > reportMax+600 {
t.Errorf("note non bornée : %d caractères", len(long))
}
}
// L'API refuse une tâche bancale AVANT de l'enregistrer : sans ça une fréquence
// illisible donnerait une tâche qui ne se déclenche jamais, sans rien dire.
func TestHandleTaskSaveValide(t *testing.T) {
testHome(t)
post := func(body string) (int, map[string]any) {
rr := httptest.NewRecorder()
handleTaskSave(rr, httptest.NewRequest("POST", "/api/tasks/save", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &out)
return rr.Code, out
}
if code, out := post(`{"name":"","prompt":"p","schedule":"@every 1h"}`); code != 400 {
t.Errorf("nom vide accepté : %d %v", code, out)
}
if code, out := post(`{"name":"n","prompt":"","schedule":"@every 1h"}`); code != 400 {
t.Errorf("consigne vide acceptée : %d %v", code, out)
}
if code, out := post(`{"name":"n","prompt":"p","schedule":"tous les jours"}`); code != 400 {
t.Errorf("fréquence illisible acceptée : %d %v", code, out)
}
if len(listTasks()) != 0 {
t.Fatal("une tâche refusée a quand même été enregistrée")
}
code, out := post(`{"name":"n","prompt":"p","schedule":"0 9 * * 1-5","tz":"Europe/Paris","enabled":true}`)
if code != 200 || out["ok"] != true {
t.Fatalf("création refusée : %d %v", code, out)
}
id, _ := out["id"].(string)
got, ok := getTask(id)
if !ok || got.TZ != "Europe/Paris" || got.NextRun == 0 {
t.Fatalf("tâche mal créée : %+v", got)
}
// Édition : l'historique d'exécution SURVIT (on ne repart pas de zéro parce
// qu'on a corrigé une faute dans la consigne).
got.LastRun, got.LastReport, got.LastOK = 42, "vu hier", true
if err := saveTask(got); err != nil {
t.Fatal(err)
}
if code, out := post(`{"id":"` + id + `","name":"n2","prompt":"p2","schedule":"0 9 * * 1-5","enabled":true}`); code != 200 {
t.Fatalf("édition refusée : %d %v", code, out)
}
after, _ := getTask(id)
if after.Name != "n2" || after.Prompt != "p2" {
t.Errorf("édition non appliquée : %+v", after)
}
if after.LastRun != 42 || after.LastReport != "vu hier" {
t.Errorf("historique perdu à l'édition : %+v", after)
}
}
// Un message envoyé pendant qu'une tâche tourne doit dire LAQUELLE occupe le
// modèle : « génération en cours » sur un fil vide et immobile n'explique rien.
func TestBusyReasonNommeLaTache(t *testing.T) {
testHome(t)
if err := conv.busyReason(); err != ErrBusy {
t.Errorf("hors tâche : %v, attendu %v", err, ErrBusy)
}
conv.mu.Lock()
conv.runningTaskName = "Veille mails"
conv.mu.Unlock()
defer func() {
conv.mu.Lock()
conv.runningTaskName = ""
conv.mu.Unlock()
}()
if err := conv.busyReason(); err == nil || !strings.Contains(err.Error(), "Veille mails") {
t.Errorf("la tâche n'est pas nommée : %v", err)
}
}
+505 -1
View File
@@ -2060,6 +2060,48 @@ html[data-files="1"] #files-btn{color:var(--accent)}
ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */ ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */
.tool-shot{margin-top:8px} .tool-shot{margin-top:8px}
.tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in} .tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in}
.mcp-tag.mcp-tag-preset{flex:0 1 auto;min-width:2.5em}
.task-consigne{min-height:260px;resize:vertical;line-height:1.6;padding:12px}
/* Tâches planifiées (repris de l'amont AJEAN) : ligne en cours d'exécution + modale d'édition. */
.mcp-row.task-running{border-color:var(--accent);background:var(--accent-bg);cursor:default}
.task-dot-run{animation:taskpulse 1.1s ease-in-out infinite}
@keyframes taskpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
.task-run-badge{font-size:10px;font-weight:600;color:var(--accent);letter-spacing:.02em}
.task-stop-btn{flex:none;margin:0;padding:3px 10px;font-size:11px;border-radius:6px;background:var(--danger-bg);border:1px solid var(--err);color:var(--err)}
.task-stop-btn:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err);opacity:.85}
/* La modale des tâches emprunte tout le design de l'éditeur de preset (.pe-*) :
même largeur, même mise en carte. */
#task-modal .modal-card{max-width:520px;overflow:hidden}
#task-modal-status:empty{display:none}
/* Panneau « Dernier résultat » : le compte-rendu complet de l'IA, lisible et
défilable — pas une mini-ligne tronquée. Posé dans une carte .pe-list-like. */
.task-report{font-size:13px;line-height:1.55;color:var(--text);background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:11px 14px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.task-report.report-err{border-color:var(--err);color:var(--err)}
.task-report.report-run{color:var(--dim);font-style:italic}
.task-report.report-empty{color:var(--dim)}
/* Compte-rendu rendu en markdown : c'est du HTML de blocs, donc plus de pre-wrap
(sinon les sauts de ligne du HTML généré ajoutent des lignes vides). Styles
alignés sur ceux des bulles de chat (.msg .body). */
.task-report.md-report{white-space:normal}
.md-report>*{margin:.6em 0}
.md-report>*:first-child{margin-top:0}
.md-report>*:last-child{margin-bottom:0}
.md-report h1,.md-report h2,.md-report h3,.md-report h4{font-weight:600;line-height:1.25;margin:.9em 0 .4em}
.md-report h1{font-size:1.2em}.md-report h2{font-size:1.1em}.md-report h3{font-size:1em}.md-report h4{font-size:.95em}
.md-report ul,.md-report ol{padding-left:1.4em;margin:.5em 0}
.md-report li{margin:.15em 0}
.md-report li>p{margin:0}
.md-report code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;word-break:break-word}
.md-report pre{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
.md-report pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
.md-report blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
.md-report a{color:var(--accent);text-decoration:none}
.md-report a:hover{text-decoration:underline}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
</style></head><body> </style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button> <button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est <!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -2212,6 +2254,23 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div> </div>
</div> </div>
</details> </details>
<details id="tasks-details"><summary><span class="stitle">Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></summary>
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
conversation — elle livre son résultat via les outils de l'IA (mail via
MCP, shell…). D'où l'avertissement quand le mode agent est off : sans
lui, aucune tâche ne peut agir. -->
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="tasks-pause-toggle" onchange="toggleTasksPause()"><span class="slider"></span></span> suspendre toutes les tâches<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Interrupteur maître : coche pour que plus aucune tâche ne se déclenche. Les tâches gardent leur réglage et repartent quand tu décoches.</span></span></label>
</div>
<div id="tasks-agent-warn" class="muted" style="display:none;font-size:12px;line-height:1.5;margin:8px 2px">Le <b>mode agent</b> est désactivé : les tâches s'exécuteront mais l'IA n'aura <b>aucun outil</b> pour agir (envoyer un mail, lire des fichiers…). Active le mode agent ci-dessus.</div>
<div class="slist">
<div class="rowbody">
<div id="tasks-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
</div>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary> <details><summary><span class="stitle">System prompt</span></summary>
<div class="slist"><div class="rowbody"> <div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea> <textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
@@ -2703,6 +2762,106 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div> </div>
</div> </div>
</div> </div>
<div id="task-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeTask()">
<div class="modal-card">
<div class="modal-head">
<strong id="task-modal-title">Tâche planifiée</strong>
<button class="modal-x" onclick="closeTask()" aria-label="Fermer">×</button>
</div>
<div class="pe-body">
<div id="task-result-block" class="pe-group" style="display:none">
<div class="pe-gh">Dernier résultat <span id="task-result-when" class="pe-sub" style="display:inline;margin:0"></span></div>
<div id="task-result" class="task-report"></div>
</div>
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="task-name" class="pe-inp" placeholder="Nom de la tâche"></div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Consigne</div>
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat.&#10;&#10;Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
</div>
<div class="pe-group">
<div class="pe-gh">Preset</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Modèle utilisé</span>
<span class="pe-row-c"><span class="pe-selc"><select id="task-preset"></select></span></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Fréquence</div>
<div class="pe-seg">
<label><input type="radio" name="task-freq-mode" value="interval" onchange="taskFreqUI('interval')"><span>Intervalle</span></label>
<label><input type="radio" name="task-freq-mode" value="cron" onchange="taskFreqUI('cron')"><span>Horaire précis</span></label>
</div>
<div class="pe-list" style="margin-top:8px">
<div id="task-interval-fields" class="pe-row">
<span class="pe-row-l">Toutes les</span>
<span class="pe-row-c">
<input id="task-interval-n" class="pe-val" type="number" min="1" value="2" style="width:54px">
<span class="pe-selc"><select id="task-interval-unit" onchange="taskUnitUI()">
<option value="m">minutes</option>
<option value="h" selected>heures</option>
<option value="d">jours</option>
</select></span>
</span>
</div>
<div id="task-time-row" class="pe-row" style="display:none">
<span class="pe-row-l">À</span>
<span class="pe-row-c"><input id="task-time" class="pe-val" type="time" value="09:00" style="width:110px"></span>
</div>
<div id="task-cron-fields" class="pe-row stack" style="display:none">
<input id="task-cron" class="pe-inp" placeholder="0 9 * * 1-5" spellcheck="false" style="font-family:var(--mono)">
<div class="pe-note"><code>minute heure jour mois jour-semaine</code></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Accès</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Accès à la mémoire</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-mem" checked><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Accès au web</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-web" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Tâche active</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-enabled" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div id="task-modal-status" class="pe-seg-note" style="padding:0 6px"></div>
</div>
<div class="pe-foot">
<div class="foot-l">
<button id="task-del" class="pe-del" onclick="deleteTaskUI()" title="supprimer" aria-label="supprimer" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
</div>
<div class="foot-r">
<button id="task-run" class="pe-btn" onclick="runTaskNow()" style="display:none">tester maintenant</button>
<button class="pe-btn" onclick="closeTask()">annuler</button>
<button class="pe-btn primary" onclick="saveTask()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none"> <div id="modal" class="modal-ov" style="display:none">
<div class="modal-card"> <div class="modal-card">
<div class="modal-head"> <div class="modal-head">
@@ -4014,7 +4173,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test // suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort()]); await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
} }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -8250,4 +8409,349 @@ function renderAskCard(ask){
// Init : état du mode au chargement de la page. // Init : état du mode au chargement de la page.
window.addEventListener('DOMContentLoaded', modeOnConvChange); window.addEventListener('DOMContentLoaded', modeOnConvChange);
// ─── Tâches planifiées ───────────────────────────────────────────────────────
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté.
//
// L'IA exécute une consigne toute seule sur une fréquence réglable (intervalle
// simple ou cron). Chaque tâche tourne ISOLÉE des discussions : elle livre son
// résultat via les propres outils de l'IA (mail via MCP, shell…), dans son
// dossier de travail à elle. L'interrupteur maître (« suspendre toutes les
// tâches ») permet de tout figer d'un coup pour discuter tranquille. Réutilise le
// gabarit visuel des serveurs MCP (mcp-row) et celui de l'éditeur de preset (pe-*).
let tasksList = [];
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
let tasksPollTimer = null;
async function loadTasks(){ renderTasks(await jget('/api/tasks')); }
// Tant qu'une tâche tourne, on rafraîchit la liste toutes les 2 s (l'exécution est
// isolée du fil : sans sondage, on ne verrait ni son démarrage ni sa fin). On
// arrête le sondage dès qu'aucune tâche ne tourne — pas de poll permanent.
function ensureTasksPoll(){
if(TASK_RUNNING && !tasksPollTimer){
tasksPollTimer = setInterval(loadTasks, 2000);
} else if(!TASK_RUNNING && tasksPollTimer){
clearInterval(tasksPollTimer); tasksPollTimer = null;
}
}
function renderTasks(r){
tasksList = (r && r.tasks) || [];
const paused = !!(r && r.paused);
const agentOn = !!(r && r.agent);
TASK_PRESETS = (r && r.presets) || [];
TASK_MEM_ON = !r || r.mem_on !== false;
TASK_WEB_ON = !r || r.web_on !== false;
TASK_RUNNING = (r && r.running_id) || '';
ensureTasksPoll();
const pc = document.getElementById('tasks-pause-toggle');
if(pc) pc.checked = paused;
// Avertissement : sans mode agent, une tâche n'a aucun outil pour agir.
const warn = document.getElementById('tasks-agent-warn');
if(warn) warn.style.display = (tasksList.length && !agentOn) ? '' : 'none';
// Pastille : « en cours » l'emporte, sinon le nombre de tâches actives.
const nActive = tasksList.filter(t=>t.enabled).length;
if(TASK_RUNNING) setBadge('tasks-badge', 'warn', 'en cours');
else setBadge('tasks-badge', nActive ? true : null, nActive ? nActive+' active'+(nActive>1?'s':'') : '');
const list = document.getElementById('tasks-list');
list.textContent = '';
if(!tasksList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucune tâche)</div>';
return;
}
tasksList.forEach(t=>{
const running = (t.id === TASK_RUNNING);
const row = document.createElement('div');
row.className = 'mcp-row'+(t.enabled?'':' off')+(running?' task-running':'');
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(running){
// Point animé pendant l'exécution.
dot.classList.add('mcp-dot-ok','task-dot-run');
} else if(!t.enabled){ dot.classList.add('mcp-dot-off'); }
else if(t.last_run && !t.last_ok){ dot.classList.add('mcp-dot-err'); }
else if(t.last_run && t.last_ok){ dot.classList.add('mcp-dot-ok'); }
else { dot.classList.add('mcp-dot-off'); }
dot.title = running ? 'en cours d’exécution'
: (!t.enabled ? 'désactivée'
: (t.last_run ? (t.last_ok ? 'dernier passage : ok' : 'dernier passage : échec') : 'jamais exécutée'));
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = t.name; nm.title = t.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
if(running){
const rn = document.createElement('span'); rn.className = 'task-run-badge';
rn.textContent = 'en cours…'; meta.appendChild(rn);
} else {
const fr = document.createElement('span'); fr.className = 'mcp-tag';
fr.textContent = scheduleLabel(t.schedule); meta.appendChild(fr);
if(t.preset){
const pr = document.createElement('span'); pr.className = 'mcp-tag mcp-tag-preset';
const pn = (TASK_PRESETS.find(p=>p.id===t.preset)||{}).name || t.preset;
pr.textContent = pn; pr.title = 'preset : '+pn; meta.appendChild(pr);
}
if(t.enabled && t.next_run){
const nx = document.createElement('span'); nx.className = 'mcp-tools';
nx.textContent = '→ '+taskWhen(t.next_run);
nx.title = 'prochaine exécution : '+taskWhen(t.next_run);
meta.appendChild(nx);
}
if(t.last_run && !t.last_ok && t.last_error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'échec'; er.title = t.last_error;
meta.appendChild(er);
}
}
info.appendChild(nm); info.appendChild(meta);
row.appendChild(dot); row.appendChild(info);
if(running){
// Bouton arrêter : passe par /api/chat/stop (conv.Stop annule le contexte de
// la tâche, cf. RunAutonomous).
const st = document.createElement('button');
st.className = 'task-stop-btn'; st.textContent = 'arrêter';
st.onclick = (e)=>{ e.stopPropagation(); stopRunningTask(); };
row.appendChild(st);
} else {
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled;
cb.onchange = ()=>toggleTask(t.id, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.onclick = (e)=>{ e.stopPropagation(); openTask(t.id); };
row.appendChild(sw); row.appendChild(edit);
}
row.onclick = ()=> running ? null : openTask(t.id);
list.appendChild(row);
});
}
async function stopRunningTask(){
await jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{});
toast('arrêt de la tâche…');
setTimeout(loadTasks, 800);
}
// taskWhen met en forme un epoch ms dans le FUSEAU du navigateur (jj/mm HH:MM) —
// le serveur envoie l'horodatage brut, sinon il serait figé en UTC (décalage).
// Nom distinct du fmtWhen du panneau Fichiers, qui prend des SECONDES et rend
// autre chose : deux vues, deux formats, aucune raison de les confondre.
function taskWhen(ms){
if(!ms) return '';
const d = new Date(ms);
const p = n => String(n).padStart(2,'0');
return p(d.getDate())+'/'+p(d.getMonth()+1)+' '+p(d.getHours())+':'+p(d.getMinutes());
}
// (fmtDur vit dans 09-stream.js : même unité, même rendu — on ne le redouble pas.)
// scheduleLabel rend une fréquence lisible pour la pastille de la liste.
function scheduleLabel(s){
s = (s||'').trim();
const m = /^@every\s+(.+)$/i.exec(s);
if(m){
const d = m[1].trim();
const mm = /^(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?$/.exec(d);
if(mm){
let n = parseInt(mm[1], 10), u = mm[2]; const tm = mm[3];
// Anciennes tâches « jours » stockées en heures (multiple de 24).
if(u === 'h' && n % 24 === 0 && n >= 24){ n = n/24; u = 'd'; }
const ul = {m:'min', h:'h', d:'jour'}[u] || u;
let lbl = 'toutes les '+n+' '+ul+((u==='d'&&n>1)?'s':'');
if(u==='d' && tm) lbl += ' à '+tm;
return lbl;
}
return 'toutes les '+d;
}
return 'cron : '+s;
}
async function toggleTask(id, on){
await jpost('/api/tasks/toggle', {id, on});
await loadTasks();
toast(on ? 'tâche activée' : 'tâche désactivée');
}
async function toggleTasksPause(){
const on = document.getElementById('tasks-pause-toggle').checked;
await jpost('/api/tasks/pause', {on});
toast(on ? 'tâches suspendues' : 'tâches réactivées');
loadTasks();
}
// Ouvre la modale d'ajout (id='') ou d'édition (id existant).
function openTask(id){
taskEditing = id || '';
const t = id ? tasksList.find(x=>x.id===id) : null;
document.getElementById('task-modal-title').textContent = t ? 'Éditer « '+t.name+' »' : 'Nouvelle tâche';
document.getElementById('task-name').value = t ? t.name : '';
document.getElementById('task-prompt').value = t ? t.prompt : '';
document.getElementById('task-enabled').checked = t ? !!t.enabled : true;
// Accès mémoire/web : sur une tâche existante on lit son réglage (no_mem/no_web,
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
fillPresetSelect(t ? (t.preset||'') : '');
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
const sched = t ? (t.schedule||'') : '@every 2h';
const m = /^@every\s+(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?\s*$/i.exec(sched);
document.getElementById('task-time').value = '09:00';
if(m || !t){
setTaskFreqMode('interval');
document.getElementById('task-interval-n').value = m ? m[1] : '2';
document.getElementById('task-interval-unit').value = m ? m[2] : 'h';
if(m && m[3]){ const tm = m[3]; document.getElementById('task-time').value = tm.length===4 ? '0'+tm : tm; }
document.getElementById('task-cron').value = '';
} else {
setTaskFreqMode('cron');
document.getElementById('task-cron').value = sched;
}
taskUnitUI();
document.getElementById('task-modal-status').textContent = '';
renderTaskResult(t);
document.getElementById('task-del').style.display = t ? '' : 'none';
document.getElementById('task-run').style.display = t ? '' : 'none';
showModal('task-modal');
}
// renderTaskResult remplit le panneau « Dernier résultat » avec le compte-rendu
// COMPLET (défilable), l'état (ok / échec / en cours) et l'horodatage. Masqué pour
// une tâche neuve ou jamais exécutée.
function renderTaskResult(t){
const block = document.getElementById('task-result-block');
const box = document.getElementById('task-result');
const when = document.getElementById('task-result-when');
box.className = 'task-report';
if(!t || (!t.last_run && t.id !== TASK_RUNNING)){ block.style.display = 'none'; return; }
block.style.display = '';
if(t.id === TASK_RUNNING){
when.textContent = '(en cours)';
box.classList.add('report-run');
box.textContent = 'exécution en cours…';
return;
}
const parts = [];
if(t.last_run) parts.push(new Date(t.last_run).toLocaleString());
if(t.last_dur_ms) parts.push('en '+fmtDur(t.last_dur_ms));
when.textContent = parts.length ? '· '+parts.join(' · ') : '';
if(!t.last_ok){
box.classList.add('report-err');
box.textContent = 'Échec : '+(t.last_error||'erreur inconnue');
return;
}
const rep = (t.last_report||'').trim();
if(rep){ box.classList.add('md-report'); box.innerHTML = md(rep); }
else { box.classList.add('report-empty'); box.textContent = '(terminée sans texte de compte-rendu — l\'action a été faite via les outils)'; }
}
function closeTask(){ hideModal('task-modal'); taskEditing = null; }
// fillPresetSelect peuple le sélecteur de preset. Première option = « preset
// actif » (vide) : la tâche utilise le modèle en cours au moment de l'exécution.
function fillPresetSelect(selected){
const sel = document.getElementById('task-preset');
sel.textContent = '';
const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'preset actif';
sel.appendChild(opt0);
TASK_PRESETS.forEach(p=>{
const o = document.createElement('option'); o.value = p.id;
o.textContent = p.name + (p.active ? ' (actif)' : '');
sel.appendChild(o);
});
sel.value = selected || '';
}
function setTaskFreqMode(mode){
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
if(r) r.checked = true;
taskFreqUI(mode);
}
function taskFreqUI(mode){
document.getElementById('task-interval-fields').style.display = mode==='interval' ? '' : 'none';
document.getElementById('task-cron-fields').style.display = mode==='cron' ? '' : 'none';
taskUnitUI();
}
// taskUnitUI : la ligne « À (heure) » n'apparaît que pour un intervalle en JOURS
// (« tous les N jours à HH:MM »). Sans intérêt pour des minutes/heures.
function taskUnitUI(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value;
const unit = document.getElementById('task-interval-unit').value;
document.getElementById('task-time-row').style.display = (mode==='interval' && unit==='d') ? '' : 'none';
}
// buildSchedule sérialise l'état du formulaire de fréquence en chaîne serveur.
function buildSchedule(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value || 'interval';
if(mode==='cron') return document.getElementById('task-cron').value.trim();
const n = parseInt(document.getElementById('task-interval-n').value, 10);
const u = document.getElementById('task-interval-unit').value;
if(!n || n < 1) return '';
// Jours : on ancre à une heure de la journée (« @every Nd@HH:MM »).
if(u === 'd'){
const tm = document.getElementById('task-time').value || '09:00';
return '@every '+n+'d@'+tm;
}
return '@every '+n+u;
}
async function saveTask(){
const name = document.getElementById('task-name').value.trim();
const prompt = document.getElementById('task-prompt').value.trim();
const schedule = buildSchedule();
const enabled = document.getElementById('task-enabled').checked;
const preset = document.getElementById('task-preset').value;
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
const no_mem = !document.getElementById('task-mem').checked;
const no_web = !document.getElementById('task-web').checked;
const st = document.getElementById('task-modal-status');
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
closeTask();
await loadTasks();
toast('tâche enregistrée');
}
async function deleteTaskUI(){
if(!taskEditing) return;
const t = tasksList.find(x=>x.id===taskEditing);
if(!await askConfirm('Supprimer la tâche « '+(t?t.name:'')+' » ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
await jpost('/api/tasks/delete', {id: taskEditing});
closeTask();
await loadTasks();
toast('tâche supprimée');
}
async function runTaskNow(){
if(!taskEditing) return;
const st = document.getElementById('task-modal-status');
const r = await jpost('/api/tasks/run', {id: taskEditing});
if(r && r.ok){
st.textContent = 'lancée, suis son avancement dans la liste';
st.style.color = '';
closeTask();
// Rafraîchit tout de suite : loadTasks détecte running_id et lance le sondage
// qui suivra l'exécution jusqu'à la fin.
setTimeout(loadTasks, 400);
} else {
st.textContent = (r && r.error) || 'impossible de lancer maintenant';
st.style.color = 'var(--err)';
}
}
</script></body></html> </script></body></html>
+117
View File
@@ -182,6 +182,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div> </div>
</div> </div>
</details> </details>
<details id="tasks-details"><summary><span class="stitle">Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></summary>
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
conversation — elle livre son résultat via les outils de l'IA (mail via
MCP, shell…). D'où l'avertissement quand le mode agent est off : sans
lui, aucune tâche ne peut agir. -->
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="tasks-pause-toggle" onchange="toggleTasksPause()"><span class="slider"></span></span> suspendre toutes les tâches<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Interrupteur maître : coche pour que plus aucune tâche ne se déclenche. Les tâches gardent leur réglage et repartent quand tu décoches.</span></span></label>
</div>
<div id="tasks-agent-warn" class="muted" style="display:none;font-size:12px;line-height:1.5;margin:8px 2px">Le <b>mode agent</b> est désactivé : les tâches s'exécuteront mais l'IA n'aura <b>aucun outil</b> pour agir (envoyer un mail, lire des fichiers…). Active le mode agent ci-dessus.</div>
<div class="slist">
<div class="rowbody">
<div id="tasks-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
</div>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary> <details><summary><span class="stitle">System prompt</span></summary>
<div class="slist"><div class="rowbody"> <div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea> <textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
@@ -673,6 +690,106 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div> </div>
</div> </div>
</div> </div>
<div id="task-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeTask()">
<div class="modal-card">
<div class="modal-head">
<strong id="task-modal-title">Tâche planifiée</strong>
<button class="modal-x" onclick="closeTask()" aria-label="Fermer">×</button>
</div>
<div class="pe-body">
<div id="task-result-block" class="pe-group" style="display:none">
<div class="pe-gh">Dernier résultat <span id="task-result-when" class="pe-sub" style="display:inline;margin:0"></span></div>
<div id="task-result" class="task-report"></div>
</div>
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="task-name" class="pe-inp" placeholder="Nom de la tâche"></div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Consigne</div>
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat.&#10;&#10;Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
</div>
<div class="pe-group">
<div class="pe-gh">Preset</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Modèle utilisé</span>
<span class="pe-row-c"><span class="pe-selc"><select id="task-preset"></select></span></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Fréquence</div>
<div class="pe-seg">
<label><input type="radio" name="task-freq-mode" value="interval" onchange="taskFreqUI('interval')"><span>Intervalle</span></label>
<label><input type="radio" name="task-freq-mode" value="cron" onchange="taskFreqUI('cron')"><span>Horaire précis</span></label>
</div>
<div class="pe-list" style="margin-top:8px">
<div id="task-interval-fields" class="pe-row">
<span class="pe-row-l">Toutes les</span>
<span class="pe-row-c">
<input id="task-interval-n" class="pe-val" type="number" min="1" value="2" style="width:54px">
<span class="pe-selc"><select id="task-interval-unit" onchange="taskUnitUI()">
<option value="m">minutes</option>
<option value="h" selected>heures</option>
<option value="d">jours</option>
</select></span>
</span>
</div>
<div id="task-time-row" class="pe-row" style="display:none">
<span class="pe-row-l">À</span>
<span class="pe-row-c"><input id="task-time" class="pe-val" type="time" value="09:00" style="width:110px"></span>
</div>
<div id="task-cron-fields" class="pe-row stack" style="display:none">
<input id="task-cron" class="pe-inp" placeholder="0 9 * * 1-5" spellcheck="false" style="font-family:var(--mono)">
<div class="pe-note"><code>minute heure jour mois jour-semaine</code></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Accès</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Accès à la mémoire</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-mem" checked><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Accès au web</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-web" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Tâche active</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-enabled" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div id="task-modal-status" class="pe-seg-note" style="padding:0 6px"></div>
</div>
<div class="pe-foot">
<div class="foot-l">
<button id="task-del" class="pe-del" onclick="deleteTaskUI()" title="supprimer" aria-label="supprimer" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
</div>
<div class="foot-r">
<button id="task-run" class="pe-btn" onclick="runTaskNow()" style="display:none">tester maintenant</button>
<button class="pe-btn" onclick="closeTask()">annuler</button>
<button class="pe-btn primary" onclick="saveTask()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none"> <div id="modal" class="modal-ov" style="display:none">
<div class="modal-card"> <div class="modal-card">
<div class="modal-head"> <div class="modal-head">
+1 -1
View File
@@ -451,7 +451,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test // suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort()]); await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
} }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+345
View File
@@ -0,0 +1,345 @@
// ─── Tâches planifiées ───────────────────────────────────────────────────────
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté.
//
// L'IA exécute une consigne toute seule sur une fréquence réglable (intervalle
// simple ou cron). Chaque tâche tourne ISOLÉE des discussions : elle livre son
// résultat via les propres outils de l'IA (mail via MCP, shell…), dans son
// dossier de travail à elle. L'interrupteur maître (« suspendre toutes les
// tâches ») permet de tout figer d'un coup pour discuter tranquille. Réutilise le
// gabarit visuel des serveurs MCP (mcp-row) et celui de l'éditeur de preset (pe-*).
let tasksList = [];
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
let tasksPollTimer = null;
async function loadTasks(){ renderTasks(await jget('/api/tasks')); }
// Tant qu'une tâche tourne, on rafraîchit la liste toutes les 2 s (l'exécution est
// isolée du fil : sans sondage, on ne verrait ni son démarrage ni sa fin). On
// arrête le sondage dès qu'aucune tâche ne tourne — pas de poll permanent.
function ensureTasksPoll(){
if(TASK_RUNNING && !tasksPollTimer){
tasksPollTimer = setInterval(loadTasks, 2000);
} else if(!TASK_RUNNING && tasksPollTimer){
clearInterval(tasksPollTimer); tasksPollTimer = null;
}
}
function renderTasks(r){
tasksList = (r && r.tasks) || [];
const paused = !!(r && r.paused);
const agentOn = !!(r && r.agent);
TASK_PRESETS = (r && r.presets) || [];
TASK_MEM_ON = !r || r.mem_on !== false;
TASK_WEB_ON = !r || r.web_on !== false;
TASK_RUNNING = (r && r.running_id) || '';
ensureTasksPoll();
const pc = document.getElementById('tasks-pause-toggle');
if(pc) pc.checked = paused;
// Avertissement : sans mode agent, une tâche n'a aucun outil pour agir.
const warn = document.getElementById('tasks-agent-warn');
if(warn) warn.style.display = (tasksList.length && !agentOn) ? '' : 'none';
// Pastille : « en cours » l'emporte, sinon le nombre de tâches actives.
const nActive = tasksList.filter(t=>t.enabled).length;
if(TASK_RUNNING) setBadge('tasks-badge', 'warn', 'en cours');
else setBadge('tasks-badge', nActive ? true : null, nActive ? nActive+' active'+(nActive>1?'s':'') : '');
const list = document.getElementById('tasks-list');
list.textContent = '';
if(!tasksList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucune tâche)</div>';
return;
}
tasksList.forEach(t=>{
const running = (t.id === TASK_RUNNING);
const row = document.createElement('div');
row.className = 'mcp-row'+(t.enabled?'':' off')+(running?' task-running':'');
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(running){
// Point animé pendant l'exécution.
dot.classList.add('mcp-dot-ok','task-dot-run');
} else if(!t.enabled){ dot.classList.add('mcp-dot-off'); }
else if(t.last_run && !t.last_ok){ dot.classList.add('mcp-dot-err'); }
else if(t.last_run && t.last_ok){ dot.classList.add('mcp-dot-ok'); }
else { dot.classList.add('mcp-dot-off'); }
dot.title = running ? 'en cours d’exécution'
: (!t.enabled ? 'désactivée'
: (t.last_run ? (t.last_ok ? 'dernier passage : ok' : 'dernier passage : échec') : 'jamais exécutée'));
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = t.name; nm.title = t.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
if(running){
const rn = document.createElement('span'); rn.className = 'task-run-badge';
rn.textContent = 'en cours…'; meta.appendChild(rn);
} else {
const fr = document.createElement('span'); fr.className = 'mcp-tag';
fr.textContent = scheduleLabel(t.schedule); meta.appendChild(fr);
if(t.preset){
const pr = document.createElement('span'); pr.className = 'mcp-tag mcp-tag-preset';
const pn = (TASK_PRESETS.find(p=>p.id===t.preset)||{}).name || t.preset;
pr.textContent = pn; pr.title = 'preset : '+pn; meta.appendChild(pr);
}
if(t.enabled && t.next_run){
const nx = document.createElement('span'); nx.className = 'mcp-tools';
nx.textContent = '→ '+taskWhen(t.next_run);
nx.title = 'prochaine exécution : '+taskWhen(t.next_run);
meta.appendChild(nx);
}
if(t.last_run && !t.last_ok && t.last_error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'échec'; er.title = t.last_error;
meta.appendChild(er);
}
}
info.appendChild(nm); info.appendChild(meta);
row.appendChild(dot); row.appendChild(info);
if(running){
// Bouton arrêter : passe par /api/chat/stop (conv.Stop annule le contexte de
// la tâche, cf. RunAutonomous).
const st = document.createElement('button');
st.className = 'task-stop-btn'; st.textContent = 'arrêter';
st.onclick = (e)=>{ e.stopPropagation(); stopRunningTask(); };
row.appendChild(st);
} else {
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled;
cb.onchange = ()=>toggleTask(t.id, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.onclick = (e)=>{ e.stopPropagation(); openTask(t.id); };
row.appendChild(sw); row.appendChild(edit);
}
row.onclick = ()=> running ? null : openTask(t.id);
list.appendChild(row);
});
}
async function stopRunningTask(){
await jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{});
toast('arrêt de la tâche…');
setTimeout(loadTasks, 800);
}
// taskWhen met en forme un epoch ms dans le FUSEAU du navigateur (jj/mm HH:MM) —
// le serveur envoie l'horodatage brut, sinon il serait figé en UTC (décalage).
// Nom distinct du fmtWhen du panneau Fichiers, qui prend des SECONDES et rend
// autre chose : deux vues, deux formats, aucune raison de les confondre.
function taskWhen(ms){
if(!ms) return '';
const d = new Date(ms);
const p = n => String(n).padStart(2,'0');
return p(d.getDate())+'/'+p(d.getMonth()+1)+' '+p(d.getHours())+':'+p(d.getMinutes());
}
// (fmtDur vit dans 09-stream.js : même unité, même rendu — on ne le redouble pas.)
// scheduleLabel rend une fréquence lisible pour la pastille de la liste.
function scheduleLabel(s){
s = (s||'').trim();
const m = /^@every\s+(.+)$/i.exec(s);
if(m){
const d = m[1].trim();
const mm = /^(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?$/.exec(d);
if(mm){
let n = parseInt(mm[1], 10), u = mm[2]; const tm = mm[3];
// Anciennes tâches « jours » stockées en heures (multiple de 24).
if(u === 'h' && n % 24 === 0 && n >= 24){ n = n/24; u = 'd'; }
const ul = {m:'min', h:'h', d:'jour'}[u] || u;
let lbl = 'toutes les '+n+' '+ul+((u==='d'&&n>1)?'s':'');
if(u==='d' && tm) lbl += ' à '+tm;
return lbl;
}
return 'toutes les '+d;
}
return 'cron : '+s;
}
async function toggleTask(id, on){
await jpost('/api/tasks/toggle', {id, on});
await loadTasks();
toast(on ? 'tâche activée' : 'tâche désactivée');
}
async function toggleTasksPause(){
const on = document.getElementById('tasks-pause-toggle').checked;
await jpost('/api/tasks/pause', {on});
toast(on ? 'tâches suspendues' : 'tâches réactivées');
loadTasks();
}
// Ouvre la modale d'ajout (id='') ou d'édition (id existant).
function openTask(id){
taskEditing = id || '';
const t = id ? tasksList.find(x=>x.id===id) : null;
document.getElementById('task-modal-title').textContent = t ? 'Éditer « '+t.name+' »' : 'Nouvelle tâche';
document.getElementById('task-name').value = t ? t.name : '';
document.getElementById('task-prompt').value = t ? t.prompt : '';
document.getElementById('task-enabled').checked = t ? !!t.enabled : true;
// Accès mémoire/web : sur une tâche existante on lit son réglage (no_mem/no_web,
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
fillPresetSelect(t ? (t.preset||'') : '');
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
const sched = t ? (t.schedule||'') : '@every 2h';
const m = /^@every\s+(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?\s*$/i.exec(sched);
document.getElementById('task-time').value = '09:00';
if(m || !t){
setTaskFreqMode('interval');
document.getElementById('task-interval-n').value = m ? m[1] : '2';
document.getElementById('task-interval-unit').value = m ? m[2] : 'h';
if(m && m[3]){ const tm = m[3]; document.getElementById('task-time').value = tm.length===4 ? '0'+tm : tm; }
document.getElementById('task-cron').value = '';
} else {
setTaskFreqMode('cron');
document.getElementById('task-cron').value = sched;
}
taskUnitUI();
document.getElementById('task-modal-status').textContent = '';
renderTaskResult(t);
document.getElementById('task-del').style.display = t ? '' : 'none';
document.getElementById('task-run').style.display = t ? '' : 'none';
showModal('task-modal');
}
// renderTaskResult remplit le panneau « Dernier résultat » avec le compte-rendu
// COMPLET (défilable), l'état (ok / échec / en cours) et l'horodatage. Masqué pour
// une tâche neuve ou jamais exécutée.
function renderTaskResult(t){
const block = document.getElementById('task-result-block');
const box = document.getElementById('task-result');
const when = document.getElementById('task-result-when');
box.className = 'task-report';
if(!t || (!t.last_run && t.id !== TASK_RUNNING)){ block.style.display = 'none'; return; }
block.style.display = '';
if(t.id === TASK_RUNNING){
when.textContent = '(en cours)';
box.classList.add('report-run');
box.textContent = 'exécution en cours…';
return;
}
const parts = [];
if(t.last_run) parts.push(new Date(t.last_run).toLocaleString());
if(t.last_dur_ms) parts.push('en '+fmtDur(t.last_dur_ms));
when.textContent = parts.length ? '· '+parts.join(' · ') : '';
if(!t.last_ok){
box.classList.add('report-err');
box.textContent = 'Échec : '+(t.last_error||'erreur inconnue');
return;
}
const rep = (t.last_report||'').trim();
if(rep){ box.classList.add('md-report'); box.innerHTML = md(rep); }
else { box.classList.add('report-empty'); box.textContent = '(terminée sans texte de compte-rendu — l\'action a été faite via les outils)'; }
}
function closeTask(){ hideModal('task-modal'); taskEditing = null; }
// fillPresetSelect peuple le sélecteur de preset. Première option = « preset
// actif » (vide) : la tâche utilise le modèle en cours au moment de l'exécution.
function fillPresetSelect(selected){
const sel = document.getElementById('task-preset');
sel.textContent = '';
const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'preset actif';
sel.appendChild(opt0);
TASK_PRESETS.forEach(p=>{
const o = document.createElement('option'); o.value = p.id;
o.textContent = p.name + (p.active ? ' (actif)' : '');
sel.appendChild(o);
});
sel.value = selected || '';
}
function setTaskFreqMode(mode){
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
if(r) r.checked = true;
taskFreqUI(mode);
}
function taskFreqUI(mode){
document.getElementById('task-interval-fields').style.display = mode==='interval' ? '' : 'none';
document.getElementById('task-cron-fields').style.display = mode==='cron' ? '' : 'none';
taskUnitUI();
}
// taskUnitUI : la ligne « À (heure) » n'apparaît que pour un intervalle en JOURS
// (« tous les N jours à HH:MM »). Sans intérêt pour des minutes/heures.
function taskUnitUI(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value;
const unit = document.getElementById('task-interval-unit').value;
document.getElementById('task-time-row').style.display = (mode==='interval' && unit==='d') ? '' : 'none';
}
// buildSchedule sérialise l'état du formulaire de fréquence en chaîne serveur.
function buildSchedule(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value || 'interval';
if(mode==='cron') return document.getElementById('task-cron').value.trim();
const n = parseInt(document.getElementById('task-interval-n').value, 10);
const u = document.getElementById('task-interval-unit').value;
if(!n || n < 1) return '';
// Jours : on ancre à une heure de la journée (« @every Nd@HH:MM »).
if(u === 'd'){
const tm = document.getElementById('task-time').value || '09:00';
return '@every '+n+'d@'+tm;
}
return '@every '+n+u;
}
async function saveTask(){
const name = document.getElementById('task-name').value.trim();
const prompt = document.getElementById('task-prompt').value.trim();
const schedule = buildSchedule();
const enabled = document.getElementById('task-enabled').checked;
const preset = document.getElementById('task-preset').value;
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
const no_mem = !document.getElementById('task-mem').checked;
const no_web = !document.getElementById('task-web').checked;
const st = document.getElementById('task-modal-status');
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
closeTask();
await loadTasks();
toast('tâche enregistrée');
}
async function deleteTaskUI(){
if(!taskEditing) return;
const t = tasksList.find(x=>x.id===taskEditing);
if(!await askConfirm('Supprimer la tâche « '+(t?t.name:'')+' » ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
await jpost('/api/tasks/delete', {id: taskEditing});
closeTask();
await loadTasks();
toast('tâche supprimée');
}
async function runTaskNow(){
if(!taskEditing) return;
const st = document.getElementById('task-modal-status');
const r = await jpost('/api/tasks/run', {id: taskEditing});
if(r && r.ok){
st.textContent = 'lancée, suis son avancement dans la liste';
st.style.color = '';
closeTask();
// Rafraîchit tout de suite : loadTasks détecte running_id et lance le sondage
// qui suivra l'exécution jusqu'à la fin.
setTimeout(loadTasks, 400);
} else {
st.textContent = (r && r.error) || 'impossible de lancer maintenant';
st.style.color = 'var(--err)';
}
}
+42
View File
@@ -2029,3 +2029,45 @@ html[data-files="1"] #files-btn{color:var(--accent)}
ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */ ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */
.tool-shot{margin-top:8px} .tool-shot{margin-top:8px}
.tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in} .tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in}
.mcp-tag.mcp-tag-preset{flex:0 1 auto;min-width:2.5em}
.task-consigne{min-height:260px;resize:vertical;line-height:1.6;padding:12px}
/* Tâches planifiées (repris de l'amont AJEAN) : ligne en cours d'exécution + modale d'édition. */
.mcp-row.task-running{border-color:var(--accent);background:var(--accent-bg);cursor:default}
.task-dot-run{animation:taskpulse 1.1s ease-in-out infinite}
@keyframes taskpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
.task-run-badge{font-size:10px;font-weight:600;color:var(--accent);letter-spacing:.02em}
.task-stop-btn{flex:none;margin:0;padding:3px 10px;font-size:11px;border-radius:6px;background:var(--danger-bg);border:1px solid var(--err);color:var(--err)}
.task-stop-btn:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err);opacity:.85}
/* La modale des tâches emprunte tout le design de l'éditeur de preset (.pe-*) :
même largeur, même mise en carte. */
#task-modal .modal-card{max-width:520px;overflow:hidden}
#task-modal-status:empty{display:none}
/* Panneau « Dernier résultat » : le compte-rendu complet de l'IA, lisible et
défilable — pas une mini-ligne tronquée. Posé dans une carte .pe-list-like. */
.task-report{font-size:13px;line-height:1.55;color:var(--text);background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:11px 14px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
.task-report.report-err{border-color:var(--err);color:var(--err)}
.task-report.report-run{color:var(--dim);font-style:italic}
.task-report.report-empty{color:var(--dim)}
/* Compte-rendu rendu en markdown : c'est du HTML de blocs, donc plus de pre-wrap
(sinon les sauts de ligne du HTML généré ajoutent des lignes vides). Styles
alignés sur ceux des bulles de chat (.msg .body). */
.task-report.md-report{white-space:normal}
.md-report>*{margin:.6em 0}
.md-report>*:first-child{margin-top:0}
.md-report>*:last-child{margin-bottom:0}
.md-report h1,.md-report h2,.md-report h3,.md-report h4{font-weight:600;line-height:1.25;margin:.9em 0 .4em}
.md-report h1{font-size:1.2em}.md-report h2{font-size:1.1em}.md-report h3{font-size:1em}.md-report h4{font-size:.95em}
.md-report ul,.md-report ol{padding-left:1.4em;margin:.5em 0}
.md-report li{margin:.15em 0}
.md-report li>p{margin:0}
.md-report code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;word-break:break-word}
.md-report pre{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
.md-report pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
.md-report blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
.md-report a{color:var(--accent);text-decoration:none}
.md-report a:hover{text-decoration:underline}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
+2
View File
@@ -107,6 +107,7 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
code := 503 code := 503
if err == ErrBusy { if err == ErrBusy {
code = 409 code = 409
err = conv.busyReason() // dit LAQUELLE des deux occupe le modèle
} }
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
return return
@@ -189,6 +190,7 @@ func handleChatCompact(w http.ResponseWriter, r *http.Request) {
code := 503 code := 503
if err == ErrBusy { if err == ErrBusy {
code = 409 code = 409
err = conv.busyReason()
} }
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
return return
+12
View File
@@ -118,6 +118,9 @@ func newWebMux() *http.ServeMux {
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher // recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
// plus rien du tout. // plus rien du tout.
lcRestoreOnce.Do(lcRestore) lcRestoreOnce.Do(lcRestore)
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
StartTaskScheduler()
mux := http.NewServeMux() mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la // Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé. // donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -230,6 +233,15 @@ func newWebMux() *http.ServeMux {
api("/api/mcp/tool", handleMCPTool) api("/api/mcp/tool", handleMCPTool)
api("/api/mcp/test", handleMCPTest) api("/api/mcp/test", handleMCPTest)
api("/api/memory", handleMemoryMode) api("/api/memory", handleMemoryMode)
// Tâches planifiées : l'IA exécute des consignes toute seule sur une fréquence
// réglable (tasks.go). Le planificateur tourne dans CE process — celui qui
// détient la conversation et parle au moteur.
api("/api/tasks", handleTasks)
api("/api/tasks/save", handleTaskSave)
api("/api/tasks/delete", handleTaskDelete)
api("/api/tasks/toggle", handleTaskToggle)
api("/api/tasks/pause", handleTasksPause)
api("/api/tasks/run", handleTaskRun)
api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows) api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
api("/api/prefs", handleWebPrefs) api("/api/prefs", handleWebPrefs)
+181
View File
@@ -0,0 +1,181 @@
// web_tasks.go — endpoints de pilotage des tâches planifiées (voir tasks.go).
// Tous derrière la clé de pilotage (enregistrés via api(...) dans newWebMux).
package loki
import (
"encoding/json"
"net/http"
"strings"
"time"
)
// handleTasks : GET → liste des tâches + état de l'interrupteur maître + si le
// mode agent est actif (sans lui, une tâche n'a aucun outil pour agir).
//
// Les horodatages (next_run, last_run) partent en epoch ms BRUTS : c'est le
// navigateur qui les met en forme, dans le fuseau de l'utilisateur. Les formater
// ici les figerait dans le fuseau du SERVEUR (souvent UTC), d'où un décalage
// horaire à l'écran.
func handleTasks(w http.ResponseWriter, r *http.Request) {
tasks := listTasks()
conv.mu.Lock()
runningID := conv.runningTaskID
conv.mu.Unlock()
// Presets (id + nom + actif) pour peupler le sélecteur du formulaire de tâche.
presets := []map[string]any{}
if list, err := ListPresets(); err == nil {
for _, p := range list {
presets = append(presets, map[string]any{"id": p.ID, "name": p.Name, "active": p.Active})
}
}
sendJSON(w, 200, map[string]any{
"ok": true,
"tasks": tasks,
"paused": tasksPaused(),
"agent": agentEnabled(),
"running_id": runningID,
"presets": presets,
// État global mémoire/web, pour proposer des défauts cohérents à la création.
"mem_on": memMode() != MemOff,
"web_on": internetEnabled() && crawlReachable(),
})
}
// handleTaskSave crée ou met à jour une tâche. Valide le schedule (400 sinon) et
// recalcule NextRun. Un ID vide = création.
func handleTaskSave(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Prompt string `json:"prompt"`
Schedule string `json:"schedule"`
TZ string `json:"tz"`
Preset string `json:"preset"`
NoMem bool `json:"no_mem"`
NoWeb bool `json:"no_web"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
req.Prompt = strings.TrimSpace(req.Prompt)
req.Schedule = strings.TrimSpace(req.Schedule)
if req.Name == "" || req.Prompt == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom et consigne obligatoires"})
return
}
if err := validateSchedule(req.Schedule, req.TZ); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "fréquence invalide : " + err.Error()})
return
}
var t Task
if req.ID != "" {
if cur, ok := getTask(req.ID); ok {
t = cur // conserve l'historique d'exécution (LastRun/LastReport…)
} else {
t.ID = req.ID
}
}
if t.ID == "" {
t.ID = newTaskID()
}
t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled
t.TZ, t.Preset = req.TZ, req.Preset
t.NoMem, t.NoWeb = req.NoMem, req.NoWeb
// Recalcule NextRun si la fréquence a changé (ou à la création).
if t.Schedule != req.Schedule || t.NextRun == 0 {
t.NextRun = computeNextRun(req.Schedule, req.TZ, time.Now())
}
t.Schedule = req.Schedule
if err := saveTask(t); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "id": t.ID})
}
// handleTaskDelete supprime une tâche.
func handleTaskDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.ID == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "id manquant"})
return
}
if err := deleteTask(req.ID); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleTaskToggle active/désactive UNE tâche. En l'activant, on (re)pose son
// NextRun pour qu'elle reparte proprement.
func handleTaskToggle(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
On bool `json:"on"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
t, ok := getTask(req.ID)
if !ok {
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
return
}
t.Enabled = req.On
if req.On {
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
}
if err := saveTask(t); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleTasksPause pilote l'interrupteur maître ({on:true} = suspendre tout).
func handleTasksPause(w http.ResponseWriter, r *http.Request) {
var req struct {
On bool `json:"on"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := setTasksPaused(req.On); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "paused": tasksPaused()})
}
// handleTaskRun lance une tâche MAINTENANT (bouton « tester »). Exécution
// détachée : on rend la main tout de suite et on rafraîchit l'état à la fin.
// 409 si une génération est déjà en cours.
func handleTaskRun(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
t, ok := getTask(req.ID)
if !ok {
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
return
}
// Vérifie la disponibilité AVANT de détacher, pour pouvoir répondre 409/503.
conv.mu.Lock()
busy := conv.Generating
conv.mu.Unlock()
if busy {
sendJSON(w, 409, map[string]any{"ok": false, "error": ErrBusy.Error()})
return
}
if !healthCheck() {
sendJSON(w, 503, map[string]any{"ok": false, "error": errModelLoading.Error()})
return
}
go runTask(t)
sendJSON(w, 200, map[string]any{"ok": true})
}