mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 19 : export, mode Code et critères tenus par la façade (avec LOKI_CHAT=rust)
Quand la façade tient la conversation, /api/chat/export, /api/chat/mode et /api/chat/criteria rejoignent ses routes (chatx.rs, facadeChatRoutes côté Go), sans nouvelle variable. L'export Markdown ou JSON part de la conversation vivante (le Go relisait la copie enregistrée, en retard d'un tour au besoin) : raisonnement replié, outils dans leur état final, compaction signalée, export partiel annoncé, JSON indenté et HTML échappé, sans mise à jour de contexte ni image en base64. Le mode et les critères (scellés, vingt au plus, statuts ramenés) vont sur le fil de la façade. Le reste du mode Code (indice, travaux de fond, outil criteria) reste au Go et lit les mêmes clés. Parité : parity_chatx_test.go tire du Go l'export d'une conversation riche sous 22 jeux d'options et 23 appels au mode et aux critères (base et fil après chacun, erreurs JSON imbriquées comprises), rejoués octet pour octet par tests/chatx.rs ; tests du relais mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
11 files changed
+2625
-11
No files matched your search
@@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
|
||||
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
|
||||
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
|
||||
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
|
||||
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris, avec l'export, le mode Code et les critères de la discussion. Le Go garde les autres outils et le reste du mode Code. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
|
||||
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Lot 19 — export, mode Code et critères de la discussion
|
||||
|
||||
## Contexte
|
||||
|
||||
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
|
||||
routes de la discussion ouverte restaient au Go :
|
||||
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
|
||||
qui peut avoir un temps de retard sur la conversation vivante ;
|
||||
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
|
||||
ses critères ;
|
||||
- `/api/chat/criteria` : critères édités à la main.
|
||||
|
||||
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
|
||||
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
|
||||
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
|
||||
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
|
||||
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
|
||||
|
||||
L'export suit `chat_export.go` :
|
||||
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
|
||||
les outils en liste (seul l'état final d'un outil compte, comme au
|
||||
rejeu), la compaction signalée, un export partiel annoncé en tête ;
|
||||
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
|
||||
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
|
||||
sa taille), journal filtré par les mêmes cases ;
|
||||
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
|
||||
des rappels de Loki.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
|
||||
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- L'export part de la conversation vivante, pas de sa copie enregistrée :
|
||||
le dernier tour y est, même tout juste fini.
|
||||
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
|
||||
conversation relue par le Go les avait déjà perdues.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
|
||||
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
|
||||
- pièces jointes, raisonnement en deltas ;
|
||||
- outils finis, en cours, avec et sans sortie, labels piégés pour le
|
||||
Markdown ;
|
||||
- statistiques, compaction ;
|
||||
- images en base64 et en URL, rappel de Loki ;
|
||||
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
|
||||
|
||||
Plus les exports d'une conversation vide.
|
||||
|
||||
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
|
||||
les événements du fil après chacun : erreurs JSON, y compris celles d'un
|
||||
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
|
||||
|
||||
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
|
||||
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
|
||||
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
|
||||
les tests.
|
||||
@@ -123,8 +123,8 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) {
|
||||
func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) }
|
||||
|
||||
// facadeChatRoutes : les routes que la façade sert quand elle tient la
|
||||
// conversation (chatapi.rs). Le reste (mode Code, critères, pièces jointes,
|
||||
// fichiers, export) reste ici.
|
||||
// conversation (chatapi.rs, chatx.rs). Le reste du mode Code (indice, travaux
|
||||
// de fond, outil criteria) reste ici et lit les mêmes clés.
|
||||
var facadeChatRoutes = map[string]bool{
|
||||
"/api/chat": true,
|
||||
"/api/chat/send": true,
|
||||
@@ -138,6 +138,10 @@ var facadeChatRoutes = map[string]bool{
|
||||
"/api/conversations/switch": true,
|
||||
"/api/conversations/rename": true,
|
||||
"/api/conversations/delete": true,
|
||||
// Lot 19 : export, mode Code et critères de la discussion ouverte.
|
||||
"/api/chat/export": true,
|
||||
"/api/chat/mode": true,
|
||||
"/api/chat/criteria": true,
|
||||
}
|
||||
|
||||
// withFacadeChat remet la requête à la façade quand elle tient la
|
||||
|
||||
@@ -96,17 +96,25 @@ func TestFacadeRelaieLesRoutesDuChat(t *testing.T) {
|
||||
if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat/send jeton {"message":"salut"}` {
|
||||
t.Fatalf("reçu par la façade : %v", got)
|
||||
}
|
||||
// Mode Code : reste au Go.
|
||||
// Mode Code, critères, export : à la façade aussi (lot 19).
|
||||
for _, p := range []string{"/api/chat/mode", "/api/chat/criteria", "/api/chat/export"} {
|
||||
w = httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
|
||||
if w.Body.String() != f.reply {
|
||||
t.Fatalf("%s non relayé : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
// L'aperçu du navigateur piloté reste ici.
|
||||
w = httptest.NewRecorder()
|
||||
withFacadeChat("/api/chat/mode", local)(w, httptest.NewRequest("POST", "/api/chat/mode", nil))
|
||||
withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("mode relayé : %q", w.Body.String())
|
||||
t.Fatalf("aperçu relayé : %q", w.Body.String())
|
||||
}
|
||||
// Sans LOKI_CHAT=rust : tout reste ici.
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
w = httptest.NewRecorder()
|
||||
withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil))
|
||||
if w.Body.String() != "go" || len(f.recu()) != 1 {
|
||||
if w.Body.String() != "go" || len(f.recu()) != 4 {
|
||||
t.Fatalf("relayé sans la variable : %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parity_chatx_test.go — journal de référence de l'export, du mode Code et
|
||||
// des critères de la discussion (lot 19 de la migration Rust) : l'export
|
||||
// Markdown et JSON d'une conversation riche (pièces jointes, raisonnement en
|
||||
// deltas, outils avec et sans sortie, compaction, images en base64, rappels
|
||||
// de Loki, mise à jour de contexte) sous une vingtaine de jeux d'options lus
|
||||
// dans la requête, puis les routes du mode et des critères avec la base après
|
||||
// chaque appel. Rangé dans rust/loki-rs/tests/parity_chatx/chatx.json ;
|
||||
// tests/chatx.rs le rejoue par la façade (conversation tenue par elle).
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteChatX
|
||||
|
||||
const parityChatXFile = "../../rust/loki-rs/tests/parity_chatx/chatx.json"
|
||||
|
||||
var (
|
||||
parityExportDateRe = regexp.MustCompile(`Exportée le \d{2}/\d{2}/\d{4} à \d{2}:\d{2}`)
|
||||
parityExportedAtRe = regexp.MustCompile(`"exported_at": "[^"]+"`)
|
||||
)
|
||||
|
||||
// parityExportMask : la date de l'export et la version, propres à chaque
|
||||
// exécution et à chaque binaire.
|
||||
func parityExportMask(s string) string {
|
||||
s = parityExportDateRe.ReplaceAllString(s, "Exportée le {{DATE}}")
|
||||
s = parityExportedAtRe.ReplaceAllString(s, `"exported_at": "{{DATE}}"`)
|
||||
return strings.ReplaceAll(s, Version, "{{VERSION}}")
|
||||
}
|
||||
|
||||
type parityChatXStep struct {
|
||||
Name string `json:"name"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Out any `json:"out"`
|
||||
Mode string `json:"mode"` // chat/mode:<id> après l'appel
|
||||
Crit any `json:"criteria"` // chat/crit:<id> après l'appel
|
||||
Deltas []any `json:"deltas"` // événements ajoutés au fil
|
||||
}
|
||||
|
||||
func TestPariteChatX(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
img := "data:image/png;base64," + strings.Repeat("QUJD", 600)
|
||||
msgs := []Message{
|
||||
{Role: "user", CtxUpd: true, Content: ctxUpdOpen + ` v="1">projet</context_update>` + "\n\nBonjour <b> & Cie"},
|
||||
{Role: "user", Content: ctxUpdOpen + ` v="2">tapé</context_update> par l'utilisateur`},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "c1", Type: "function",
|
||||
Function: ToolCallFunc{Name: "bash", Arguments: `{"cmd":"ls"}`}}}},
|
||||
{Role: "tool", Content: "a.txt\nb.txt", ToolCallID: "c1"},
|
||||
{Role: "assistant", Content: "Voilà.", ReasoningContent: "je liste", ReasoningModel: "m"},
|
||||
{Role: "user", Content: thinkNudgeFirst},
|
||||
{Role: "user", Content: []any{
|
||||
map[string]any{"type": "text", "text": "Regarde"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": img, "detail": "low"}},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://x/y.png"}},
|
||||
"brut",
|
||||
}},
|
||||
{Role: "assistant", Content: "Une image."},
|
||||
{Role: "user", Content: "Dernier tour"},
|
||||
{Role: "assistant", Content: "Fin."},
|
||||
}
|
||||
ev := func(seq int, d map[string]any) LogEvent { return LogEvent{Seq: seq, TS: int64(1000 + seq), Delta: d} }
|
||||
log := []LogEvent{
|
||||
ev(1, map[string]any{"user": "Bonjour <b> & Cie", "files": []any{map[string]any{"name": "rapport.pdf", "size": 10}, map[string]any{"name": ""}, "x"}}),
|
||||
ev(2, map[string]any{"reasoning_content": "je "}),
|
||||
ev(3, map[string]any{"reasoning_content": "liste\n"}),
|
||||
ev(4, map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls *.txt\n[a]", "result": "a.txt\nb.txt\n\n", "done": true}}),
|
||||
ev(5, map[string]any{"tool_used": map[string]any{"name": "mem_read", "result": " ", "done": true}}),
|
||||
ev(5, map[string]any{"tool_used": map[string]any{"name": "git_`log`", "label": " `--oneline` [main] ", "done": true}}),
|
||||
ev(5, map[string]any{"tool_used": map[string]any{"name": "en_cours", "label": "pas fini"}}),
|
||||
ev(6, map[string]any{"content": "Voi"}),
|
||||
ev(7, map[string]any{"content": "là.\n"}),
|
||||
ev(8, map[string]any{"stats": map[string]any{"tok_s": 12.5}}),
|
||||
ev(9, map[string]any{"compacted": true}),
|
||||
ev(10, map[string]any{"user": "", "files": []any{map[string]any{"name": "photo.png"}}}),
|
||||
ev(11, map[string]any{"content": "Une image."}),
|
||||
ev(12, map[string]any{"user": "Dernier tour"}),
|
||||
ev(13, map[string]any{"tool_used": map[string]any{"name": "web_open", "label": "https://ex.com/a_b*c", "result": "titre"}}),
|
||||
ev(14, map[string]any{"content": "Fin."}),
|
||||
}
|
||||
c := &Conversation{Messages: msgs, Log: log, Seq: 14, CtxUsed: 4321}
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type exportCase struct {
|
||||
Query string `json:"query"`
|
||||
Opts any `json:"opts"`
|
||||
Body string `json:"body"`
|
||||
Filename string `json:"filename_ext"`
|
||||
}
|
||||
var exports []exportCase
|
||||
for _, q := range []string{
|
||||
"", "format=json", "format=md", "format=JSON", "reasoning=0", "reasoning=true&tools=off",
|
||||
"tools=0", "tools=0&format=json", "results=0", "results=0&format=json", "reasoning=0&format=json",
|
||||
"turns=1", "turns=2&format=json", "turns=9", "turns=0", "turns=-2", "turns=abc", "turns=1&results=0&reasoning=0",
|
||||
"tools=on&results=yes", "format=json&format=md", "turns=3&format=json", "turns=4&format=json",
|
||||
} {
|
||||
vals, _ := url.ParseQuery(q)
|
||||
o := exportOptsFromQuery(vals)
|
||||
var body []byte
|
||||
var ext string
|
||||
if o.Format == "json" {
|
||||
body, err = c.ExportJSON(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ext = "json"
|
||||
} else {
|
||||
body = []byte(c.ExportMarkdown(o))
|
||||
ext = "md"
|
||||
}
|
||||
exports = append(exports, exportCase{Query: q, Opts: map[string]any{"format": o.Format, "reasoning": o.Reasoning,
|
||||
"tools": o.Tools, "results": o.Results, "turns": o.Turns}, Body: parityExportMask(string(body)), Filename: ext})
|
||||
}
|
||||
empty := &Conversation{}
|
||||
emptyMD := parityExportMask(empty.ExportMarkdown(defaultExportOpts()))
|
||||
emptyJSON, _ := empty.ExportJSON(defaultExportOpts())
|
||||
|
||||
// --- mode et critères, conversation tenue ici ---
|
||||
id := convEnsureActive()
|
||||
conv.mu.Lock()
|
||||
conv.Log = nil
|
||||
conv.mu.Unlock()
|
||||
run := func(st parityChatXStep) parityChatXStep {
|
||||
conv.mu.Lock()
|
||||
before := len(conv.Log)
|
||||
conv.mu.Unlock()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body))
|
||||
if st.Path == "/api/chat/mode" {
|
||||
handleChatMode(rec, req)
|
||||
} else {
|
||||
handleChatCriteria(rec, req)
|
||||
}
|
||||
st.Status = rec.Code
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &st.Out)
|
||||
st.Mode = getStr(bkChat, modeKey(id))
|
||||
var crit any
|
||||
if b := getBytes(bkChat, critKey(id)); len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &crit)
|
||||
}
|
||||
st.Crit = crit
|
||||
conv.mu.Lock()
|
||||
for _, e := range conv.Log[before:] {
|
||||
d := map[string]any{}
|
||||
for k, v := range e.Delta {
|
||||
if k != "ts" {
|
||||
d[k] = v
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(d)
|
||||
var x any
|
||||
_ = json.Unmarshal(b, &x)
|
||||
st.Deltas = append(st.Deltas, x)
|
||||
}
|
||||
conv.mu.Unlock()
|
||||
return st
|
||||
}
|
||||
long := make([]string, 0, 25)
|
||||
for i := 0; i < 25; i++ {
|
||||
long = append(long, `{"text":"c`+string(rune('a'+i))+`"}`)
|
||||
}
|
||||
var steps []parityChatXStep
|
||||
for _, st := range []parityChatXStep{
|
||||
{Name: "mode par défaut", Method: "GET", Path: "/api/chat/mode"},
|
||||
{Name: "mode code", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"code"}`},
|
||||
{Name: "mode relu", Method: "GET", Path: "/api/chat/mode"},
|
||||
{Name: "mode inconnu", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"Code"}`},
|
||||
{Name: "mode corps illisible", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":`},
|
||||
{Name: "mode type faux", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":true}`},
|
||||
{Name: "mode en PUT", Method: "PUT", Path: "/api/chat/mode", Body: `{"mode":"chat"}`},
|
||||
{Name: "critères en GET", Method: "GET", Path: "/api/chat/criteria"},
|
||||
{Name: "critères", Method: "POST", Path: "/api/chat/criteria",
|
||||
Body: `{"criteria":[{"text":"compile"},{"id":5,"text":"tests <ok> & verts","status":"passed","note":"vu"},{"text":" "},{"id":0,"text":"doc","status":"bizarre"},{"id":2,"text":"lint","status":"failed"},{"text":"perf","status":"completed"}]}`},
|
||||
{Name: "mode relu avec critères", Method: "GET", Path: "/api/chat/mode"},
|
||||
{Name: "critères au-delà de vingt", Method: "POST", Path: "/api/chat/criteria",
|
||||
Body: `{"criteria":[` + strings.Join(long, ",") + `]}`},
|
||||
{Name: "critères vidés", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[]}`},
|
||||
{Name: "critères absents", Method: "POST", Path: "/api/chat/criteria", Body: `{}`},
|
||||
{Name: "critères null", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":null}`},
|
||||
{Name: "critères corps vide", Method: "POST", Path: "/api/chat/criteria", Body: ``},
|
||||
{Name: "critères illisibles", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[`},
|
||||
{Name: "critère id texte", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"id":"2","text":"x"}]}`},
|
||||
{Name: "critère texte nombre", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"text":3}]}`},
|
||||
{Name: "critères pas une liste", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":"a"}`},
|
||||
{Name: "critère pas un objet", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":["a",{"text":"b"}]}`},
|
||||
{Name: "critère id décimal", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"id":1.5,"text":"x"}]}`},
|
||||
{Name: "critère clés en majuscules", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"ID":3,"TEXT":"maj","Status":"passed"},null]}`},
|
||||
{Name: "mode chat", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"chat"}`},
|
||||
} {
|
||||
steps = append(steps, run(st))
|
||||
}
|
||||
|
||||
got, err := parityJSON(map[string]any{
|
||||
"conversation": json.RawMessage(raw), "exports": exports,
|
||||
"empty_md": emptyMD, "empty_json": parityExportMask(string(emptyJSON)), "steps": steps,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityChatXFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityChatXFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityChatXFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("l'export, le mode ou les critères ne produisent plus le journal rangé dans %s.\n"+
|
||||
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteChatX,\n"+
|
||||
"puis cargo test --test chatx dit si le port Rust suit.\n--- obtenu ---\n%s", parityChatXFile, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
//! Export, mode Code et critères de la discussion, tenus par la façade quand
|
||||
//! elle tient la conversation (`LOKI_CHAT=rust`, lot 19 de la migration) :
|
||||
//!
|
||||
//! - `/api/chat/export` — port de `chat_export.go` : Markdown (fait pour être
|
||||
//! lu) ou JSON (fait pour être retraité), mêmes options pour les deux
|
||||
//! (raisonnements, outils, sorties, derniers échanges), tirés de la
|
||||
//! conversation VIVANTE (le Go relisait la copie enregistrée) ;
|
||||
//! - `/api/chat/mode` — `handleChatMode` (`code_mode.go`) : mode de la
|
||||
//! discussion ouverte (`chat/mode:<id>`) et ses critères ;
|
||||
//! - `/api/chat/criteria` — `handleChatCriteria` : critères édités à la main
|
||||
//! (`chat/crit:<id>`, scellés comme le contenu des discussions).
|
||||
//!
|
||||
//! Les changements partent sur le fil de la façade (deltas `mode`,
|
||||
//! `criteria`), comme le Go les lui envoyait. Le reste du mode Code (indice,
|
||||
//! travaux de fond, outil `criteria`) reste au Go, qui lit les mêmes clés.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::api::Ctx;
|
||||
use crate::auth::json as send_json;
|
||||
use crate::chat::compact::{is_loki_injected, strip_context_updates};
|
||||
use crate::chat::generate::ChatService;
|
||||
use crate::chat::journal::coalesce_replay;
|
||||
use crate::chat::persist::BK_CHAT;
|
||||
use crate::chat::types::{LogEvent, Message};
|
||||
use crate::gojson::{self, Kind};
|
||||
use crate::proxy::Body;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `exportOpts`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExportOpts {
|
||||
/// « md » ou « json ».
|
||||
pub format: String,
|
||||
pub reasoning: bool,
|
||||
pub tools: bool,
|
||||
pub results: bool,
|
||||
/// 0 = tout le fil ; N = les N derniers échanges.
|
||||
pub turns: i64,
|
||||
}
|
||||
|
||||
impl Default for ExportOpts {
|
||||
/// `defaultExportOpts`.
|
||||
fn default() -> Self {
|
||||
ExportOpts {
|
||||
format: "md".into(),
|
||||
reasoning: true,
|
||||
tools: true,
|
||||
results: true,
|
||||
turns: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `exportOptsFromQuery` : toute clé absente garde sa valeur par défaut (la
|
||||
/// première valeur d'une clé répétée compte).
|
||||
pub fn opts_from_query(query: &str) -> ExportOpts {
|
||||
let mut first: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
first
|
||||
.entry(k.into_owned())
|
||||
.or_insert_with(|| v.into_owned());
|
||||
}
|
||||
let mut o = ExportOpts::default();
|
||||
let flag = |k: &str, cur: bool| match first.get(k) {
|
||||
None => cur,
|
||||
Some(v) => v == "1" || v == "true" || v == "on",
|
||||
};
|
||||
if first.get("format").is_some_and(|v| v == "json") {
|
||||
o.format = "json".into();
|
||||
}
|
||||
o.reasoning = flag("reasoning", o.reasoning);
|
||||
o.tools = flag("tools", o.tools);
|
||||
o.results = flag("results", o.results);
|
||||
if let Some(n) = first
|
||||
.get("turns")
|
||||
.and_then(|v| go_atoi(v))
|
||||
.filter(|n| *n > 0)
|
||||
{
|
||||
o.turns = n;
|
||||
}
|
||||
// Sans les bulles d'outils, leur sortie n'a nulle part où aller.
|
||||
if !o.tools {
|
||||
o.results = false;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// `strconv.Atoi` (signe optionnel, chiffres ASCII seulement).
|
||||
fn go_atoi(s: &str) -> Option<i64> {
|
||||
let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// `filterLog` : les mêmes cases que le Markdown, appliquées au journal (sur
|
||||
/// une copie : les événements appartiennent à la conversation vivante).
|
||||
pub fn filter_log(log: &[LogEvent], o: &ExportOpts) -> Vec<LogEvent> {
|
||||
if o.reasoning && o.tools && o.results {
|
||||
return log.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(log.len());
|
||||
for ev in log {
|
||||
if ev.delta.contains_key("reasoning_content") && !o.reasoning {
|
||||
continue;
|
||||
}
|
||||
if let Some(Value::Object(tu)) = ev.delta.get("tool_used") {
|
||||
if !o.tools {
|
||||
continue;
|
||||
}
|
||||
if !o.results {
|
||||
let mut cp = tu.clone();
|
||||
cp.remove("result");
|
||||
let mut ev = ev.clone();
|
||||
ev.delta.insert("tool_used".into(), Value::Object(cp));
|
||||
out.push(ev);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(ev.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `lastTurnsMessages` : les `n` derniers échanges, un échange commençant à un
|
||||
/// message `user` (un rappel de Loki n'en ouvre pas).
|
||||
pub fn last_turns_messages(msgs: &[Message], n: i64) -> Vec<Message> {
|
||||
let mut seen = 0;
|
||||
for i in (0..msgs.len()).rev() {
|
||||
if msgs[i].role != "user" || is_loki_injected(&msgs[i]) {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if seen == n {
|
||||
return msgs[i..].to_vec();
|
||||
}
|
||||
}
|
||||
msgs.to_vec()
|
||||
}
|
||||
|
||||
/// `lastTurnsLog` : idem sur le journal, où un échange commence à un événement
|
||||
/// `user`.
|
||||
pub fn last_turns_log(log: &[LogEvent], n: i64) -> Vec<LogEvent> {
|
||||
let mut seen = 0;
|
||||
for i in (0..log.len()).rev() {
|
||||
if !log[i].delta.contains_key("user") {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if seen == n {
|
||||
return log[i..].to_vec();
|
||||
}
|
||||
}
|
||||
log.to_vec()
|
||||
}
|
||||
|
||||
/// `elideDataURI` : l'en-tête « data:<mime>;base64, » gardé, le corps
|
||||
/// remplacé par sa taille.
|
||||
pub fn elide_data_uri(url: &str) -> String {
|
||||
match url.find(',') {
|
||||
Some(i) => format!(
|
||||
"{}<élidé — {}>",
|
||||
&url[..=i],
|
||||
crate::downloads::human_bytes((url.len() - i - 1) as i64)
|
||||
),
|
||||
None => "<image élidée>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `stripInlineImages` : les images base64 des messages multimodaux élidées
|
||||
/// (un export JSON embarquait plusieurs Mo par image).
|
||||
pub fn strip_inline_images(msgs: Vec<Message>) -> Vec<Message> {
|
||||
msgs.into_iter()
|
||||
.map(|mut m| {
|
||||
if let Some(Value::Array(parts)) = &mut m.content {
|
||||
for p in parts.iter_mut() {
|
||||
if let Value::Object(part) = p
|
||||
&& let Some(Value::Object(iu)) = part.get_mut("image_url")
|
||||
&& let Some(Value::String(url)) = iu.get("url")
|
||||
&& url.starts_with("data:")
|
||||
{
|
||||
let e = elide_data_uri(url);
|
||||
iu.insert("url".into(), Value::String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `exportNote` : ce que l'export ne contient PAS, en une ligne.
|
||||
pub fn export_note(o: &ExportOpts) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if o.turns > 0 {
|
||||
parts.push(format!("{} derniers échanges seulement", o.turns));
|
||||
}
|
||||
if !o.reasoning {
|
||||
parts.push("raisonnements retirés".into());
|
||||
}
|
||||
if !o.tools {
|
||||
parts.push("appels d'outils retirés".into());
|
||||
} else if !o.results {
|
||||
parts.push("sorties d'outils retirées".into());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("_Export partiel : {}._", parts.join(", "))
|
||||
}
|
||||
|
||||
/// `mdText`.
|
||||
fn md_text(v: Option<&Value>) -> String {
|
||||
v.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('\n')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// `mdInline` : ce qui casserait une ligne Markdown neutralisé.
|
||||
pub fn md_inline(s: &str) -> String {
|
||||
let s = s.replace('\n', " ");
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if matches!(c, '*' | '_' | '`' | '[' | ']') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// `exportFileNames`.
|
||||
fn export_file_names(v: Option<&Value>) -> Vec<String> {
|
||||
match v {
|
||||
Some(Value::Array(files)) => files
|
||||
.iter()
|
||||
.filter_map(|f| f.get("name").and_then(Value::as_str))
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn present<'a>(ev: &'a Map<String, Value>, k: &str) -> Option<&'a Value> {
|
||||
ev.get(k).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
/// `ExportMarkdown` : un bloc par tour, le raisonnement replié dans un
|
||||
/// `<details>`, les outils en liste. `date` : « 02/01/2006 à 15:04 ».
|
||||
pub fn export_markdown(log: &[LogEvent], o: &ExportOpts, date: &str, version: &str) -> String {
|
||||
let snapshot = if o.turns > 0 {
|
||||
last_turns_log(log, o.turns)
|
||||
} else {
|
||||
log.to_vec()
|
||||
};
|
||||
let mut b = format!("# Conversation Loki\n\nExportée le {date} par Loki {version}.\n");
|
||||
let note = export_note(o);
|
||||
if !note.is_empty() {
|
||||
b.push_str(&format!("\n{note}\n"));
|
||||
}
|
||||
let mut open_bubble = false;
|
||||
let head = |b: &mut String, open: &mut bool| {
|
||||
if !*open {
|
||||
b.push_str("\n## Loki\n");
|
||||
*open = true;
|
||||
}
|
||||
};
|
||||
for ev in coalesce_replay(&snapshot, 0) {
|
||||
if let Some(u) = present(&ev, "user") {
|
||||
open_bubble = false;
|
||||
b.push_str(&format!("\n---\n\n## Vous\n\n{}\n", md_text(Some(u))));
|
||||
let names = export_file_names(ev.get("files"));
|
||||
if !names.is_empty() {
|
||||
b.push_str(&format!("\nFichiers joints : {}\n", names.join(", ")));
|
||||
}
|
||||
} else if let Some(r) = present(&ev, "reasoning_content") {
|
||||
if o.reasoning {
|
||||
head(&mut b, &mut open_bubble);
|
||||
b.push_str(&format!(
|
||||
"\n<details>\n<summary>Raisonnement</summary>\n\n{}\n\n</details>\n",
|
||||
md_text(Some(r))
|
||||
));
|
||||
}
|
||||
} else if let Some(c) = present(&ev, "content") {
|
||||
head(&mut b, &mut open_bubble);
|
||||
b.push_str(&format!("\n{}\n", md_text(Some(c))));
|
||||
} else if let Some(tu) = present(&ev, "tool_used") {
|
||||
if !o.tools {
|
||||
continue;
|
||||
}
|
||||
let Value::Object(tu) = tu else {
|
||||
continue;
|
||||
};
|
||||
head(&mut b, &mut open_bubble);
|
||||
let name = tu.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let label = match tu.get("label").and_then(Value::as_str).unwrap_or("") {
|
||||
"" => name,
|
||||
l => l,
|
||||
};
|
||||
b.push_str(&format!(
|
||||
"\n> 🔧 **{}** — {}\n",
|
||||
md_inline(name),
|
||||
md_inline(label)
|
||||
));
|
||||
if let Some(res) = tu.get("result").and_then(Value::as_str)
|
||||
&& o.results
|
||||
&& !res.trim().is_empty()
|
||||
{
|
||||
b.push_str(&format!("\n```\n{}\n```\n", res.trim_end_matches('\n')));
|
||||
}
|
||||
} else if present(&ev, "compacted").is_some() {
|
||||
b.push_str("\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n");
|
||||
}
|
||||
}
|
||||
if !open_bubble && snapshot.is_empty() {
|
||||
b.push_str("\n_Conversation vide._\n");
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExportPayload<'a> {
|
||||
loki_version: &'a str,
|
||||
exported_at: &'a str,
|
||||
ctx_used: i64,
|
||||
messages: Vec<Message>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
log: Vec<LogEvent>,
|
||||
}
|
||||
|
||||
/// `json.MarshalIndent(v, "", " ")` : indenté de deux espaces, HTML échappé.
|
||||
pub fn marshal_indent<T: Serialize>(v: &T) -> Vec<u8> {
|
||||
let s = serde_json::to_string_pretty(v).unwrap_or_default();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'<' => out.push_str("\\u003c"),
|
||||
'>' => out.push_str("\\u003e"),
|
||||
'&' => out.push_str("\\u0026"),
|
||||
'\u{2028}' => out.push_str("\\u2028"),
|
||||
'\u{2029}' => out.push_str("\\u2029"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
/// `ExportJSON` : la vue modèle (sans mises à jour de contexte ni images
|
||||
/// base64) et le journal d'affichage filtré.
|
||||
pub fn export_json(
|
||||
messages: &[Message],
|
||||
log: &[LogEvent],
|
||||
ctx_used: i64,
|
||||
o: &ExportOpts,
|
||||
exported_at: &str,
|
||||
version: &str,
|
||||
) -> Vec<u8> {
|
||||
let (msgs, log) = if o.turns > 0 {
|
||||
(
|
||||
last_turns_messages(messages, o.turns),
|
||||
last_turns_log(log, o.turns),
|
||||
)
|
||||
} else {
|
||||
(messages.to_vec(), log.to_vec())
|
||||
};
|
||||
let p = ExportPayload {
|
||||
loki_version: version,
|
||||
exported_at,
|
||||
ctx_used,
|
||||
messages: strip_inline_images(strip_context_updates(msgs)),
|
||||
log: filter_log(&log, o),
|
||||
};
|
||||
marshal_indent(&p)
|
||||
}
|
||||
|
||||
/// Heure locale : (année, mois, jour, heure, minute, seconde, décalage en s).
|
||||
fn local_now() -> (i64, u32, u32, u32, u32, u32, i64) {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let t = secs as libc::time_t;
|
||||
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||
// SAFETY : pointeurs valides, structure de sortie initialisée.
|
||||
if !unsafe { libc::localtime_r(&t, &mut tm) }.is_null() {
|
||||
return (
|
||||
i64::from(tm.tm_year) + 1900,
|
||||
(tm.tm_mon + 1) as u32,
|
||||
tm.tm_mday as u32,
|
||||
tm.tm_hour as u32,
|
||||
tm.tm_min as u32,
|
||||
tm.tm_sec as u32,
|
||||
tm.tm_gmtoff as i64,
|
||||
);
|
||||
}
|
||||
}
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
let (y, m, d) = civil(days);
|
||||
(
|
||||
y,
|
||||
m,
|
||||
d,
|
||||
(rem / 3600) as u32,
|
||||
(rem % 3600 / 60) as u32,
|
||||
(rem % 60) as u32,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Jours depuis 1970 → (année, mois, jour).
|
||||
fn civil(days: i64) -> (i64, u32, u32) {
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z.rem_euclid(146097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(era * 400 + yoe + i64::from(m <= 2), m, d)
|
||||
}
|
||||
|
||||
/// `time.RFC3339` en heure locale (« Z » à l'heure UTC, comme le Go).
|
||||
fn rfc3339_now() -> String {
|
||||
let (y, mo, d, h, mi, s, off) = local_now();
|
||||
let zone = if off == 0 {
|
||||
"Z".to_string()
|
||||
} else {
|
||||
let sign = if off < 0 { '-' } else { '+' };
|
||||
let a = off.abs();
|
||||
format!("{sign}{:02}:{:02}", a / 3600, a % 3600 / 60)
|
||||
};
|
||||
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}{zone}")
|
||||
}
|
||||
|
||||
/// « 02/01/2006 à 15:04 ».
|
||||
fn fr_date_now() -> String {
|
||||
let (y, mo, d, h, mi, _, _) = local_now();
|
||||
format!("{d:02}/{mo:02}/{y:04} à {h:02}:{mi:02}")
|
||||
}
|
||||
|
||||
/// `exportFilename` : horodaté pour que deux exports ne s'écrasent pas.
|
||||
fn export_filename(ext: &str) -> String {
|
||||
let (y, mo, d, h, mi, _, _) = local_now();
|
||||
format!("loki-conversation-{y:04}-{mo:02}-{d:02}-{h:02}{mi:02}.{ext}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode et critères
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `Criterion`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Criterion {
|
||||
pub id: i64,
|
||||
pub text: String,
|
||||
pub status: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// `critMaxCount`.
|
||||
pub const CRIT_MAX_COUNT: usize = 20;
|
||||
|
||||
fn mode_key(id: &str) -> String {
|
||||
format!("mode:{id}")
|
||||
}
|
||||
|
||||
fn crit_key(id: &str) -> String {
|
||||
format!("crit:{id}")
|
||||
}
|
||||
|
||||
/// Nom de type JSON d'une valeur, comme `UnmarshalTypeError.Value`.
|
||||
fn json_kind(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::String(_) => "string",
|
||||
Value::Number(_) => "number",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
Value::Null => "null",
|
||||
}
|
||||
}
|
||||
|
||||
const MODE_REQ_TYPE: &str = r#"struct { Mode string "json:\"mode\"" }"#;
|
||||
const CRIT_REQ_TYPE: &str = r#"struct { Criteria []loki.Criterion "json:\"criteria\"" }"#;
|
||||
|
||||
/// Décode `{criteria: []Criterion}` comme `json.NewDecoder(r.Body).Decode`
|
||||
/// (liste absente ou nulle : vide).
|
||||
pub fn decode_criteria(body: &[u8]) -> Result<Vec<Criterion>, String> {
|
||||
let d = gojson::decode_struct(body, CRIT_REQ_TYPE, &[("criteria", Kind::Any)]);
|
||||
if let Some(e) = d.err.clone() {
|
||||
return Err(e);
|
||||
}
|
||||
let Some(raw) = d.fields.get("criteria") else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let items = match raw {
|
||||
Value::Null => return Ok(Vec::new()),
|
||||
Value::Array(a) => a.clone(),
|
||||
v => {
|
||||
return Err(format!(
|
||||
"json: cannot unmarshal {} into Go struct field .criteria of type []loki.Criterion",
|
||||
json_kind(v)
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut first_err: Option<String> = None;
|
||||
for it in items {
|
||||
match it {
|
||||
Value::Null => out.push(Criterion::default()),
|
||||
Value::Object(_) => {
|
||||
let b = serde_json::to_vec(&it).unwrap_or_default();
|
||||
let d = gojson::decode_struct(
|
||||
&b,
|
||||
"Criterion",
|
||||
&[
|
||||
("id", Kind::Int),
|
||||
("text", Kind::Str),
|
||||
("status", Kind::Str),
|
||||
("note", Kind::Str),
|
||||
],
|
||||
);
|
||||
if let Some(e) = d.err.clone()
|
||||
&& first_err.is_none()
|
||||
{
|
||||
first_err = Some(e.replacen(
|
||||
"Go struct field Criterion.",
|
||||
"Go struct field Criterion.criteria.",
|
||||
1,
|
||||
));
|
||||
}
|
||||
out.push(Criterion {
|
||||
id: d.int("id"),
|
||||
text: d.str("text"),
|
||||
status: d.str("status"),
|
||||
note: d.str("note"),
|
||||
});
|
||||
}
|
||||
v => {
|
||||
if first_err.is_none() {
|
||||
first_err = Some(format!(
|
||||
"json: cannot unmarshal {} into Go struct field .criteria of type loki.Criterion",
|
||||
json_kind(&v)
|
||||
));
|
||||
}
|
||||
out.push(Criterion::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(out),
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleChatCriteria` : vides écartés, vingt au plus, ids posés,
|
||||
/// statuts ramenés à pending/passed/failed.
|
||||
pub fn clean_criteria(list: Vec<Criterion>) -> Vec<Criterion> {
|
||||
let mut clean: Vec<Criterion> = Vec::new();
|
||||
let mut next = 1;
|
||||
for mut c in list {
|
||||
if c.text.trim().is_empty() || clean.len() >= CRIT_MAX_COUNT {
|
||||
continue;
|
||||
}
|
||||
if c.id <= 0 {
|
||||
c.id = next;
|
||||
}
|
||||
if c.id >= next {
|
||||
next = c.id + 1;
|
||||
}
|
||||
if !matches!(c.status.as_str(), "pending" | "passed" | "failed") {
|
||||
c.status = "pending".into();
|
||||
}
|
||||
clean.push(c);
|
||||
}
|
||||
clean
|
||||
}
|
||||
|
||||
/// `critList` : `null` sans critères.
|
||||
async fn crit_list(ctx: &Ctx, conv_id: &str) -> Value {
|
||||
match ctx.store.get_open(BK_CHAT, &crit_key(conv_id)).await {
|
||||
Ok(Some(b)) if !b.is_empty() => serde_json::from_slice(&b).unwrap_or(Value::Null),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Les routes servies par ce module (avec la conversation).
|
||||
pub fn handles(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/chat/export" | "/api/chat/mode" | "/api/chat/criteria"
|
||||
)
|
||||
}
|
||||
|
||||
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
|
||||
send_json(code, &json!({ "ok": false, "error": e.into() }))
|
||||
}
|
||||
|
||||
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
|
||||
pub async fn serve(
|
||||
ctx: &Ctx,
|
||||
chat: &ChatService,
|
||||
req: Request<Incoming>,
|
||||
from_go: bool,
|
||||
) -> Response<Body> {
|
||||
let (parts, body) = req.into_parts();
|
||||
let version = if from_go {
|
||||
ctx.internal
|
||||
.auth()
|
||||
.await
|
||||
.map(|i| i.version)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
match ctx.guard(&parts.headers).await {
|
||||
Ok(v) => v,
|
||||
Err(refus) => return *refus,
|
||||
}
|
||||
};
|
||||
let conv = chat.sessions.conv();
|
||||
let conv_id = conv.id();
|
||||
let read = || async {
|
||||
Limited::new(body, 1 << 20)
|
||||
.collect()
|
||||
.await
|
||||
.map(|b| b.to_bytes().to_vec())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
match parts.uri.path() {
|
||||
"/api/chat/export" => {
|
||||
let o = opts_from_query(parts.uri.query().unwrap_or(""));
|
||||
let (messages, log, ctx_used) =
|
||||
conv.with_state(|st| (st.messages.clone(), st.log.clone(), st.ctx_used));
|
||||
let (body, ctype, ext) = if o.format == "json" {
|
||||
(
|
||||
export_json(&messages, &log, ctx_used, &o, &rfc3339_now(), &version),
|
||||
"application/json; charset=utf-8",
|
||||
"json",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
export_markdown(&log, &o, &fr_date_now(), &version).into_bytes(),
|
||||
"text/markdown; charset=utf-8",
|
||||
"md",
|
||||
)
|
||||
};
|
||||
let len = body.len();
|
||||
let mut r = Response::new(
|
||||
Full::new(bytes::Bytes::from(body))
|
||||
.map_err(|n| match n {})
|
||||
.boxed(),
|
||||
);
|
||||
let h = r.headers_mut();
|
||||
h.insert(CONTENT_TYPE, HeaderValue::from_static(ctype));
|
||||
if let Ok(v) = HeaderValue::from_str(&format!(
|
||||
"attachment; filename=\"{}\"",
|
||||
export_filename(ext)
|
||||
)) {
|
||||
h.insert(CONTENT_DISPOSITION, v);
|
||||
}
|
||||
h.insert(CONTENT_LENGTH, HeaderValue::from(len));
|
||||
r
|
||||
}
|
||||
"/api/chat/mode" if parts.method == Method::POST => {
|
||||
let raw = read().await;
|
||||
let d = gojson::decode_struct(&raw, MODE_REQ_TYPE, &[("mode", Kind::Str)]);
|
||||
if let Some(e) = d.err.clone() {
|
||||
return fail(StatusCode::BAD_REQUEST, e);
|
||||
}
|
||||
let mode = d.str("mode");
|
||||
if mode != "code" && mode != "chat" {
|
||||
return fail(StatusCode::BAD_REQUEST, "mode inconnu (chat|code)");
|
||||
}
|
||||
// chat = défaut = clé absente.
|
||||
let res = if mode == "code" {
|
||||
ctx.store
|
||||
.put(BK_CHAT, &mode_key(&conv_id), b"code".to_vec())
|
||||
.await
|
||||
} else {
|
||||
ctx.store.del(BK_CHAT, &mode_key(&conv_id)).await
|
||||
};
|
||||
if let Err(e) = res {
|
||||
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
|
||||
}
|
||||
let mut d = Map::new();
|
||||
d.insert("mode".into(), json!(mode));
|
||||
chat.push_delta(d);
|
||||
send_json(StatusCode::OK, &json!({ "ok": true, "mode": mode }))
|
||||
}
|
||||
"/api/chat/mode" => {
|
||||
let code = ctx.store.get_str(BK_CHAT, &mode_key(&conv_id)).await == "code";
|
||||
send_json(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"ok": true,
|
||||
"mode": if code { "code" } else { "chat" },
|
||||
"criteria": crit_list(ctx, &conv_id).await,
|
||||
}),
|
||||
)
|
||||
}
|
||||
"/api/chat/criteria" => {
|
||||
if parts.method != Method::POST {
|
||||
return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu");
|
||||
}
|
||||
let raw = read().await;
|
||||
let list = match decode_criteria(&raw) {
|
||||
Ok(l) => l,
|
||||
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let clean = clean_criteria(list);
|
||||
// critSave : une liste vide supprime la clé.
|
||||
let _ = if clean.is_empty() {
|
||||
ctx.store.del(BK_CHAT, &crit_key(&conv_id)).await
|
||||
} else {
|
||||
ctx.store
|
||||
.put_seal(
|
||||
BK_CHAT,
|
||||
&crit_key(&conv_id),
|
||||
gojson::marshal(&clean).into_bytes(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("{e:?}"))
|
||||
};
|
||||
// critNotify.
|
||||
let mut d = Map::new();
|
||||
d.insert("criteria".into(), json!(clean));
|
||||
d.insert("ts".into(), json!(now_ms()));
|
||||
chat.push_delta(d);
|
||||
let out = if clean.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(clean)
|
||||
};
|
||||
send_json(StatusCode::OK, &json!({ "ok": true, "criteria": out }))
|
||||
}
|
||||
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn atoi_comme_go() {
|
||||
assert_eq!(go_atoi("12"), Some(12));
|
||||
assert_eq!(go_atoi("+3"), Some(3));
|
||||
assert_eq!(go_atoi("-2"), Some(-2));
|
||||
assert_eq!(go_atoi("1.5"), None);
|
||||
assert_eq!(go_atoi(" 1"), None);
|
||||
assert_eq!(go_atoi(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indentation_html_echappee() {
|
||||
let b = marshal_indent(&json!({"a": "<b> & c", "l": []}));
|
||||
assert_eq!(
|
||||
String::from_utf8(b).unwrap(),
|
||||
"{\n \"a\": \"\\u003cb\\u003e \\u0026 c\",\n \"l\": []\n}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod api;
|
||||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod chatapi;
|
||||
pub mod chatx;
|
||||
pub mod child;
|
||||
pub mod convfiles;
|
||||
pub mod dictate;
|
||||
|
||||
@@ -206,6 +206,11 @@ impl Proxy {
|
||||
if api::handles(req.method(), path) {
|
||||
return api::serve(&self.ctx, req).await;
|
||||
}
|
||||
if let Some(chat) = &self.ctx.chat
|
||||
&& crate::chatx::handles(path)
|
||||
{
|
||||
return crate::chatx::serve(&self.ctx, chat, req, from_go).await;
|
||||
}
|
||||
if let Some(chat) = &self.ctx.chat
|
||||
&& chatapi::handles(path)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Lot 19 : export, mode Code et critères de la discussion tenus par la
|
||||
//! façade quand elle tient la conversation (`LOKI_CHAT=rust`).
|
||||
//!
|
||||
//! Le journal tiré du Go (`internal/loki/parity_chatx_test.go` →
|
||||
//! `tests/parity_chatx/chatx.json`) est rejoué : l'export Markdown et JSON de
|
||||
//! la même conversation sous chaque jeu d'options, puis les appels aux routes
|
||||
//! du mode et des critères par la façade, avec la base et les événements du
|
||||
//! fil après chacun.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::fakego::{self, CryptMode};
|
||||
use loki_rs::chat::persist::decode_state;
|
||||
use loki_rs::chatx::{self as cx, ExportOpts};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("parity_chatx/chatx.json")).expect("journal")
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parite_de_l_export() {
|
||||
let fx = fixture();
|
||||
// La conversation VIVANTE, images comprises (decode_state, comme le
|
||||
// rechargement du Go, les retire d'un fil relu).
|
||||
let c = &fx["conversation"];
|
||||
let conv = decode_state(c.to_string().as_bytes());
|
||||
let messages: Vec<loki_rs::chat::types::Message> =
|
||||
serde_json::from_value(c["messages"].clone()).unwrap();
|
||||
let log: Vec<loki_rs::chat::types::LogEvent> =
|
||||
serde_json::from_value(c["log"].clone()).unwrap();
|
||||
assert_eq!(conv.log, log);
|
||||
for c in fx["exports"].as_array().unwrap() {
|
||||
let q = s(&c["query"]);
|
||||
let o = cx::opts_from_query(q);
|
||||
let want = &c["opts"];
|
||||
assert_eq!(
|
||||
o,
|
||||
ExportOpts {
|
||||
format: s(&want["format"]).into(),
|
||||
reasoning: want["reasoning"].as_bool().unwrap(),
|
||||
tools: want["tools"].as_bool().unwrap(),
|
||||
results: want["results"].as_bool().unwrap(),
|
||||
turns: want["turns"].as_i64().unwrap(),
|
||||
},
|
||||
"options de {q:?}"
|
||||
);
|
||||
let body = if o.format == "json" {
|
||||
String::from_utf8(cx::export_json(
|
||||
&messages,
|
||||
&log,
|
||||
conv.ctx_used,
|
||||
&o,
|
||||
"{{DATE}}",
|
||||
"{{VERSION}}",
|
||||
))
|
||||
.unwrap()
|
||||
} else {
|
||||
cx::export_markdown(&log, &o, "{{DATE}}", "{{VERSION}}")
|
||||
};
|
||||
assert_eq!(body, s(&c["body"]), "export de {q:?}");
|
||||
}
|
||||
let o = ExportOpts::default();
|
||||
assert_eq!(
|
||||
cx::export_markdown(&[], &o, "{{DATE}}", "{{VERSION}}"),
|
||||
s(&fx["empty_md"])
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8(cx::export_json(&[], &[], 0, &o, "{{DATE}}", "{{VERSION}}")).unwrap(),
|
||||
s(&fx["empty_json"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parite_du_mode_et_des_criteres() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, go, _stop, chat) = fakego::start_with(
|
||||
tmp.path().to_path_buf(),
|
||||
fakego::Opts {
|
||||
chat: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let chat = chat.unwrap();
|
||||
*go.crypt_mode.lock().unwrap() = CryptMode::Plain;
|
||||
let conv = chat.sessions.conv();
|
||||
let id = conv.id();
|
||||
let log_len = || conv.with_state(|st| st.log.len());
|
||||
for st in fixture()["steps"].as_array().unwrap() {
|
||||
let name = s(&st["name"]);
|
||||
let before = log_len();
|
||||
let (code, body) = fakego::send(
|
||||
facade,
|
||||
s(&st["method"]),
|
||||
s(&st["path"]),
|
||||
&[],
|
||||
st["body"].as_str().unwrap_or(""),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
code.as_u16() as i64,
|
||||
st["status"].as_i64().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
let out: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(out, st["out"], "{name}");
|
||||
assert_eq!(
|
||||
go.get("chat", &format!("mode:{id}")).unwrap_or_default(),
|
||||
s(&st["mode"]),
|
||||
"{name} : mode"
|
||||
);
|
||||
let crit: Value = go
|
||||
.get("chat", &format!("crit:{id}"))
|
||||
.map(|v| serde_json::from_str(&v).unwrap())
|
||||
.unwrap_or(Value::Null);
|
||||
assert_eq!(crit, st["criteria"], "{name} : critères");
|
||||
let deltas: Vec<Value> = conv.with_state(|s| {
|
||||
s.log[before..]
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut d = e.delta.clone();
|
||||
d.remove("ts");
|
||||
Value::Object(d)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let want = st["deltas"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(deltas, want, "{name} : fil");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_par_la_route() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, _go, _stop, chat) = fakego::start_with(
|
||||
tmp.path().to_path_buf(),
|
||||
fakego::Opts {
|
||||
chat: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let chat = chat.unwrap();
|
||||
let conv = chat.sessions.conv();
|
||||
let mut d = serde_json::Map::new();
|
||||
d.insert("user".into(), json!("Salut"));
|
||||
conv.append_delta(conv.epoch(), d);
|
||||
let mut d = serde_json::Map::new();
|
||||
d.insert("content".into(), json!("Bonjour !"));
|
||||
conv.append_delta(conv.epoch(), d);
|
||||
let (code, headers, body) =
|
||||
fakego::get(facade, "/api/chat/export?format=json&reasoning=0", &[]).await;
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(headers["content-type"], "application/json; charset=utf-8");
|
||||
let disp = headers["content-disposition"].to_str().unwrap();
|
||||
assert!(
|
||||
disp.starts_with("attachment; filename=\"loki-conversation-") && disp.ends_with(".json\""),
|
||||
"{disp}"
|
||||
);
|
||||
assert_eq!(headers["content-length"], body.len().to_string().as_str());
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["log"].as_array().unwrap().len(), 2);
|
||||
assert!(v["exported_at"].as_str().unwrap().contains('T'));
|
||||
let (_, headers, body) = fakego::get(facade, "/api/chat/export", &[]).await;
|
||||
assert_eq!(headers["content-type"], "text/markdown; charset=utf-8");
|
||||
let md = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(
|
||||
md.contains("## Vous\n\nSalut\n") && md.contains("## Loki\n\nBonjour !\n"),
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sans_la_conversation_ces_routes_restent_au_go() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
|
||||
for p in ["/api/chat/export", "/api/chat/mode", "/api/chat/criteria"] {
|
||||
let (_, _, b) = fakego::get(facade, p, &[]).await;
|
||||
assert_eq!(&b[..], format!("go:{p}").as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -1352,8 +1352,8 @@ async fn routes_du_chat_par_la_facade() {
|
||||
let (_, list) = call(facade, "GET", "/api/conversations", "").await;
|
||||
assert_eq!(list["conversations"][0]["title"], "Mon fil");
|
||||
|
||||
// Mode Code : servi par le Go, qui pousse son événement au journal d'ici
|
||||
// par l'API interne de la façade (jeton interne exigé).
|
||||
// Mode Code : servi par la façade (lot 19), son événement au journal
|
||||
// d'ici, sans passer par le Go.
|
||||
assert_eq!(
|
||||
call(facade, "POST", "/api/chat/mode", r#"{"mode":"code"}"#)
|
||||
.await
|
||||
@@ -1361,11 +1361,14 @@ async fn routes_du_chat_par_la_facade() {
|
||||
200
|
||||
);
|
||||
assert!(
|
||||
go.relayed
|
||||
!go.relayed
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&"/api/chat/mode".to_string())
|
||||
);
|
||||
assert_eq!(deltas(&chat, "mode"), [serde_json::json!("code")]);
|
||||
// Le Go pousse encore ses propres événements (indice de code, outil
|
||||
// criteria) par l'API interne de la façade (jeton interne exigé).
|
||||
let (code, _) = call(
|
||||
facade,
|
||||
"POST",
|
||||
@@ -1376,7 +1379,10 @@ async fn routes_du_chat_par_la_facade() {
|
||||
assert_eq!(code, 404, "sans le jeton, l'API interne n'existe pas");
|
||||
let code = call_internal(facade, "/internal/facade/v1/delta", r#"{"mode":"code"}"#).await;
|
||||
assert_eq!(code, 204);
|
||||
assert_eq!(deltas(&chat, "mode"), [serde_json::json!("code")]);
|
||||
assert_eq!(
|
||||
deltas(&chat, "mode"),
|
||||
[serde_json::json!("code"), serde_json::json!("code")]
|
||||
);
|
||||
// Fil déjà rempli : le déverrouillage ne le recharge pas.
|
||||
assert_eq!(
|
||||
call_internal(facade, "/internal/facade/v1/reload", "").await,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user