mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Tâches : scripts planifiés sans modèle, et l'IA planifie la sienne
Deux reprises d'AJEAN autour des tâches planifiées. Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory, hors du workspace. Le workspace est jetable — supprimer une discussion emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses scripts normalement. Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script sans charger le modèle ni consommer un token, sa sortie devient le compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne hors du verrou de génération — d'où un registre à part pour l'afficher « en cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale. Outils task_list/create/update/delete : l'IA se donne elle-même rappels et veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000 caractères, avec le palier documenté dans le test. Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et c'est la condition d'un chiffrement de la mémoire à venir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
This commit is contained in:
17 files changed
+1043
-26
No files matched your search
@@ -139,7 +139,8 @@ et modèles compris.
|
||||
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
@@ -215,7 +216,15 @@ Héritées d'AJEAN :
|
||||
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.
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
|
||||
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
|
||||
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
|
||||
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
|
||||
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
|
||||
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **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
|
||||
|
||||
@@ -26,10 +26,27 @@ import (
|
||||
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
|
||||
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
|
||||
// actions — quatre outils auraient coûté quatre fois ça.
|
||||
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
|
||||
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
|
||||
// passer par l'interface. Descriptions déjà au plus court, et un
|
||||
// seul schéma par action (create/update/delete ne partagent pas
|
||||
// assez d'arguments pour fusionner sans rendre chacun ambigu).
|
||||
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
|
||||
// Playwright est présent — c'est le cas dans l'image, pas sur le
|
||||
// runner de CI : le budget doit tenir pour l'image, la vraie.
|
||||
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
|
||||
// schémas n'existent que si on arme son interrupteur.)
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne
|
||||
// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 8600 // ~2150 tokens, tout allumé
|
||||
// Les ~200 car de prompt qui les accompagnent sont le seul écart
|
||||
// assumé à la règle « pas de relèvement pour du prompt » : un outil
|
||||
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
|
||||
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
|
||||
// et qu'un script planifié ne charge aucun modèle). Elle a été
|
||||
// ramenée de trois lignes à une.
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
|
||||
// pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package loki
|
||||
|
||||
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
|
||||
//
|
||||
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
|
||||
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
|
||||
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
|
||||
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
|
||||
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
|
||||
// fichiers temporaires).
|
||||
//
|
||||
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
|
||||
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
|
||||
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
|
||||
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
|
||||
// protéger d'une suppression.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
|
||||
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
|
||||
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
|
||||
func scriptsPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom de script vide")
|
||||
}
|
||||
// On interdit les chemins absolus et la remontée : un script vit dans
|
||||
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
|
||||
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
|
||||
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
|
||||
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
|
||||
}
|
||||
// Caractères hostiles au shell : le nom finit dans une ligne de commande
|
||||
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
|
||||
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
|
||||
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
|
||||
// partout rend le comportement identique sur les deux plateformes. Un nom de
|
||||
// fichier n'a de toute façon besoin d'aucun de ces caractères.
|
||||
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
|
||||
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), clean)
|
||||
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
|
||||
if !underDir(full, scriptsDir()) {
|
||||
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
|
||||
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
|
||||
func scriptExists(name string) error {
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("script introuvable : %s", name)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s est un dossier, pas un script", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
|
||||
// leur taille), triés. Ignore les dossiers.
|
||||
func listScripts() ([]scriptInfo, error) {
|
||||
root := scriptsDir()
|
||||
var out []scriptInfo
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier absent ou illisible : on renvoie ce qu'on a
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
size := int64(0)
|
||||
if fi, ferr := d.Info(); ferr == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, err
|
||||
}
|
||||
|
||||
type scriptInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
|
||||
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
|
||||
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
|
||||
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
|
||||
// sont pas concernés par ces gardes.
|
||||
//
|
||||
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
|
||||
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
|
||||
// d'avec le workspace jetable, pas d'un verrou.
|
||||
func toolOnlyDirs() []string { return []string{memoryDir()} }
|
||||
|
||||
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
|
||||
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
|
||||
func toolOnlyLabel() string {
|
||||
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
|
||||
}
|
||||
|
||||
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
|
||||
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
|
||||
func guardToolOnlyPath(path string) string {
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if underDir(path, d) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
|
||||
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
|
||||
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
|
||||
func guardToolOnlyCommand(command string) string {
|
||||
lc := strings.ToLower(command)
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if strings.Contains(lc, strings.ToLower(normPath(d))) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
|
||||
// (sous Windows) insensibilité à la casse.
|
||||
func underDir(path, dir string) bool {
|
||||
a := normPath(path)
|
||||
b := normPath(dir)
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(a, b+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
|
||||
// et minusculé sous Windows (système de fichiers insensible à la casse).
|
||||
func normPath(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
if runtime.GOOS == "windows" {
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/
|
||||
// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre.
|
||||
func TestGuardToolOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Shell : toute mention du dossier memory est refusée (lecture comprise). Le
|
||||
// dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute).
|
||||
blockedCmds := []string{
|
||||
"cat " + memoryDir() + "/note.md",
|
||||
"echo x > " + memoryDir() + "/x",
|
||||
}
|
||||
for _, c := range blockedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg == "" {
|
||||
t.Errorf("commande aurait dû être refusée : %q", c)
|
||||
}
|
||||
}
|
||||
allowedCmds := []string{
|
||||
"ls " + workspaceDir(),
|
||||
"bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé
|
||||
"ls " + scriptsDir(),
|
||||
}
|
||||
for _, c := range allowedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg != "" {
|
||||
t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non.
|
||||
if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" {
|
||||
t.Errorf("écriture directe dans memory aurait dû être refusée")
|
||||
}
|
||||
for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} {
|
||||
if msg := guardToolOnlyPath(p); msg != "" {
|
||||
t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni
|
||||
// glisser un caractère hostile au shell dans le nom.
|
||||
func TestScriptsPathEscape(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"}
|
||||
for _, name := range bad {
|
||||
if _, err := scriptsPath(name); err == nil {
|
||||
t.Errorf("aurait dû refuser le nom : %q", name)
|
||||
}
|
||||
}
|
||||
good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"}
|
||||
for _, name := range good {
|
||||
p, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
t.Errorf("aurait dû accepter %q : %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) {
|
||||
t.Errorf("%q résolu hors de scriptsDir : %s", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptExists couvre la validation d'existence utilisée par les tâches script.
|
||||
func TestScriptExists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
if err := scriptExists("hello.sh"); err == nil {
|
||||
t.Fatalf("un script absent aurait dû échouer")
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), "hello.sh")
|
||||
if err := os.MkdirAll(scriptsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("hello.sh"); err != nil {
|
||||
t.Fatalf("scriptExists : %v", err)
|
||||
}
|
||||
list, _ := listScripts()
|
||||
if len(list) != 1 || list[0].Name != "hello.sh" {
|
||||
t.Fatalf("list inattendu : %+v", list)
|
||||
}
|
||||
// Un dossier n'est pas un script.
|
||||
if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("d"); err == nil {
|
||||
t.Fatalf("un dossier ne doit pas passer pour un script")
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,13 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
|
||||
// à connaître pour le modèle — juste [texte](chemin).
|
||||
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
|
||||
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
|
||||
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
|
||||
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
|
||||
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
|
||||
// disent pas. Une ligne, pas trois : le budget du préambule est compté
|
||||
// (TestSystemPromptStaysLean).
|
||||
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
|
||||
}
|
||||
@@ -180,7 +187,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
||||
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
||||
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.")
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -195,6 +202,14 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
|
||||
// délai (5 minutes au maximum), bouton stop sans effet.
|
||||
func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
|
||||
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
|
||||
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
|
||||
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
|
||||
// n'avait pas servie.
|
||||
if msg := guardToolOnlyCommand(command); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
}
|
||||
@@ -277,6 +292,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
@@ -315,6 +334,10 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
|
||||
@@ -336,6 +336,12 @@ func EnabledTools(caps Caps) []Tool {
|
||||
// n'archive des blocs QUE quand le mode agent est actif — annoncer recall
|
||||
// sans lui donnerait un outil qui ne peut rien trouver.
|
||||
tools = append(tools, recallTool(), recallSearchTool())
|
||||
// Tâches planifiées : l'IA se donne elle-même des rappels et des veilles
|
||||
// récurrentes, et gère les siennes (tasks_tools.go). Cloisonnées par
|
||||
// projet : dans un projet, elle ne voit et ne pilote QUE ses tâches.
|
||||
// Réservé au mode agent — une tâche sans outils pour agir n'aurait rien
|
||||
// à livrer.
|
||||
tools = append(tools, taskListTool(), taskCreateTool(), taskUpdateTool(), taskDeleteTool())
|
||||
}
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
@@ -1445,6 +1451,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = capWebOutput(toolWebGrep(args))
|
||||
case "web_screenshot":
|
||||
result = toolWebScreenshot(args, caps)
|
||||
case "task_list":
|
||||
result = toolTaskList(args)
|
||||
case "task_create":
|
||||
result = toolTaskCreate(args)
|
||||
case "task_update":
|
||||
result = toolTaskUpdate(args)
|
||||
case "task_delete":
|
||||
result = toolTaskDelete(args)
|
||||
case "browser_open":
|
||||
result = capCUOutput(toolCUOpen(args))
|
||||
case "browser_snapshot":
|
||||
|
||||
@@ -243,6 +243,12 @@ func memoryDir() string { return projectMemoryDir(activeProjectSlug()) }
|
||||
func modelsDir() string { return filepath.Join(LokiHome(), "models") }
|
||||
func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") }
|
||||
|
||||
// scriptsDir est le dossier des scripts de l'agent : à côté de memory/presets,
|
||||
// HORS du workspace. Le workspace est un bac à sable jetable (clones, tests,
|
||||
// fichiers d'une discussion) qu'un nettoyage — ou la suppression de la
|
||||
// discussion — peut raser ; les scripts qu'on veut CONSERVER vivent ici.
|
||||
func scriptsDir() string { return filepath.Join(LokiHome(), "scripts") }
|
||||
|
||||
// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est
|
||||
// uiUnitName (« loki-ui »), qui sert l'interface et le tunnel.
|
||||
func serviceName() string {
|
||||
|
||||
@@ -22,6 +22,7 @@ type lokiPaths struct {
|
||||
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
|
||||
Workspace string `json:"workspace"` // dossier de travail du mode agent
|
||||
Memory string `json:"memory"`
|
||||
Scripts string `json:"scripts"` // scripts durables de l'agent (hors workspace jetable)
|
||||
Presets string `json:"presets"`
|
||||
Models string `json:"models"`
|
||||
Backends string `json:"backends"`
|
||||
@@ -50,6 +51,7 @@ func currentPaths() lokiPaths {
|
||||
Installed: installedExePath(),
|
||||
Workspace: agentWorkspace(),
|
||||
Memory: memoryDir(),
|
||||
Scripts: scriptsDir(),
|
||||
Presets: presetsDir(),
|
||||
Models: modelsDir(),
|
||||
Backends: backendsDir(),
|
||||
@@ -66,6 +68,7 @@ func cmdWhere(args []string) error {
|
||||
{"binaire installé", p.Installed},
|
||||
{"travail de l'agent", p.Workspace},
|
||||
{"mémoire", p.Memory},
|
||||
{"scripts", p.Scripts},
|
||||
{"presets", p.Presets},
|
||||
{"modèles", p.Models},
|
||||
{"backends", p.Backends},
|
||||
|
||||
+23
-2
@@ -31,6 +31,12 @@ type Task struct {
|
||||
// chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide
|
||||
// sur les tâches d'avant les projets : traitées comme « Générale ».
|
||||
Project string `json:"project,omitempty"`
|
||||
// Kind distingue une tâche IA d'une tâche script. Vide ou "agent" = Prompt est
|
||||
// une consigne exécutée par le modèle (RunAutonomous). "script" = on lance
|
||||
// directement le script Script du dossier protégé, SANS charger le modèle ni
|
||||
// consommer de tokens. Le zéro JSON ("") garde le comportement historique (IA).
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Script string `json:"script,omitempty"` // pour Kind=="script" : nom du script dans scriptsDir()
|
||||
// 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
|
||||
@@ -75,9 +81,11 @@ func listTasks() []Task {
|
||||
}
|
||||
|
||||
// taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut :
|
||||
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire.
|
||||
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. Un
|
||||
// projet SUPPRIMÉ depuis retombe de même sur « Générale » : sans ça, la tâche
|
||||
// restait rattachée à un slug fantôme et n'apparaissait plus dans aucun projet.
|
||||
func taskProjectOf(t Task) string {
|
||||
if s := strings.TrimSpace(t.Project); s != "" {
|
||||
if s := strings.TrimSpace(t.Project); s != "" && projectExists(s) {
|
||||
return s
|
||||
}
|
||||
return defaultProjectSlug
|
||||
@@ -101,6 +109,19 @@ func tagOrphanTasks(slug string) {
|
||||
}
|
||||
}
|
||||
|
||||
// listTasksForProject ne renvoie que les tâches rattachées au projet `slug`
|
||||
// (mêmes tri et résolution que listTasks). Sert à cloisonner les outils task_*
|
||||
// exposés à l'IA : dans un projet, elle ne voit et ne pilote QUE ses tâches.
|
||||
func listTasksForProject(slug string) []Task {
|
||||
var out []Task
|
||||
for _, t := range listTasks() {
|
||||
if taskProjectOf(t) == slug {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getTask lit une tâche par ID. Renvoie false si absente.
|
||||
func getTask(id string) (Task, bool) {
|
||||
var t Task
|
||||
|
||||
@@ -149,6 +149,14 @@ const reportMax = 4000
|
||||
func runTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
|
||||
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
|
||||
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
|
||||
if t.Kind == "script" {
|
||||
runScriptTask(t)
|
||||
return
|
||||
}
|
||||
|
||||
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
|
||||
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
|
||||
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package loki
|
||||
|
||||
// tasks_script.go — exécution d'une tâche « script seul » : on lance un script du
|
||||
// dossier scriptsDir(), SANS charger le modèle ni consommer de tokens. La sortie
|
||||
// du script devient le compte-rendu, exactement comme pour une tâche IA, si bien
|
||||
// que l'UI et les outils task_* l'affichent sans traitement particulier.
|
||||
//
|
||||
// Une tâche script ne passe PAS par le verrou de génération de la conversation
|
||||
// (elle n'utilise pas le modèle), donc conv.runningTaskID ne la voit pas. On tient
|
||||
// donc un petit registre à part (scriptRunning) pour que l'UI l'affiche « en
|
||||
// cours » et puisse l'arrêter.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptTaskTimeout borne l'exécution d'un script planifié. Généreux (un backup ou
|
||||
// une synchro peut durer), mais fini : une tâche coincée ne doit pas bloquer le
|
||||
// planificateur à vie.
|
||||
const scriptTaskTimeout = 10 * time.Minute
|
||||
|
||||
// scriptRunning suit les tâches script en cours (id → cancel + nom), pour que
|
||||
// handleTasks les signale « en cours » et que l'arrêt (/api/tasks/stop) puisse
|
||||
// annuler leur contexte.
|
||||
var scriptRunning = struct {
|
||||
mu sync.Mutex
|
||||
cancel map[string]context.CancelFunc
|
||||
name map[string]string
|
||||
}{cancel: map[string]context.CancelFunc{}, name: map[string]string{}}
|
||||
|
||||
func scriptRunBegin(id, name string, cancel context.CancelFunc) {
|
||||
scriptRunning.mu.Lock()
|
||||
scriptRunning.cancel[id] = cancel
|
||||
scriptRunning.name[id] = name
|
||||
scriptRunning.mu.Unlock()
|
||||
}
|
||||
|
||||
func scriptRunEnd(id string) {
|
||||
scriptRunning.mu.Lock()
|
||||
delete(scriptRunning.cancel, id)
|
||||
delete(scriptRunning.name, id)
|
||||
scriptRunning.mu.Unlock()
|
||||
}
|
||||
|
||||
// scriptRunningAny renvoie l'id et le nom d'une tâche script en cours (la
|
||||
// première trouvée), ou "","" si aucune. Le scheduler ne lance qu'une tâche à la
|
||||
// fois, donc en pratique il y en a au plus une.
|
||||
func scriptRunningAny() (string, string) {
|
||||
scriptRunning.mu.Lock()
|
||||
defer scriptRunning.mu.Unlock()
|
||||
for id, n := range scriptRunning.name {
|
||||
return id, n
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// scriptRunStop annule la tâche script en cours d'id `id`. Renvoie true si une
|
||||
// exécution correspondante a été trouvée et annulée.
|
||||
func scriptRunStop(id string) bool {
|
||||
scriptRunning.mu.Lock()
|
||||
c := scriptRunning.cancel[id]
|
||||
scriptRunning.mu.Unlock()
|
||||
if c != nil {
|
||||
c()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask
|
||||
// pour une tâche IA), mais sans aucune inférence.
|
||||
func runScriptTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
name := strings.TrimSpace(t.Script)
|
||||
if name == "" {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("aucun script associé à la tâche"))
|
||||
return
|
||||
}
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
recordTaskEnd(t.ID, start, "", err)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("script introuvable : %s", name))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scriptTaskTimeout)
|
||||
defer cancel()
|
||||
// Registre : rend la tâche visible « en cours » dans l'UI et arrêtable via
|
||||
// /api/tasks/stop (qui appelle scriptRunStop → cancel).
|
||||
scriptRunBegin(t.ID, t.Name, cancel)
|
||||
defer scriptRunEnd(t.ID)
|
||||
|
||||
// On réutilise runShell : garde-fous, dossier de travail, troncature de sortie
|
||||
// et gestion du timeout y sont déjà. La sortie formatée ("exit: N ...") sert à
|
||||
// la fois de compte-rendu et de signal de succès.
|
||||
out := runShell(ctx, scriptRunCommand(full), int(scriptTaskTimeout/time.Second))
|
||||
switch {
|
||||
case ctx.Err() == context.Canceled:
|
||||
// Arrêt volontaire (bouton stop) : recordTaskEnd l'affiche « interrompue »
|
||||
// sans allumer l'indicateur d'échec.
|
||||
recordTaskEnd(t.ID, start, "", context.Canceled)
|
||||
case strings.HasPrefix(out, "exit: 0"):
|
||||
recordTaskEnd(t.ID, start, out, nil)
|
||||
default:
|
||||
// Sortie non nulle, timeout ou erreur de lancement : échec, avec la sortie
|
||||
// complète comme message d'erreur.
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("%s", out))
|
||||
}
|
||||
}
|
||||
|
||||
// scriptRunCommand construit la ligne de commande qui exécute le script `path`
|
||||
// selon la plateforme et l'extension. Sous Unix, on lance le fichier directement
|
||||
// (il est écrit en 0755, le shebang choisit l'interpréteur). Sous Windows, un .ps1
|
||||
// passe par PowerShell ; le reste (.bat/.cmd/.exe) se lance tel quel via cmd. Le
|
||||
// nom de script est déjà bridé par scriptsPath (pas de guillemet ni de métacaractère
|
||||
// shell), donc l'entourer de guillemets suffit.
|
||||
func scriptRunCommand(path string) string {
|
||||
q := `"` + path + `"`
|
||||
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".ps1") {
|
||||
return `powershell -NoProfile -ExecutionPolicy Bypass -File ` + q
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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": "\"@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)"},
|
||||
},
|
||||
"required": []string{"name", "schedule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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à.
|
||||
func defaultTaskTZ() string {
|
||||
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"]))
|
||||
if name == "" || schedule == "" {
|
||||
return "[erreur] name et schedule sont obligatoires"
|
||||
}
|
||||
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)"
|
||||
}
|
||||
tz := defaultTaskTZ()
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -3127,11 +3127,34 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type de tâche : une consigne exécutée par l'IA, ou un script du dossier
|
||||
protégé lancé SEUL — sans charger le modèle ni consommer de tokens.
|
||||
Une sauvegarde, une synchro, un nettoyage n'ont rien à demander à un
|
||||
LLM ; les faire passer par lui coûtait un chargement de modèle. -->
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Type</div>
|
||||
<div class="pe-seg">
|
||||
<label><input type="radio" name="task-kind" value="agent" onchange="taskKindUI('agent')"><span>Consigne IA</span></label>
|
||||
<label><input type="radio" name="task-kind" value="script" onchange="taskKindUI('script')"><span>Script seul</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group" id="task-prompt-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" id="task-script-group" style="display:none">
|
||||
<div class="pe-gh">Script</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">À exécuter</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-script"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack plain"><div id="task-script-hint" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Preset</div>
|
||||
<div class="pe-list">
|
||||
@@ -9526,6 +9549,8 @@ 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_PROJECTS = []; // projets disponibles (sélecteur « projet visé »)
|
||||
let TASK_SCRIPTS = []; // scripts durables disponibles (tâche « script seul »)
|
||||
let TASK_SCRIPTS_DIR = ''; // chemin du dossier de scripts, pour l'aide affichée
|
||||
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;
|
||||
@@ -9549,6 +9574,8 @@ function renderTasks(r){
|
||||
const agentOn = !!(r && r.agent);
|
||||
TASK_PRESETS = (r && r.presets) || [];
|
||||
TASK_PROJECTS = (r && r.projects) || [];
|
||||
TASK_SCRIPTS = (r && r.scripts) || [];
|
||||
TASK_SCRIPTS_DIR = (r && r.scripts_dir) || 'le dossier de scripts';
|
||||
TASK_MEM_ON = !r || r.mem_on !== false;
|
||||
TASK_WEB_ON = !r || r.web_on !== false;
|
||||
TASK_RUNNING = (r && r.running_id) || '';
|
||||
@@ -9708,6 +9735,9 @@ function openTask(id){
|
||||
// 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;
|
||||
// Type : IA par défaut. Une tâche script n'a ni consigne ni preset utile.
|
||||
setTaskKind(t && t.kind === 'script' ? 'script' : 'agent');
|
||||
fillScriptSelect(t ? (t.script||'') : '');
|
||||
fillPresetSelect(t ? (t.preset||'') : '');
|
||||
// Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une
|
||||
// tâche pour le chantier qu'on a sous les yeux.
|
||||
@@ -9798,6 +9828,51 @@ function fillTaskProjectSelect(selected){
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
}
|
||||
|
||||
// fillScriptSelect peuple le sélecteur de script avec les fichiers du dossier
|
||||
// protégé (LOKI_HOME/scripts). Dossier vide = on le dit et on laisse le
|
||||
// sélecteur vide : l'enregistrement sera refusé côté serveur, autant l'annoncer.
|
||||
function fillScriptSelect(selected){
|
||||
const sel = document.getElementById('task-script');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
TASK_SCRIPTS.forEach(sc=>{
|
||||
const o = document.createElement('option'); o.value = sc.name;
|
||||
o.textContent = sc.name + (sc.size ? ' · ' + fmtSize(sc.size) : '');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
const hint = document.getElementById('task-script-hint');
|
||||
if(hint){
|
||||
hint.textContent = TASK_SCRIPTS.length
|
||||
? 'Scripts de ' + TASK_SCRIPTS_DIR + ' — l\'IA peut y écrire les siens.'
|
||||
: 'Aucun script dans ' + TASK_SCRIPTS_DIR + ' : demande à l\'IA d\'y en écrire un, ou dépose-le toi-même.';
|
||||
}
|
||||
}
|
||||
|
||||
// setTaskKind bascule le formulaire entre « consigne IA » et « script seul ».
|
||||
function setTaskKind(kind){
|
||||
const r = document.querySelector('input[name="task-kind"][value="'+kind+'"]');
|
||||
if(r) r.checked = true;
|
||||
taskKindUI(kind);
|
||||
}
|
||||
|
||||
// taskKindUI n'affiche que les champs du type choisi : une tâche script n'a pas
|
||||
// de consigne, et le preset ne la concerne pas (aucun modèle n'est chargé).
|
||||
function taskKindUI(kind){
|
||||
const script = kind === 'script';
|
||||
const pg = document.getElementById('task-prompt-group');
|
||||
const sg = document.getElementById('task-script-group');
|
||||
if(pg) pg.style.display = script ? 'none' : '';
|
||||
if(sg) sg.style.display = script ? '' : 'none';
|
||||
const pr = document.getElementById('task-preset');
|
||||
if(pr) pr.closest('.pe-row').style.display = script ? 'none' : '';
|
||||
}
|
||||
|
||||
function taskKind(){
|
||||
const r = document.querySelector('input[name="task-kind"]:checked');
|
||||
return (r && r.value === 'script') ? 'script' : 'agent';
|
||||
}
|
||||
|
||||
function setTaskFreqMode(mode){
|
||||
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
|
||||
if(r) r.checked = true;
|
||||
@@ -9833,6 +9908,8 @@ function buildSchedule(){
|
||||
|
||||
async function saveTask(){
|
||||
const name = document.getElementById('task-name').value.trim();
|
||||
const kind = taskKind();
|
||||
const script = (document.getElementById('task-script')||{}).value || '';
|
||||
const prompt = document.getElementById('task-prompt').value.trim();
|
||||
const schedule = buildSchedule();
|
||||
const enabled = document.getElementById('task-enabled').checked;
|
||||
@@ -9842,9 +9919,13 @@ async function saveTask(){
|
||||
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(!name){ st.textContent = 'nom obligatoire'; st.style.color = 'var(--err)'; return; }
|
||||
if(kind === 'script' ? !script : !prompt){
|
||||
st.textContent = kind === 'script' ? 'choisis un script' : 'consigne obligatoire';
|
||||
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, project, tz, no_mem, no_web});
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, kind, script, prompt, schedule, enabled, preset, project, tz, no_mem, no_web});
|
||||
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
|
||||
@@ -926,11 +926,34 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type de tâche : une consigne exécutée par l'IA, ou un script du dossier
|
||||
protégé lancé SEUL — sans charger le modèle ni consommer de tokens.
|
||||
Une sauvegarde, une synchro, un nettoyage n'ont rien à demander à un
|
||||
LLM ; les faire passer par lui coûtait un chargement de modèle. -->
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Type</div>
|
||||
<div class="pe-seg">
|
||||
<label><input type="radio" name="task-kind" value="agent" onchange="taskKindUI('agent')"><span>Consigne IA</span></label>
|
||||
<label><input type="radio" name="task-kind" value="script" onchange="taskKindUI('script')"><span>Script seul</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group" id="task-prompt-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" id="task-script-group" style="display:none">
|
||||
<div class="pe-gh">Script</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">À exécuter</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-script"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack plain"><div id="task-script-hint" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Preset</div>
|
||||
<div class="pe-list">
|
||||
|
||||
@@ -12,6 +12,8 @@ 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_PROJECTS = []; // projets disponibles (sélecteur « projet visé »)
|
||||
let TASK_SCRIPTS = []; // scripts durables disponibles (tâche « script seul »)
|
||||
let TASK_SCRIPTS_DIR = ''; // chemin du dossier de scripts, pour l'aide affichée
|
||||
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;
|
||||
@@ -35,6 +37,8 @@ function renderTasks(r){
|
||||
const agentOn = !!(r && r.agent);
|
||||
TASK_PRESETS = (r && r.presets) || [];
|
||||
TASK_PROJECTS = (r && r.projects) || [];
|
||||
TASK_SCRIPTS = (r && r.scripts) || [];
|
||||
TASK_SCRIPTS_DIR = (r && r.scripts_dir) || 'le dossier de scripts';
|
||||
TASK_MEM_ON = !r || r.mem_on !== false;
|
||||
TASK_WEB_ON = !r || r.web_on !== false;
|
||||
TASK_RUNNING = (r && r.running_id) || '';
|
||||
@@ -194,6 +198,9 @@ function openTask(id){
|
||||
// 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;
|
||||
// Type : IA par défaut. Une tâche script n'a ni consigne ni preset utile.
|
||||
setTaskKind(t && t.kind === 'script' ? 'script' : 'agent');
|
||||
fillScriptSelect(t ? (t.script||'') : '');
|
||||
fillPresetSelect(t ? (t.preset||'') : '');
|
||||
// Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une
|
||||
// tâche pour le chantier qu'on a sous les yeux.
|
||||
@@ -284,6 +291,51 @@ function fillTaskProjectSelect(selected){
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
}
|
||||
|
||||
// fillScriptSelect peuple le sélecteur de script avec les fichiers du dossier
|
||||
// protégé (LOKI_HOME/scripts). Dossier vide = on le dit et on laisse le
|
||||
// sélecteur vide : l'enregistrement sera refusé côté serveur, autant l'annoncer.
|
||||
function fillScriptSelect(selected){
|
||||
const sel = document.getElementById('task-script');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
TASK_SCRIPTS.forEach(sc=>{
|
||||
const o = document.createElement('option'); o.value = sc.name;
|
||||
o.textContent = sc.name + (sc.size ? ' · ' + fmtSize(sc.size) : '');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
const hint = document.getElementById('task-script-hint');
|
||||
if(hint){
|
||||
hint.textContent = TASK_SCRIPTS.length
|
||||
? 'Scripts de ' + TASK_SCRIPTS_DIR + ' — l\'IA peut y écrire les siens.'
|
||||
: 'Aucun script dans ' + TASK_SCRIPTS_DIR + ' : demande à l\'IA d\'y en écrire un, ou dépose-le toi-même.';
|
||||
}
|
||||
}
|
||||
|
||||
// setTaskKind bascule le formulaire entre « consigne IA » et « script seul ».
|
||||
function setTaskKind(kind){
|
||||
const r = document.querySelector('input[name="task-kind"][value="'+kind+'"]');
|
||||
if(r) r.checked = true;
|
||||
taskKindUI(kind);
|
||||
}
|
||||
|
||||
// taskKindUI n'affiche que les champs du type choisi : une tâche script n'a pas
|
||||
// de consigne, et le preset ne la concerne pas (aucun modèle n'est chargé).
|
||||
function taskKindUI(kind){
|
||||
const script = kind === 'script';
|
||||
const pg = document.getElementById('task-prompt-group');
|
||||
const sg = document.getElementById('task-script-group');
|
||||
if(pg) pg.style.display = script ? 'none' : '';
|
||||
if(sg) sg.style.display = script ? '' : 'none';
|
||||
const pr = document.getElementById('task-preset');
|
||||
if(pr) pr.closest('.pe-row').style.display = script ? 'none' : '';
|
||||
}
|
||||
|
||||
function taskKind(){
|
||||
const r = document.querySelector('input[name="task-kind"]:checked');
|
||||
return (r && r.value === 'script') ? 'script' : 'agent';
|
||||
}
|
||||
|
||||
function setTaskFreqMode(mode){
|
||||
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
|
||||
if(r) r.checked = true;
|
||||
@@ -319,6 +371,8 @@ function buildSchedule(){
|
||||
|
||||
async function saveTask(){
|
||||
const name = document.getElementById('task-name').value.trim();
|
||||
const kind = taskKind();
|
||||
const script = (document.getElementById('task-script')||{}).value || '';
|
||||
const prompt = document.getElementById('task-prompt').value.trim();
|
||||
const schedule = buildSchedule();
|
||||
const enabled = document.getElementById('task-enabled').checked;
|
||||
@@ -328,9 +382,13 @@ async function saveTask(){
|
||||
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(!name){ st.textContent = 'nom obligatoire'; st.style.color = 'var(--err)'; return; }
|
||||
if(kind === 'script' ? !script : !prompt){
|
||||
st.textContent = kind === 'script' ? 'choisis un script' : 'consigne obligatoire';
|
||||
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, project, tz, no_mem, no_web});
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, kind, script, prompt, schedule, enabled, preset, project, tz, no_mem, no_web});
|
||||
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
|
||||
@@ -263,6 +263,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/tasks/toggle", handleTaskToggle)
|
||||
api("/api/tasks/pause", handleTasksPause)
|
||||
api("/api/tasks/run", handleTaskRun)
|
||||
api("/api/tasks/stop", handleTaskStop) // arrête la tâche en cours (script : via son registre)
|
||||
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)
|
||||
@@ -304,13 +305,13 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/conversations/switch", handleConvSwitch)
|
||||
api("/api/conversations/rename", handleConvRename)
|
||||
api("/api/conversations/delete", handleConvDelete)
|
||||
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
|
||||
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
|
||||
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/tool-result", handleToolResult) // résultat complet d'un outil, chargé à la demande (bouton « voir plus »)
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@ func handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
conv.mu.Lock()
|
||||
runningID := conv.runningTaskID
|
||||
conv.mu.Unlock()
|
||||
// Une tâche script tourne HORS du verrou de génération (aucune inférence) :
|
||||
// conv ne la voit pas, on complète donc avec le registre des scripts en cours
|
||||
// (tasks_script.go), sinon l'UI l'affiche à l'arrêt pendant qu'elle tourne.
|
||||
if runningID == "" {
|
||||
if sid, _ := scriptRunningAny(); sid != "" {
|
||||
runningID = sid
|
||||
}
|
||||
}
|
||||
// Presets (id + nom + actif) pour peupler le sélecteur du formulaire de tâche.
|
||||
presets := []map[string]any{}
|
||||
if list, err := ListPresets(); err == nil {
|
||||
@@ -28,13 +36,20 @@ func handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
presets = append(presets, map[string]any{"id": p.ID, "name": p.Name, "active": p.Active})
|
||||
}
|
||||
}
|
||||
// Scripts durables disponibles, pour le sélecteur d'une tâche « script seul ».
|
||||
scripts, _ := listScripts()
|
||||
if scripts == nil {
|
||||
scripts = []scriptInfo{}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"tasks": tasks,
|
||||
"paused": tasksPaused(),
|
||||
"agent": agentEnabled(),
|
||||
"running_id": runningID,
|
||||
"presets": presets,
|
||||
"ok": true,
|
||||
"tasks": tasks,
|
||||
"scripts": scripts,
|
||||
"scripts_dir": scriptsDir(),
|
||||
"paused": tasksPaused(),
|
||||
"agent": agentEnabled(),
|
||||
"running_id": runningID,
|
||||
"presets": presets,
|
||||
// Projets, pour le sélecteur « projet visé » du formulaire de tâche.
|
||||
"projects": projectDTOs(),
|
||||
// État global mémoire/web, pour proposer des défauts cohérents à la création.
|
||||
@@ -54,6 +69,8 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
TZ string `json:"tz"`
|
||||
Preset string `json:"preset"`
|
||||
Project string `json:"project"`
|
||||
Kind string `json:"kind"`
|
||||
Script string `json:"script"`
|
||||
NoMem bool `json:"no_mem"`
|
||||
NoWeb bool `json:"no_web"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -65,8 +82,26 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
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"})
|
||||
req.Kind = strings.TrimSpace(req.Kind)
|
||||
req.Script = strings.TrimSpace(req.Script)
|
||||
if req.Name == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom obligatoire"})
|
||||
return
|
||||
}
|
||||
// Une tâche script exige un script EXISTANT (le vérifier ici évite une tâche
|
||||
// planifiée qui échouera silencieusement au premier tic) ; une tâche IA exige
|
||||
// une consigne.
|
||||
if req.Kind == "script" {
|
||||
if req.Script == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "script obligatoire pour une tâche script"})
|
||||
return
|
||||
}
|
||||
if err := scriptExists(req.Script); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else if req.Prompt == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "consigne obligatoire"})
|
||||
return
|
||||
}
|
||||
if err := validateSchedule(req.Schedule, req.TZ); err != nil {
|
||||
@@ -87,6 +122,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled
|
||||
t.TZ, t.Preset = req.TZ, req.Preset
|
||||
t.Kind, t.Script = req.Kind, req.Script
|
||||
// Projet visé : vide = le projet actif au moment de l'enregistrement. Une tâche
|
||||
// créée depuis un projet appartient à ce projet — c'est là que sa mémoire et
|
||||
// ses trackers l'attendent quand elle tournera (voir runTask).
|
||||
@@ -162,6 +198,22 @@ func handleTasksPause(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": tasksPaused()})
|
||||
}
|
||||
|
||||
// handleTaskStop arrête la tâche en cours. Une tâche SCRIPT ne passe pas par le
|
||||
// verrou de génération : on l'annule via son registre (tasks_script.go). Sinon
|
||||
// (tâche IA), on retombe sur conv.Stop, qui annule la génération autonome.
|
||||
func handleTaskStop(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.ID != "" && scriptRunStop(req.ID) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
conv.Stop()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -175,6 +227,14 @@ func handleTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
|
||||
return
|
||||
}
|
||||
// Tâche script : aucune inférence, donc ni verrou de génération ni moteur
|
||||
// chargé à attendre — la refuser parce que le modèle répond ailleurs n'aurait
|
||||
// aucun sens.
|
||||
if t.Kind == "script" {
|
||||
go runTask(t)
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
// Vérifie la disponibilité AVANT de détacher, pour pouvoir répondre 409/503.
|
||||
conv.mu.Lock()
|
||||
busy := conv.Generating
|
||||
|
||||
Reference in new issue
Block a user