From 2bbf0ba76dddd68ba8574fbb97ae15eb31a283df Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:25:04 +0200 Subject: [PATCH] =?UTF-8?q?T=C3=A2ches=20:=20=C2=AB=20rappelle-moi=20dans?= =?UTF-8?q?=2020=20minutes=20=C2=BB=20sans=20cron,=20=C3=A0=20l'heure=20du?= =?UTF-8?q?=20navigateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris d'AJEAN 0.17.5. - task_create accepte in_minutes (dans N minutes) ou at (« HH:MM », prochaine occurrence, ou « AAAA-MM-JJ HH:MM ») pour une tâche unique, calculée côté serveur : le modèle n'a plus à écrire un cron ni à jongler avec les fuseaux pour un simple rappel. Elle s'écrit « @once AAAA-MM-JJ HH:MM » et se désactive après son passage ; manquée pendant un arrêt, elle part au redémarrage. - Le navigateur envoie son fuseau (Europe/Paris…) avec chaque message ; il est retenu et sert aux tâches que l'IA planifie. Le conteneur tourne en UTC : « à 17h » tombait deux heures à côté. La réponse de task_create nomme le fuseau utilisé. Co-Authored-By: Claude Opus 5.5 --- internal/loki/tasks_cron.go | 17 +++++++ internal/loki/tasks_once_test.go | 68 +++++++++++++++++++++++++++ internal/loki/tasks_run.go | 3 ++ internal/loki/tasks_tools.go | 70 ++++++++++++++++++++++++---- internal/loki/ui/index.html | 5 +- internal/loki/ui/src/js/09-stream.js | 5 +- internal/loki/web_api.go | 3 ++ internal/loki/web_chat.go | 1 + 8 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 internal/loki/tasks_once_test.go diff --git a/internal/loki/tasks_cron.go b/internal/loki/tasks_cron.go index d1e88f2..29f87d1 100644 --- a/internal/loki/tasks_cron.go +++ b/internal/loki/tasks_cron.go @@ -45,11 +45,22 @@ func loc(tz string) *time.Location { // - "@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. +// - "@once 2026-10-01 16:54" : une seule fois, à cette date et heure murale. 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, "@once"); ok { + at, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(rest), loc(tz)) + if err != nil { + return time.Time{}, fmt.Errorf("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)") + } + if !at.After(from) { + return time.Time{}, fmt.Errorf("%s est déjà passé (il est %s)", at.Format("2006-01-02 15:04"), from.In(loc(tz)).Format("2006-01-02 15:04")) + } + return at, nil + } if rest, ok := cutPrefix(s, "@every"); ok { return nextEvery(strings.TrimSpace(rest), tz, from) } @@ -249,3 +260,9 @@ func cutPrefix(s, prefix string) (string, bool) { } return "", false } + +// isOnce : tâche à exécution unique (« @once … »), désactivée après son passage. +func isOnce(schedule string) bool { + _, ok := cutPrefix(strings.TrimSpace(schedule), "@once") + return ok +} diff --git a/internal/loki/tasks_once_test.go b/internal/loki/tasks_once_test.go new file mode 100644 index 0000000..f7f7eb5 --- /dev/null +++ b/internal/loki/tasks_once_test.go @@ -0,0 +1,68 @@ +package loki + +import ( + "strings" + "testing" + "time" +) + +// « @once » : une date future est acceptée, une date passée refusée. +func TestScheduleOnce(t *testing.T) { + from := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + at, err := nextAfter("@once 2026-10-02 12:30", "UTC", from) + if err != nil || !at.Equal(from.Add(30*time.Minute)) { + t.Fatalf("@once futur : %v, %v", at, err) + } + if _, err := nextAfter("@once 2026-10-02 11:00", "UTC", from); err == nil { + t.Fatal("@once passé accepté") + } + if !isOnce(" @ONCE 2026-10-02 12:30") || isOnce("@every 2h") { + t.Fatal("isOnce se trompe") + } +} + +// L'IA crée un rappel « dans 20 minutes » ou « à HH:MM » sans écrire de cron, +// dans le fuseau du navigateur ; une fois passée, la tâche se désactive. +func TestTacheUniqueParLIA(t *testing.T) { + testHome(t) + rememberUserTZ("Europe/Paris") + if got := defaultTaskTZ(); got != "Europe/Paris" { + t.Fatalf("fuseau retenu = %q", got) + } + rememberUserTZ("Pas/Un_Fuseau") // invalide : ignoré + if got := defaultTaskTZ(); got != "Europe/Paris" { + t.Fatalf("fuseau invalide retenu : %q", got) + } + + out := toolTaskCreate(map[string]any{"name": "rappel", "prompt": "dis bonjour", "in_minutes": float64(20)}) + if !strings.HasPrefix(out, "[ok]") || !strings.Contains(out, "Europe/Paris") { + t.Fatalf("création « dans 20 min » : %s", out) + } + out = toolTaskCreate(map[string]any{"name": "apéro", "prompt": "rappelle l'apéro", "at": "18:30"}) + if !strings.HasPrefix(out, "[ok]") { + t.Fatalf("création « à 18:30 » : %s", out) + } + if out := toolTaskCreate(map[string]any{"name": "flou", "prompt": "x", "at": "demain"}); !strings.HasPrefix(out, "[erreur]") { + t.Fatalf("'at' invalide accepté : %s", out) + } + + var once Task + for _, tk := range listTasks() { + if tk.Name == "rappel" { + once = tk + } + } + if !isOnce(once.Schedule) || once.TZ != "Europe/Paris" || once.NextRun == 0 { + t.Fatalf("tâche unique mal posée : %+v", once) + } + want := time.Now().Add(20 * time.Minute) + if d := time.UnixMilli(once.NextRun).Sub(want); d > time.Minute || d < -time.Minute { + t.Fatalf("prochaine exécution à %v, attendu ≈ %v", time.UnixMilli(once.NextRun), want) + } + + recordTaskEnd(once.ID, time.Now(), "bonjour", nil) + after, _ := getTask(once.ID) + if after.Enabled || after.NextRun != 0 { + t.Fatalf("tâche unique encore active après son passage : %+v", after) + } +} diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 8a780ae..8aa21f0 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -266,6 +266,9 @@ func recordTaskEnd(id string, start time.Time, report string, err error) { cur.LastDurMs = now.Sub(start).Milliseconds() cur.LastRun = now.UnixMilli() cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now) + if isOnce(cur.Schedule) { + cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée + } 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. diff --git a/internal/loki/tasks_tools.go b/internal/loki/tasks_tools.go index 760e95a..f6e98e0 100644 --- a/internal/loki/tasks_tools.go +++ b/internal/loki/tasks_tools.go @@ -54,10 +54,12 @@ func taskCreateTool() Tool { "script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"}, // Le format du schedule est le seul point que le modèle ne peut pas // deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil. - "schedule": map[string]any{"type": "string", "description": "\"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\""}, - "enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"}, + "schedule": map[string]any{"type": "string", "description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)"}, + "in_minutes": map[string]any{"type": "integer", "description": "One-time task, N minutes from now"}, + "at": map[string]any{"type": "string", "description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\""}, + "enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"}, }, - "required": []string{"name", "schedule"}, + "required": []string{"name"}, }, }, } @@ -101,11 +103,30 @@ func taskDeleteTool() Tool { } } -// defaultTaskTZ : le modèle n'a pas le fuseau du navigateur. On reprend celui -// d'une tâche existante (posé par l'UI depuis le navigateur) pour que « tous les -// jours à 9h » tombe à la bonne heure ; à défaut, fuseau vide = heure locale du -// serveur (souvent UTC), ce que la description du planificateur assume déjà. +// stUserTZKey : fuseau IANA du navigateur de l'utilisateur, envoyé avec chaque +// message (voir rememberUserTZ). +const stUserTZKey = "user_tz" + +// rememberUserTZ retient le fuseau du navigateur (« Europe/Paris ») s'il est +// valide et a changé. Le conteneur tourne en UTC : sans ça, « rappelle-moi à +// 17h » tombait deux heures à côté (AJEAN 0.17.5). +func rememberUserTZ(tz string) { + tz = strings.TrimSpace(tz) + if tz == "" || tz == getStr(bkState, stUserTZKey) { + return + } + if _, err := time.LoadLocation(tz); err == nil { + _ = putStr(bkState, stUserTZKey, tz) + } +} + +// defaultTaskTZ : le fuseau de l'utilisateur pour ce que le modèle planifie. +// Celui du navigateur s'il est connu, sinon celui d'une tâche existante (posé +// par l'UI), sinon vide = heure locale du serveur (souvent UTC). func defaultTaskTZ() string { + if tz := getStr(bkState, stUserTZKey); tz != "" { + return tz + } for _, t := range listTasks() { if strings.TrimSpace(t.TZ) != "" { return t.TZ @@ -189,8 +210,30 @@ func toolTaskCreate(args map[string]any) string { prompt := strings.TrimSpace(taskStrArg(args["prompt"])) script := strings.TrimSpace(taskStrArg(args["script"])) schedule := strings.TrimSpace(taskStrArg(args["schedule"])) + tz := defaultTaskTZ() + // Tâche unique : « dans N minutes » ou « à HH:MM », calculés ICI dans le + // fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec + // les fuseaux ni le cron pour un simple rappel. + if schedule == "" { + now := time.Now().In(loc(tz)) + if v, ok := args["in_minutes"].(float64); ok && v >= 1 { + schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04") + } else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" { + if t, err := time.ParseInLocation("2006-01-02 15:04", at, loc(tz)); err == nil { + schedule = "@once " + t.Format("2006-01-02 15:04") + } else if hh, mm, err := parseHHMM(at); err == nil { + t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc(tz)) + if !t.After(now) { + t = t.AddDate(0, 0, 1) + } + schedule = "@once " + t.Format("2006-01-02 15:04") + } else { + return "[erreur] 'at' invalide : « HH:MM » ou « AAAA-MM-JJ HH:MM »" + } + } + } if name == "" || schedule == "" { - return "[erreur] name et schedule sont obligatoires" + return "[erreur] name obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)" } kind := "" if script != "" { @@ -202,7 +245,6 @@ func toolTaskCreate(args map[string]any) string { } else if prompt == "" { return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)" } - tz := defaultTaskTZ() if err := validateSchedule(schedule, tz); err != nil { return "[erreur] fréquence invalide : " + err.Error() } @@ -223,7 +265,7 @@ func toolTaskCreate(args map[string]any) string { } when := "" if t.NextRun > 0 && enabled { - when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + " (" + tzLabel(tz) + ")" } return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when) } @@ -279,3 +321,11 @@ func toolTaskDelete(args map[string]any) string { } return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id) } + +// tzLabel : nom du fuseau à montrer au modèle. +func tzLabel(tz string) string { + if tz == "" { + return "heure du serveur" + } + return tz +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index ef6b20b..1bfd47d 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -7658,6 +7658,9 @@ function loadFullHistory(){ // (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une // erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de // « network error » alarmiste alors que l'IA répond quand même. +// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC, +// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ). +const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })(); async function send(){ // Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici. if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; } @@ -7686,7 +7689,7 @@ async function send(){ if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent()); for(let attempt=0; attempt<3; attempt++){ try{ - const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid})}); + const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})}); if(r.ok) clearAttach(); // 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas. if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; } diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 2899a83..579f9d4 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -683,6 +683,9 @@ function loadFullHistory(){ // (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une // erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de // « network error » alarmiste alors que l'IA répond quand même. +// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC, +// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ). +const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })(); async function send(){ // Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici. if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; } @@ -711,7 +714,7 @@ async function send(){ if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent()); for(let attempt=0; attempt<3; attempt++){ try{ - const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid})}); + const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})}); if(r.ok) clearAttach(); // 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas. if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; } diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 8d83ed9..7830202 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -1353,6 +1353,9 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) { // le décodeur JSON ignore les champs qu'il ne connaît pas. type chatReq struct { Temperature float64 `json:"temperature"` + // TZ = fuseau IANA du navigateur (« Europe/Paris »), retenu pour dater ce + // que le modèle planifie. Voir rememberUserTZ. + TZ string `json:"tz"` // Surcharges par requête, portées par les agents ajean.link qui ont leurs // propres interrupteurs. nil = on prend la configuration de la machine. // ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody). diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go index 3df0100..e33f4aa 100644 --- a/internal/loki/web_chat.go +++ b/internal/loki/web_chat.go @@ -106,6 +106,7 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"}) return } + rememberUserTZ(body.TZ) // Génération en cours : au lieu de refuser, on MET EN FILE (AJEAN 0.14.0). // Le message est injecté dans la réponse en cours à la prochaine frontière // d'étape, ou traité comme tour suivant. queued=true le signale au client.