mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 11:49:43 +02:00
Avec LOKI_CHAT=rust, changer de projet passait par projectSwitch du Go, qui enregistrait SA copie de la discussion — chargée au démarrage, périmée — par-dessus celle de la façade, ouvrait une autre discussion que la façade ignorait, et ne voyait pas un tour de la façade en cours. Désormais : - /api/projects (switch) remet le changement à la façade (/internal/facade/v1/project/switch) : elle enregistre sa discussion avec le projet quitté, fait changer le projet actif au Go (/internal/v1/project/activate), ouvre la plus récente du nouveau projet (une neuve s'il n'en a pas) ; refusé pendant un tour (409) ; - supprimer un projet : la façade le quitte d'abord s'il est actif, puis supprime ses discussions (/internal/facade/v1/project/drop). Réglages que la façade ne sait pas honorer (PROJ_SNAPSHOT, KEEP_TURN_IMAGES, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs retry_patterns) : le Go garde la conversation, décidé une fois au démarrage (facadeChatBlocker) ; la façade le lit (/internal/v1/chat/holder) avant de servir quoi que ce soit, au lieu d'échouer en plein tour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
153 lines
4.9 KiB
Go
153 lines
4.9 KiB
Go
package loki
|
|
|
|
// web_projects.go — l'API du hub des projets. Un seul endpoint verbe-orienté
|
|
// (/api/projects) plutôt qu'une route par action : le hub fait six choses sur le
|
|
// même objet, six routes n'auraient rien clarifié.
|
|
//
|
|
// Toutes les mutations passent par projects.go, qui porte les invariants (slug
|
|
// unique, dernier projet indestructible, réassignation de l'actif).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// projectDTO est la vue d'un projet renvoyée à l'UI : le registre, plus ce qui
|
|
// se compte (discussions, pages, trackers) pour que le hub soit lisible sans
|
|
// ouvrir chaque projet.
|
|
type projectDTO struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Desc string `json:"desc,omitempty"`
|
|
Created int64 `json:"created"`
|
|
Convs int `json:"convs"`
|
|
Pages int `json:"pages"`
|
|
Trackers int `json:"trackers"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
func projectDTOs() []projectDTO {
|
|
active := activeProjectSlug()
|
|
// Un seul parcours de l'index des discussions pour tous les projets : compter
|
|
// projet par projet relirait la base autant de fois qu'il y a de projets.
|
|
convs := map[string]int{}
|
|
for _, m := range convIndex() {
|
|
convs[convProjectOf(m)]++
|
|
}
|
|
out := make([]projectDTO, 0, len(listProjects()))
|
|
for _, p := range listProjects() {
|
|
d := projectDTO{
|
|
Slug: p.Slug, Name: p.Name, Desc: p.Desc, Created: p.CreatedAt,
|
|
Convs: convs[p.Slug], Trackers: len(trackerKeysForProject(p.Slug)),
|
|
Active: p.Slug == active,
|
|
}
|
|
// Les pages se comptent dans le dossier du projet : on emprunte memoryDir
|
|
// le temps du comptage plutôt que de dupliquer la lecture du dossier.
|
|
// MEMORY.md est exclu — c'est l'index du projet, pas une page de contenu :
|
|
// l'annoncer ferait afficher « 1 page » sur un projet tout neuf et vide.
|
|
withProject(p.Slug, func() {
|
|
for _, m := range MemList() {
|
|
if !isIndexFile(m.Name) {
|
|
d.Pages++
|
|
}
|
|
}
|
|
})
|
|
out = append(out, d)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handleProjects :
|
|
//
|
|
// GET → {ok, projects, active}
|
|
// POST {action, ...} → create | rename | desc | delete | switch
|
|
func handleProjects(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Action string `json:"action"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Desc string `json:"desc"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(body.Slug)
|
|
var err error
|
|
switch strings.ToLower(strings.TrimSpace(body.Action)) {
|
|
case "create":
|
|
_, err = createProject(body.Name)
|
|
case "rename":
|
|
err = renameProject(slug, body.Name)
|
|
case "desc":
|
|
err = setProjectDesc(slug, body.Desc)
|
|
case "delete":
|
|
// Une génération en cours travaille dans la mémoire du projet actif :
|
|
// supprimer sous ses pieds la laisserait écrire dans le vide. La
|
|
// façade, quand elle tient la conversation, refuse elle-même (409).
|
|
if conv.isGenerating() {
|
|
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
|
return
|
|
}
|
|
err = deleteProject(slug)
|
|
case "switch":
|
|
if conv.isGenerating() {
|
|
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
|
return
|
|
}
|
|
if facadeChat() {
|
|
if !projectExists(slug) {
|
|
err = fmt.Errorf("projet introuvable")
|
|
} else {
|
|
err = facadeProjectSwitch(slug)
|
|
}
|
|
} else {
|
|
err = projectSwitch(slug)
|
|
}
|
|
default:
|
|
err = fmt.Errorf("action inconnue : %q", body.Action)
|
|
}
|
|
if err != nil {
|
|
code := 400
|
|
var fe *facadeError
|
|
if errors.As(err, &fe) && fe.status == http.StatusConflict {
|
|
code = http.StatusConflict
|
|
}
|
|
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()})
|
|
default:
|
|
sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"})
|
|
}
|
|
}
|
|
|
|
// handleMemMove déplace une page mémoire vers un autre projet. Séparé de
|
|
// /api/projects : c'est une opération sur une PAGE, pas sur un projet.
|
|
func handleMemMove(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
To string `json:"to"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
if err := moveMemPage(body.Name, activeProjectSlug(), strings.TrimSpace(body.To)); err != nil {
|
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
sendJSON(w, 200, map[string]any{"ok": true})
|
|
}
|