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

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

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

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

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

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

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

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

182 lines
5.5 KiB
Go

package loki
// code_mode.go — le mode Chat/Code d'une discussion. Bascule EXPLICITE
// (sélecteur dans le pied du composeur), persistée par discussion ; en mode
// chat, une détection côté serveur peut SUGGÉRER la bascule (puce ignorable
// dans l'UI) mais ne l'impose jamais — un faux positif coûterait des tours de
// planification/vérification sur un simple bonjour.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
func modeKey(convID string) string { return "mode:" + convID }
func hintKey(convID string) string { return "hinted:" + convID }
// convCodeMode : la discussion active est-elle en mode code ?
func convCodeMode() bool { return getStr(bkChat, modeKey(convEnsureActive())) == "code" }
func setConvMode(convID, mode string) error {
if mode != "code" {
mode = "" // chat = défaut = clé absente
}
return putStr(bkChat, modeKey(convID), mode)
}
// dropConvMode : appelé à la suppression d'une discussion.
func dropConvMode(convID string) {
_ = putStr(bkChat, modeKey(convID), "")
_ = putStr(bkChat, hintKey(convID), "")
}
// --- Détection « ça ressemble à du code » ------------------------------------
// Côté serveur, AUCUN token : ni consigne dans le prompt, ni appel modèle.
// Un indice suffit ; la puce de l'UI est gratuite à ignorer.
var codeHintRes = []*regexp.Regexp{
// Un bloc de code clôturé.
regexp.MustCompile("```"),
// Un chemin de fichier source.
regexp.MustCompile(`(?i)\b[\w./\\-]+\.(go|py|js|jsx|ts|tsx|rs|java|kt|c|h|cpp|hpp|cs|rb|php|swift|sh|ps1|sql|html|css|scss|vue|svelte|toml|yaml|yml|json|dockerfile|makefile)\b`),
// Verbes / vocabulaire de tâche de code.
regexp.MustCompile(`(?i)\b(corrige|implémente|implemente|refactor|refactorise|débogue|debug|compile|stack ?trace|segfault|exception|traceback|unit ?test|pull ?request|merge|commit|clone)\b`),
regexp.MustCompile(`(?i)\b(bug|erreur de compilation|tests? (échoue|cassé)|fonction|classe|module|api rest|endpoint)\b`),
}
// looksLikeCode dit si un message utilisateur ressemble à une tâche de code.
// Le dépôt git déjà cloné dans la discussion est aussi un indice fort.
func looksLikeCode(text string) bool {
if strings.TrimSpace(text) == "" {
return false
}
for _, re := range codeHintRes {
if re.MatchString(text) {
return true
}
}
return false
}
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
func convHasRepo() bool {
ws := agentCwd()
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
return true
}
entries, err := os.ReadDir(ws)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(ws, e.Name(), ".git")); err == nil {
return true
}
}
return false
}
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
func maybeCodeHint(c *Conversation, epoch int, text string) {
if convCodeMode() || !agentEnabled() {
return
}
id := convEnsureActive()
if getBool(bkChat, hintKey(id)) {
return
}
if !looksLikeCode(text) && !convHasRepo() {
return
}
_ = putBool(bkChat, hintKey(id), true)
c.appendDelta(epoch, map[string]any{"code_hint": true})
}
// --- API ---------------------------------------------------------------------
// handleChatMode — GET : {mode, criteria} de la discussion active ;
// POST {"mode":"code"|"chat"} : bascule (et pousse le nouvel état aux abonnés).
func handleChatMode(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if body.Mode != "code" && body.Mode != "chat" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu (chat|code)"})
return
}
id := convEnsureActive()
if err := setConvMode(id, body.Mode); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
// suivre, comme le fil lui-même).
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
return
}
mode := "chat"
if convCodeMode() {
mode = "code"
}
sendJSON(w, 200, map[string]any{
"ok": true,
"mode": mode,
"criteria": critList(convEnsureActive()),
})
}
// handleChatCriteria — POST : édition manuelle des critères depuis l'UI.
// {"criteria":[{"id":1,"text":"…","status":"pending"},…]} remplace la liste.
func handleChatCriteria(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
return
}
var body struct {
Criteria []Criterion `json:"criteria"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
var clean []Criterion
next := 1
for _, c := range body.Criteria {
if strings.TrimSpace(c.Text) == "" || len(clean) >= critMaxCount {
continue
}
if c.ID <= 0 {
c.ID = next
}
if c.ID >= next {
next = c.ID + 1
}
switch c.Status {
case "pending", "passed", "failed":
default:
c.Status = "pending"
}
clean = append(clean, c)
}
critSave(convEnsureActive(), clean)
critNotify(clean)
sendJSON(w, 200, map[string]any{"ok": true, "criteria": clean})
}