mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
25 files changed
+2441
-15
No files matched your search
@@ -169,6 +169,17 @@ Héritées d'AJEAN :
|
||||
`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
|
||||
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.
|
||||
- **É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
|
||||
|
||||
@@ -56,6 +56,12 @@ type Conversation struct {
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
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 {
|
||||
@@ -286,7 +292,14 @@ func (c *Conversation) state() map[string]any {
|
||||
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 :
|
||||
@@ -301,6 +314,20 @@ func (c *Conversation) isGenerating() bool {
|
||||
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
|
||||
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
|
||||
// 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.")
|
||||
|
||||
@@ -161,7 +161,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
cwd := convWorkspace()
|
||||
cwd := agentCwd()
|
||||
|
||||
var b strings.Builder
|
||||
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
|
||||
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
|
||||
// tout refuser.
|
||||
if ws := convWorkspace(); ws != "" {
|
||||
if ws := agentCwd(); ws != "" {
|
||||
if err := os.MkdirAll(ws, 0o755); err == nil {
|
||||
cmd.Dir = ws
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Dossier de travail du mode agent.
|
||||
@@ -21,7 +22,8 @@ import (
|
||||
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
|
||||
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
|
||||
// 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"
|
||||
|
||||
@@ -73,6 +75,30 @@ func workspaceCandidates() []string {
|
||||
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é ;
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
|
||||
// discussion ouverte.
|
||||
@@ -89,5 +115,5 @@ func resolveAgentPath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(convWorkspace(), filepath.FromSlash(p))
|
||||
return filepath.Join(agentCwd(), filepath.FromSlash(p))
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "git", args...)
|
||||
cmd.Dir = convWorkspace()
|
||||
cmd.Dir = agentCwd()
|
||||
// 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.
|
||||
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) {
|
||||
return "[erreur] dossier cible invalide"
|
||||
}
|
||||
target := filepath.Join(convWorkspace(), dir)
|
||||
target := filepath.Join(agentCwd(), dir)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "[erreur] le dossier " + dir + " existe déjà"
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func toolBashBg(args map[string]any) string {
|
||||
if reason := dangerousCommand(command); reason != "" {
|
||||
return refusedCommandResult(reason)
|
||||
}
|
||||
ws := convWorkspace()
|
||||
ws := agentCwd()
|
||||
logDir := filepath.Join(ws, ".loki", "jobs")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
|
||||
@@ -64,7 +64,7 @@ func looksLikeCode(text string) bool {
|
||||
|
||||
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
|
||||
func convHasRepo() bool {
|
||||
ws := convWorkspace()
|
||||
ws := agentCwd()
|
||||
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func refusedCommandResult(reason string) string {
|
||||
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode
|
||||
// code uniquement.
|
||||
func codePathAllowed(abs string) bool {
|
||||
ws := convWorkspace()
|
||||
ws := agentCwd()
|
||||
if ws == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func toolGrep(args map[string]any, codeMode bool) string {
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
root := convWorkspace()
|
||||
root := agentCwd()
|
||||
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
|
||||
root = resolveAgentPath(p)
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func toolGlob(args map[string]any, codeMode bool) string {
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
root := convWorkspace()
|
||||
root := agentCwd()
|
||||
re, err := globToRegexp(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
|
||||
@@ -128,7 +128,7 @@ func lspDiagnosticsFor(absPath string) string {
|
||||
if _, err := exec.LookPath(lang.Command[0]); err != nil {
|
||||
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 :
|
||||
// gopls a besoin du go.mod, tsserver du tsconfig.
|
||||
if r := projectRootFor(absPath, root); r != "" {
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
bkPrefs = "prefs" // préférences de l'UI web
|
||||
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
|
||||
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é.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
.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}
|
||||
|
||||
.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>
|
||||
<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
|
||||
@@ -2212,6 +2254,23 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
@@ -2703,6 +2762,106 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</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. 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 class="modal-card">
|
||||
<div class="modal-head">
|
||||
@@ -4014,7 +4173,7 @@ async function loadAll(){
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
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.
|
||||
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>
|
||||
@@ -182,6 +182,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
@@ -673,6 +690,106 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</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. 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 class="modal-card">
|
||||
<div class="modal-head">
|
||||
|
||||
@@ -451,7 +451,7 @@ async function loadAll(){
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
.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}
|
||||
|
||||
.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}
|
||||
@@ -107,6 +107,7 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
||||
code := 503
|
||||
if err == ErrBusy {
|
||||
code = 409
|
||||
err = conv.busyReason() // dit LAQUELLE des deux occupe le modèle
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
@@ -189,6 +190,7 @@ func handleChatCompact(w http.ResponseWriter, r *http.Request) {
|
||||
code := 503
|
||||
if err == ErrBusy {
|
||||
code = 409
|
||||
err = conv.busyReason()
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -118,6 +118,9 @@ func newWebMux() *http.ServeMux {
|
||||
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
|
||||
// plus rien du tout.
|
||||
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()
|
||||
// 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é.
|
||||
@@ -230,6 +233,15 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/mcp/tool", handleMCPTool)
|
||||
api("/api/mcp/test", handleMCPTest)
|
||||
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/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user