mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Façade : changement de projet sans écraser la discussion, détenteur décidé par le Go
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
This commit is contained in:
14 files changed
+655
-20
No files matched your search
@@ -29,7 +29,9 @@ import (
|
||||
// - les événements d'affichage nés ici (bascule du mode Code, critères) vont
|
||||
// dans SON journal ;
|
||||
// - l'export lit la discussion enregistrée ;
|
||||
// - le déverrouillage de la mémoire lui fait recharger la discussion.
|
||||
// - le déverrouillage de la mémoire lui fait recharger la discussion ;
|
||||
// - changer de projet, ou en supprimer un, passe par elle : la discussion
|
||||
// de ce process est périmée, l'enregistrer écraserait la sienne.
|
||||
|
||||
const (
|
||||
facadeChatEnv = "LOKI_CHAT"
|
||||
@@ -37,10 +39,85 @@ const (
|
||||
)
|
||||
|
||||
// facadeChat : la façade tient-elle la conversation ? Il faut aussi son
|
||||
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis.
|
||||
// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et
|
||||
// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker).
|
||||
func facadeChat() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == ""
|
||||
}
|
||||
|
||||
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
|
||||
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
|
||||
// figé, images gardées, slots du moteur, préchauffage, motifs de relance
|
||||
// personnalisés).
|
||||
func facadeChatBlocker(cfg map[string]string) string {
|
||||
switch {
|
||||
case projSnapEnabled(cfg):
|
||||
return "PROJ_SNAPSHOT"
|
||||
case keepTurnImagesOn(cfg):
|
||||
return "KEEP_TURN_IMAGES"
|
||||
case cfgOn(cfg, "NUDGE_IN_TOOL"):
|
||||
return "NUDGE_IN_TOOL"
|
||||
case sideSlotOn(cfg):
|
||||
return "SIDE_SLOT"
|
||||
case slotPersistOn(cfg):
|
||||
return "SLOT_PERSIST"
|
||||
case prewarmMode(cfg) != "":
|
||||
return "PREWARM"
|
||||
case strings.TrimSpace(getStr(bkState, "retry_patterns")) != "":
|
||||
return "retry_patterns"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cfgOn : une clé booléenne posée (mêmes valeurs que les autres clés opt-in).
|
||||
func cfgOn(cfg map[string]string, k string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg[k])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Décision prise UNE fois par process : passer la conversation d'un détenteur
|
||||
// à l'autre en cours de route laisserait chacun avec sa copie — un réglage
|
||||
// changé vaut au redémarrage suivant. La façade la lit au démarrage
|
||||
// (/internal/v1/chat/holder) et s'y range.
|
||||
var (
|
||||
facadeDecisionMu sync.Mutex
|
||||
facadeDecisionDone bool
|
||||
facadeDecisionWhy string
|
||||
)
|
||||
|
||||
func facadeChatDecision() string {
|
||||
facadeDecisionMu.Lock()
|
||||
defer facadeDecisionMu.Unlock()
|
||||
if !facadeDecisionDone {
|
||||
facadeDecisionWhy = facadeChatBlocker(ReadConfig())
|
||||
facadeDecisionDone = true
|
||||
if facadeDecisionWhy != "" {
|
||||
fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy)
|
||||
}
|
||||
}
|
||||
return facadeDecisionWhy
|
||||
}
|
||||
|
||||
// handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou
|
||||
// {"holder":"go","reason":…}). La façade s'y range au démarrage.
|
||||
func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
|
||||
return
|
||||
}
|
||||
if facadeChat() {
|
||||
sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"})
|
||||
return
|
||||
}
|
||||
why := facadeChatDecision()
|
||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") {
|
||||
why = facadeChatEnv + " absent"
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"holder": "go", "reason": why})
|
||||
}
|
||||
|
||||
func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) }
|
||||
@@ -120,6 +197,61 @@ func facadePost(path string, body any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// facadeError : refus de la façade, avec son statut (409 : un tour tourne).
|
||||
type facadeError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *facadeError) Error() string { return e.msg }
|
||||
|
||||
// facadeCall appelle l'API interne de la façade et rend son refus éventuel
|
||||
// tel quel (statut et message), pour les actions dont l'utilisateur attend
|
||||
// la réponse.
|
||||
func facadeCall(path string, body any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(internalTokenHeader, internalToken())
|
||||
// Délai large : enregistrer une discussion et en ouvrir une autre passe
|
||||
// par le disque (et par le chiffrement).
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("conversation indisponible : %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
var out struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out.Error == "" {
|
||||
out.Error = "façade : " + resp.Status
|
||||
}
|
||||
return &facadeError{status: resp.StatusCode, msg: out.Error}
|
||||
}
|
||||
|
||||
// facadeProjectSwitch : la façade enregistre la discussion du projet quitté
|
||||
// (avec son projet), fait changer le projet actif ici
|
||||
// (/internal/v1/project/activate), puis ouvre une discussion du nouveau.
|
||||
func facadeProjectSwitch(slug string) error {
|
||||
return facadeCall("/internal/facade/v1/project/switch", map[string]any{"slug": slug})
|
||||
}
|
||||
|
||||
// facadeDropProject : la façade supprime les discussions d'un projet (index,
|
||||
// contenu, et ce qui les entoure ici par /internal/v1/conv/dropped).
|
||||
func facadeDropProject(slug string) error {
|
||||
return facadeCall("/internal/facade/v1/project/drop", map[string]any{"slug": slug})
|
||||
}
|
||||
|
||||
// facadeDelta : un événement d'affichage né ici, journalisé par la façade
|
||||
// (tous les appareils le reçoivent). Rend false si la façade ne tient pas la
|
||||
// conversation : l'appelant journalise alors comme avant.
|
||||
|
||||
@@ -18,10 +18,27 @@ type fausseFacade struct {
|
||||
t *testing.T
|
||||
sse string
|
||||
reply string
|
||||
// Réponse de l'API interne (0 : 204 sans corps).
|
||||
internalStatus int
|
||||
internalBody string
|
||||
}
|
||||
|
||||
// oublierDecisionFacade : la décision « qui tient la conversation » est prise
|
||||
// une fois par process ; chaque test repart de zéro.
|
||||
func oublierDecisionFacade(t *testing.T) {
|
||||
t.Helper()
|
||||
reset := func() {
|
||||
facadeDecisionMu.Lock()
|
||||
facadeDecisionDone, facadeDecisionWhy = false, ""
|
||||
facadeDecisionMu.Unlock()
|
||||
}
|
||||
reset()
|
||||
t.Cleanup(reset)
|
||||
}
|
||||
|
||||
func demarrerFacade(t *testing.T) *fausseFacade {
|
||||
t.Helper()
|
||||
oublierDecisionFacade(t)
|
||||
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
@@ -35,7 +52,15 @@ func demarrerFacade(t *testing.T) *fausseFacade {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/internal/facade/") {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
f.mu.Lock()
|
||||
st, body := f.internalStatus, f.internalBody
|
||||
f.mu.Unlock()
|
||||
if st == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(st)
|
||||
io.WriteString(w, body)
|
||||
return
|
||||
}
|
||||
io.WriteString(w, f.reply)
|
||||
@@ -139,3 +164,142 @@ func TestFacadeEvenementsEtExport(t *testing.T) {
|
||||
t.Fatalf("export : %v %s", err, body)
|
||||
}
|
||||
}
|
||||
|
||||
// Changer de projet quand la façade tient la conversation : c'est elle qui
|
||||
// enregistre sa discussion et en ouvre une autre (la copie d'ici est périmée,
|
||||
// l'enregistrer écraserait la sienne). Son refus (tour en cours) remonte tel
|
||||
// quel ; un projet inconnu est refusé ici sans la déranger.
|
||||
func TestFacadeChangeDeProjet(t *testing.T) {
|
||||
testHome(t)
|
||||
ensureDefaultProject()
|
||||
p, err := createProject("Deux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := demarrerFacade(t)
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
before := activeProjectSlug()
|
||||
if w := post(`{"action":"switch","slug":"` + p.Slug + `"}`); w.Code != 200 {
|
||||
t.Fatalf("bascule : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := f.recu(); len(got) != 1 || got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+p.Slug+`"}` {
|
||||
t.Fatalf("reçu par la façade : %v", got)
|
||||
}
|
||||
// Le projet actif ne change que par la façade (/internal/v1/project/activate).
|
||||
if activeProjectSlug() != before {
|
||||
t.Fatalf("projet actif changé sans la façade : %s", activeProjectSlug())
|
||||
}
|
||||
if w := post(`{"action":"switch","slug":"inconnu"}`); w.Code != 400 || len(f.recu()) != 1 {
|
||||
t.Fatalf("projet inconnu : %d, façade %v", w.Code, f.recu())
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.internalStatus, f.internalBody = 409, `{"ok":false,"error":"une génération est en cours"}`
|
||||
f.mu.Unlock()
|
||||
w := post(`{"action":"switch","slug":"` + p.Slug + `"}`)
|
||||
if w.Code != 409 || !strings.Contains(w.Body.String(), "une génération est en cours") {
|
||||
t.Fatalf("refus de la façade : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer le projet actif : la façade le quitte d'abord (discussion
|
||||
// enregistrée, une du projet restant ouverte), puis supprime ses discussions.
|
||||
func TestFacadeSuppressionDeProjet(t *testing.T) {
|
||||
testHome(t)
|
||||
ensureDefaultProject()
|
||||
p, err := createProject("Deux")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := demarrerFacade(t)
|
||||
w := httptest.NewRecorder()
|
||||
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("suppression : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
got := f.recu()
|
||||
if len(got) != 2 ||
|
||||
got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+defaultProjectSlug+`"}` ||
|
||||
got[1] != `POST /internal/facade/v1/project/drop jeton {"slug":"`+p.Slug+`"}` {
|
||||
t.Fatalf("reçu par la façade : %v", got)
|
||||
}
|
||||
if projectExists(p.Slug) {
|
||||
t.Fatal("projet toujours là")
|
||||
}
|
||||
}
|
||||
|
||||
// /internal/v1/project/activate : le projet actif change, s'il existe.
|
||||
func TestFacadeActivationDeProjet(t *testing.T) {
|
||||
testHome(t)
|
||||
ensureDefaultProject()
|
||||
p, err := createProject("Trois")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"`+p.Slug+`"}`); w.Code != 204 {
|
||||
t.Fatalf("activation : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if activeProjectSlug() != p.Slug {
|
||||
t.Fatalf("actif : %s", activeProjectSlug())
|
||||
}
|
||||
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"nulle-part"}`); w.Code != 400 {
|
||||
t.Fatalf("inconnu : %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Un réglage que la façade ne sait pas honorer garde la conversation ici :
|
||||
// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient
|
||||
// jusqu'au redémarrage.
|
||||
func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
|
||||
testHome(t)
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) {
|
||||
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Posé après la décision : sans effet avant le redémarrage.
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !facadeChat() {
|
||||
t.Fatal("décision changée en cours de route")
|
||||
}
|
||||
oublierDecisionFacade(t)
|
||||
if facadeChat() {
|
||||
t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go")
|
||||
}
|
||||
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
|
||||
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") {
|
||||
t.Fatalf("détenteur : %s", w.Body.String())
|
||||
}
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
rec := httptest.NewRecorder()
|
||||
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
|
||||
if rec.Body.String() != "go" || len(f.recu()) != 0 {
|
||||
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "NUDGE_IN_TOOL", "SIDE_SLOT": "SIDE_SLOT",
|
||||
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
|
||||
} {
|
||||
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
|
||||
t.Errorf("%s : %q", k, got)
|
||||
}
|
||||
}
|
||||
if got := facadeChatBlocker(map[string]string{}); got != "" {
|
||||
t.Errorf("sans réglage : %q", got)
|
||||
}
|
||||
// Sans LOKI_CHAT=rust : le Go, et pourquoi.
|
||||
oublierDecisionFacade(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") {
|
||||
t.Fatalf("sans la variable : %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -360,9 +360,23 @@ func deleteProject(slug string) error {
|
||||
if !found {
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
// Discussions du projet.
|
||||
for _, m := range convIndexForProject(slug) {
|
||||
_ = convDelete(m.ID)
|
||||
// Discussions du projet. Quand la façade tient la conversation, c'est
|
||||
// elle qui les supprime — après avoir quitté le projet s'il est actif :
|
||||
// elle enregistre sa discussion et en ouvre une du projet restant, et ne
|
||||
// supprime donc jamais celle qu'elle a ouverte.
|
||||
if facadeChat() {
|
||||
if activeProjectSlug() == slug {
|
||||
if err := facadeProjectSwitch(kept[0].Slug); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := facadeDropProject(slug); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
for _, m := range convIndexForProject(slug) {
|
||||
_ = convDelete(m.ID)
|
||||
}
|
||||
}
|
||||
// Trackers du projet.
|
||||
for _, k := range trackerKeysForProject(slug) {
|
||||
|
||||
@@ -99,6 +99,10 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalTurnDone(w, r)
|
||||
case "/internal/v1/turn/verifier":
|
||||
handleInternalTurnVerifier(w, r)
|
||||
case "/internal/v1/project/activate":
|
||||
handleInternalProjectActivate(w, r)
|
||||
case "/internal/v1/chat/holder":
|
||||
handleInternalChatHolder(w, r)
|
||||
case "/internal/v1/conv/active":
|
||||
handleInternalConvActive(w, r)
|
||||
case "/internal/v1/conv/dropped":
|
||||
|
||||
@@ -21,6 +21,8 @@ import (
|
||||
// POST /internal/v1/turn/view {"caps":{…}} → {head, prefix, tools}
|
||||
// 409 : PROJ_SNAPSHOT actif (le bloc projet figé reste au Go)
|
||||
// POST /internal/v1/turn/done {"ms":N} → 204 (Web Push si abonnés)
|
||||
// POST /internal/v1/project/activate {"slug":…} → 204 (projet actif changé
|
||||
// par la façade, qui a enregistré la discussion du projet quitté)
|
||||
// POST /internal/v1/turn/verifier {"caps":{…}} → {system, tools, caps}
|
||||
// (passe de vérification du mode Code : contexte ISOLÉ, rôle verifier)
|
||||
// POST /internal/v1/conv/active {"id":…} → 204 (discussion active des outils)
|
||||
@@ -131,6 +133,26 @@ func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleInternalProjectActivate : setActiveProject pour la façade (projet
|
||||
// rangé en base, cache de ceux qui le lisent ici — mémoire, trackers, outils).
|
||||
func handleInternalProjectActivate(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if !readInternalJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !projectExists(in.Slug) {
|
||||
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "projet introuvable"})
|
||||
return
|
||||
}
|
||||
if err := setActiveProject(in.Slug); err != nil {
|
||||
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handleInternalTurnDone(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Ms int64 `json:"ms"`
|
||||
|
||||
@@ -9,6 +9,7 @@ package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -89,7 +90,8 @@ func handleProjects(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// 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
|
||||
@@ -100,12 +102,25 @@ func handleProjects(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
|
||||
return
|
||||
}
|
||||
err = projectSwitch(slug)
|
||||
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 {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
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()})
|
||||
|
||||
Reference in new issue
Block a user