Files
Loki/internal/loki/chat_workspace.go
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

120 lines
4.1 KiB
Go

package loki
import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
// Dossier de travail du mode agent.
//
// Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS :
// le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a
// lancé loki — le Bureau quand on double-clique l'installateur, C:\ProgramData\loki\bin
// quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer
// des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher.
//
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
// elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine
// reste la borne de sécurité des téléchargements.
const workspaceEnv = "LOKI_WORKSPACE"
var (
workspaceOnce sync.Once
workspacePath string
)
// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il
// essaie plusieurs emplacements car LokiHome() vaut %ProgramData%\loki sous
// Windows, qui n'est pas inscriptible par un utilisateur non administrateur.
func agentWorkspace() string {
workspaceOnce.Do(func() {
for _, dir := range workspaceCandidates() {
if dir == "" {
continue
}
if err := os.MkdirAll(dir, 0o755); err != nil {
continue
}
// MkdirAll réussit sur un dossier existant même non inscriptible :
// on vérifie l'écriture réelle.
f, err := os.CreateTemp(dir, ".probe-*")
if err != nil {
continue
}
name := f.Name()
f.Close()
os.Remove(name)
workspacePath = dir
return
}
// Dernier recours : le répertoire courant, comportement historique.
workspacePath, _ = os.Getwd()
})
return workspacePath
}
func workspaceCandidates() []string {
var c []string
if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" {
c = append(c, v)
}
c = append(c, workspaceDir())
if home, err := os.UserHomeDir(); err == nil && home != "" {
c = append(c, filepath.Join(home, "loki", "workspace"))
}
c = append(c, filepath.Join(os.TempDir(), "loki-workspace"))
return c
}
// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée
// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses
// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte
// — et changeraient de place en cours de route si l'utilisateur en changeait.
//
// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas
// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois
// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent
// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des
// messages — continue de passer par convWorkspace() et reste donc sur sa
// discussion, même pendant qu'une tâche écrit ailleurs.
var taskWS atomic.Value // string
func setTaskWorkspace(dir string) { taskWS.Store(dir) }
// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en
// cours s'il y en a une, sinon celui de la discussion ouverte.
func agentCwd() string {
if d, _ := taskWS.Load().(string); d != "" {
return d
}
return convWorkspace()
}
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
// discussion ouverte.
func resolveAgentPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return p
}
if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, filepath.FromSlash(strings.TrimPrefix(p[1:], "/")))
}
}
if filepath.IsAbs(p) {
return p
}
return filepath.Join(agentCwd(), filepath.FromSlash(p))
}