mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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.
182 lines
5.5 KiB
Go
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})
|
|
}
|