mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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 <noreply@anthropic.com>
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package loki
|
|
|
|
// tasks_tools.go — outils task_* exposés au modèle pour qu'il gère LUI-MÊME ses
|
|
// tâches planifiées (voir tasks.go) : se donner un rappel récurrent, une veille,
|
|
// un ménage périodique… puis les lister, les ajuster ou les supprimer. Ils
|
|
// réutilisent exactement l'infra de l'UI (validateSchedule, computeNextRun,
|
|
// saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les
|
|
// autres dans le panneau Tâches.
|
|
//
|
|
// Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si
|
|
// l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour
|
|
// livrer son résultat.
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// str extrait une chaîne d'un argument d'outil décodé en any ("" si absent ou
|
|
// d'un autre type).
|
|
func taskStrArg(v any) string {
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
func taskListTool() Tool {
|
|
return Tool{
|
|
Type: "function",
|
|
Function: ToolFunction{
|
|
Name: "task_list",
|
|
Description: "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string", "description": "Show only this task, with its full last report"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func taskCreateTool() Tool {
|
|
return Tool{
|
|
Type: "function",
|
|
Function: ToolFunction{
|
|
Name: "task_create",
|
|
Description: "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"name": map[string]any{"type": "string"},
|
|
"prompt": map[string]any{"type": "string", "description": "AI task: self-contained instruction to run each time (omit if using script)"},
|
|
"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": "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"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func taskUpdateTool() Tool {
|
|
return Tool{
|
|
Type: "function",
|
|
Function: ToolFunction{
|
|
Name: "task_update",
|
|
Description: "Change one or more fields of a scheduled task by id.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
"name": map[string]any{"type": "string"},
|
|
"prompt": map[string]any{"type": "string"},
|
|
"schedule": map[string]any{"type": "string"},
|
|
"enabled": map[string]any{"type": "boolean", "description": "false pauses, true resumes"},
|
|
},
|
|
"required": []string{"id"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func taskDeleteTool() Tool {
|
|
return Tool{
|
|
Type: "function",
|
|
Function: ToolFunction{
|
|
Name: "task_delete",
|
|
Description: "Delete a scheduled task by id.",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"id"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// toolTaskList formate les tâches pour le modèle. Avec un id, on ne montre QUE
|
|
// cette tâche, compte-rendu COMPLET (le cas « donne-moi le compte-rendu de la
|
|
// tâche X ») ; sans id, la liste complète avec un aperçu court par tâche.
|
|
func toolTaskList(args map[string]any) string {
|
|
proj := activeProjectSlug()
|
|
tasks := listTasksForProject(proj)
|
|
if id := strings.TrimSpace(taskStrArg(args["id"])); id != "" {
|
|
// On ne révèle que les tâches du projet actif : une tâche d'un autre projet
|
|
// est traitée comme inexistante (même message que si l'id était faux).
|
|
t, ok := getTask(id)
|
|
if !ok || taskProjectOf(t) != proj {
|
|
return "[erreur] tâche introuvable : " + id
|
|
}
|
|
return formatTask(t, true)
|
|
}
|
|
if len(tasks) == 0 {
|
|
return "[aucune tâche planifiée dans ce projet]"
|
|
}
|
|
var b strings.Builder
|
|
for _, t := range tasks {
|
|
b.WriteString(formatTask(t, false) + "\n")
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
// formatTask rend une tâche. full=true : compte-rendu entier (sur ses propres
|
|
// lignes) ; full=false : aperçu court d'une ligne, pour une liste scannable.
|
|
func formatTask(t Task, full bool) string {
|
|
var b strings.Builder
|
|
state := "off"
|
|
if t.Enabled {
|
|
state = "on"
|
|
}
|
|
fmt.Fprintf(&b, "- %s [%s] « %s » — %s", t.ID, state, t.Name, t.Schedule)
|
|
if t.NextRun > 0 && t.Enabled {
|
|
fmt.Fprintf(&b, " (prochaine : %s)", time.UnixMilli(t.NextRun).In(loc(t.TZ)).Format("2006-01-02 15:04"))
|
|
}
|
|
if t.LastRun > 0 {
|
|
when := time.UnixMilli(t.LastRun).In(loc(t.TZ)).Format("2006-01-02 15:04")
|
|
if t.LastOK {
|
|
fmt.Fprintf(&b, " ✓ dernier run %s", when)
|
|
} else {
|
|
fmt.Fprintf(&b, " ✗ dernier run %s : %s", when, t.LastError)
|
|
}
|
|
if r := strings.TrimSpace(t.LastReport); r != "" {
|
|
if full {
|
|
// Compte-rendu ENTIER (déjà borné à 4000 car au stockage), tel quel,
|
|
// sur ses propres lignes — c'est ce que l'utilisateur demande.
|
|
b.WriteString("\n compte-rendu complet :\n" + r)
|
|
} else {
|
|
b.WriteString("\n compte-rendu : " + previewReport(r))
|
|
}
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// previewReport aplati + tronque le compte-rendu pour l'aperçu de la liste (un
|
|
// rapport entier par tâche gonflerait la sortie). Le texte complet s'obtient via
|
|
// task_list avec l'id de la tâche.
|
|
func previewReport(s string) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
const max = 300
|
|
if len(s) > max {
|
|
return s[:max] + " […] (id pour le compte-rendu complet)"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// toolTaskCreate crée une tâche depuis un appel d'outil du modèle.
|
|
func toolTaskCreate(args map[string]any) string {
|
|
name := strings.TrimSpace(taskStrArg(args["name"]))
|
|
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 obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)"
|
|
}
|
|
kind := ""
|
|
if script != "" {
|
|
// Tâche script : le script doit exister dans le dossier protégé.
|
|
kind = "script"
|
|
if err := scriptExists(script); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
} else if prompt == "" {
|
|
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)"
|
|
}
|
|
if err := validateSchedule(schedule, tz); err != nil {
|
|
return "[erreur] fréquence invalide : " + err.Error()
|
|
}
|
|
enabled := true
|
|
if v, ok := args["enabled"].(bool); ok {
|
|
enabled = v
|
|
}
|
|
t := Task{
|
|
ID: newTaskID(), Name: name, Prompt: prompt, Schedule: schedule,
|
|
TZ: tz, Enabled: enabled, Kind: kind, Script: script,
|
|
// Rattache la tâche au projet actif : l'IA ne crée que DANS son projet,
|
|
// comme elle n'y voit et n'y pilote que ses propres tâches.
|
|
Project: activeProjectSlug(),
|
|
}
|
|
t.NextRun = computeNextRun(schedule, tz, time.Now())
|
|
if err := saveTask(t); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
when := ""
|
|
if t.NextRun > 0 && enabled {
|
|
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)
|
|
}
|
|
|
|
// toolTaskUpdate modifie une tâche existante (partiel).
|
|
func toolTaskUpdate(args map[string]any) string {
|
|
id := strings.TrimSpace(taskStrArg(args["id"]))
|
|
if id == "" {
|
|
return "[erreur] id manquant"
|
|
}
|
|
t, ok := getTask(id)
|
|
if !ok || taskProjectOf(t) != activeProjectSlug() {
|
|
return "[erreur] tâche introuvable : " + id
|
|
}
|
|
if v, ok := args["name"].(string); ok && strings.TrimSpace(v) != "" {
|
|
t.Name = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := args["prompt"].(string); ok && strings.TrimSpace(v) != "" {
|
|
t.Prompt = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := args["schedule"].(string); ok && strings.TrimSpace(v) != "" {
|
|
sched := strings.TrimSpace(v)
|
|
if err := validateSchedule(sched, t.TZ); err != nil {
|
|
return "[erreur] fréquence invalide : " + err.Error()
|
|
}
|
|
t.Schedule = sched
|
|
t.NextRun = computeNextRun(sched, t.TZ, time.Now())
|
|
}
|
|
if v, ok := args["enabled"].(bool); ok {
|
|
t.Enabled = v
|
|
if v && t.NextRun == 0 {
|
|
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
|
|
}
|
|
}
|
|
if err := saveTask(t); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
return fmt.Sprintf("[ok] tâche '%s' (id %s) mise à jour", t.Name, t.ID)
|
|
}
|
|
|
|
// toolTaskDelete supprime une tâche.
|
|
func toolTaskDelete(args map[string]any) string {
|
|
id := strings.TrimSpace(taskStrArg(args["id"]))
|
|
if id == "" {
|
|
return "[erreur] id manquant"
|
|
}
|
|
t, ok := getTask(id)
|
|
if !ok || taskProjectOf(t) != activeProjectSlug() {
|
|
return "[erreur] tâche introuvable : " + id
|
|
}
|
|
if err := deleteTask(id); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
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
|
|
}
|