mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +02:00
Chat : compactage sans redite, file multi-appareils, bascule par identifiant
Repris d'AJEAN 0.17.4. - Compactage : la dernière demande du torse n'est plus réinjectée quand la queue contient déjà un message utilisateur (le modèle répondait une seconde fois à une vieille question), ni quand le résumé a échoué (elle est déjà dans le torse dégraissé et réapparaissait après ses propres résultats d'outils). - Le texte écrit avant un appel d'outil n'est plus rangé deux fois dans l'historique du modèle (message tool_calls ET réponse finale) : du contexte gaspillé à chaque tour d'outil. - Deux appareils qui envoient en même temps : le second message part en file au lieu d'un 409. Une tâche planifiée garde le refus. - Un raisonnement qui reprend après du texte de réponse ouvre une nouvelle bulle sous la réponse au lieu de remplir l'ancienne, repliée au-dessus. - Bascule de preset par identifiant : le numéro seul visait un autre preset quand la liste avait bougé sur un autre appareil. Le numéro reste accepté. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f8c5d360f
commit
199d7f1e99
10 files changed
+159
-14
No files matched your search
@@ -408,8 +408,23 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
|||||||
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
|
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
|
||||||
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
|
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
|
||||||
// dans l'historique d'origine). Le torse reste entièrement compactable.
|
// dans l'historique d'origine). Le torse reste entièrement compactable.
|
||||||
|
//
|
||||||
|
// Seulement quand c'est nécessaire (AJEAN 0.17.4), sinon deux bugs :
|
||||||
|
// - la queue contient déjà un message `user` : la dernière demande du torse a
|
||||||
|
// DÉJÀ reçu sa réponse, la réinjecter juste avant la nouvelle faisait
|
||||||
|
// répondre le modèle une seconde fois à une vieille question ;
|
||||||
|
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
|
||||||
|
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
|
||||||
|
tailHasUser := false
|
||||||
|
for _, m := range msgs[tailStart:] {
|
||||||
|
if m.Role == "user" {
|
||||||
|
tailHasUser = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summarized := err == nil && !summaryLooksEmpty(summary)
|
||||||
var pending []Message
|
var pending []Message
|
||||||
for i := len(torso) - 1; i >= 0; i-- {
|
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
|
||||||
if torso[i].Role != "user" {
|
if torso[i].Role != "user" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,3 +177,30 @@ func TestEstimateTokensGrows(t *testing.T) {
|
|||||||
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
|
||||||
|
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
|
||||||
|
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
|
||||||
|
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
page := func(n int) Message {
|
||||||
|
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||||
|
}
|
||||||
|
msgs := []Message{
|
||||||
|
um("première question de la conversation"), am("ok"),
|
||||||
|
um("cherche les horaires du train pour Lyon"),
|
||||||
|
}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
msgs = append(msgs, atc("web_read"), page(i))
|
||||||
|
}
|
||||||
|
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||||
|
n := 0
|
||||||
|
for _, m := range out {
|
||||||
|
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package loki
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -568,6 +569,21 @@ func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
err := c.StartTurn(text, files, caps, temperature)
|
err := c.StartTurn(text, files, caps, temperature)
|
||||||
|
if errors.Is(err, ErrBusy) {
|
||||||
|
// Un autre tour a démarré entre le test ci-dessus et StartTurn (deux
|
||||||
|
// appareils qui envoient en même temps) : en file plutôt qu'un refus
|
||||||
|
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.Generating && c.runningTaskName == "" {
|
||||||
|
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
|
||||||
|
c.mu.Unlock()
|
||||||
|
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
|
||||||
|
// le prochain envoi.
|
||||||
|
c.startQueuedIfAny()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
|
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
|
||||||
// ce même message, son cid ne doit pas rester marqué comme reçu.
|
// ce même message, son cid ne doit pas rester marqué comme reçu.
|
||||||
@@ -774,6 +790,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
case ev.Err != nil:
|
case ev.Err != nil:
|
||||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||||
case ev.ToolUsed != nil:
|
case ev.ToolUsed != nil:
|
||||||
|
// Le texte écrit AVANT cet appel d'outil est déjà rangé dans le message
|
||||||
|
// assistant porteur des tool_calls (extra). Le garder aussi dans la
|
||||||
|
// réponse finale le doublait dans l'historique vu par le modèle — du
|
||||||
|
// contexte gaspillé à chaque tour d'outil (AJEAN 0.17.4).
|
||||||
|
content.Reset()
|
||||||
tu := map[string]any{
|
tu := map[string]any{
|
||||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||||
|
|||||||
@@ -4325,7 +4325,7 @@ async function loadPresets(){
|
|||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
const pend = !x.active && pendingPreset===i+1;
|
const pend = !x.active && pendingPreset===i+1;
|
||||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||||
row.onclick=()=>switchTo(i+1, x.name);
|
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||||
const info=document.createElement('div'); info.className='preset-info';
|
const info=document.createElement('div'); info.className='preset-info';
|
||||||
const nm=document.createElement('div'); nm.className='preset-name';
|
const nm=document.createElement('div'); nm.className='preset-name';
|
||||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||||
@@ -4931,14 +4931,15 @@ async function runBenchUI(){
|
|||||||
loadPresets();
|
loadPresets();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function switchTo(n,name){
|
async function switchTo(n,name,id){
|
||||||
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
||||||
toast('switching…');
|
toast('switching…');
|
||||||
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
||||||
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
||||||
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
||||||
pendingPreset = n; loadPresets();
|
pendingPreset = n; loadPresets();
|
||||||
const r=await jpost('/api/switch',{n:n});
|
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
|
||||||
|
const r=await jpost('/api/switch',id?{id:id}:{n:n});
|
||||||
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
||||||
toast('switched');
|
toast('switched');
|
||||||
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
||||||
@@ -7534,6 +7535,17 @@ function handleDelta(d){
|
|||||||
return; }
|
return; }
|
||||||
if(d.reasoning_content){
|
if(d.reasoning_content){
|
||||||
killTyping('reasoning');
|
killTyping('reasoning');
|
||||||
|
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
|
||||||
|
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
|
||||||
|
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
|
||||||
|
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
|
||||||
|
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
|
||||||
|
// la bulle vide et le raisonnement continue dans la sienne.
|
||||||
|
if(T.contentEl){
|
||||||
|
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
|
||||||
|
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
|
||||||
|
T.contentEl=null;
|
||||||
|
}
|
||||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||||
@@ -9592,7 +9604,7 @@ function onModelSwitch(sel){
|
|||||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||||
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
||||||
// pendant les quelques secondes du redémarrage.
|
// pendant les quelques secondes du redémarrage.
|
||||||
switchTo(n, name);
|
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||||
@@ -9670,6 +9682,7 @@ function paintModelSwitch(){
|
|||||||
MS_PRESETS.forEach((p, i) => {
|
MS_PRESETS.forEach((p, i) => {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(i + 1);
|
o.value = String(i + 1);
|
||||||
|
o.dataset.id = p.id || '';
|
||||||
o.textContent = p.name;
|
o.textContent = p.name;
|
||||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||||
g.appendChild(o);
|
g.appendChild(o);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async function loadPresets(){
|
|||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
const pend = !x.active && pendingPreset===i+1;
|
const pend = !x.active && pendingPreset===i+1;
|
||||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||||
row.onclick=()=>switchTo(i+1, x.name);
|
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||||
const info=document.createElement('div'); info.className='preset-info';
|
const info=document.createElement('div'); info.className='preset-info';
|
||||||
const nm=document.createElement('div'); nm.className='preset-name';
|
const nm=document.createElement('div'); nm.className='preset-name';
|
||||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||||
|
|||||||
@@ -41,14 +41,15 @@ async function runBenchUI(){
|
|||||||
loadPresets();
|
loadPresets();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function switchTo(n,name){
|
async function switchTo(n,name,id){
|
||||||
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
|
||||||
toast('switching…');
|
toast('switching…');
|
||||||
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
|
||||||
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
|
||||||
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
|
||||||
pendingPreset = n; loadPresets();
|
pendingPreset = n; loadPresets();
|
||||||
const r=await jpost('/api/switch',{n:n});
|
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
|
||||||
|
const r=await jpost('/api/switch',id?{id:id}:{n:n});
|
||||||
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
|
||||||
toast('switched');
|
toast('switched');
|
||||||
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
|
||||||
|
|||||||
@@ -560,6 +560,17 @@ function handleDelta(d){
|
|||||||
return; }
|
return; }
|
||||||
if(d.reasoning_content){
|
if(d.reasoning_content){
|
||||||
killTyping('reasoning');
|
killTyping('reasoning');
|
||||||
|
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
|
||||||
|
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
|
||||||
|
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
|
||||||
|
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
|
||||||
|
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
|
||||||
|
// la bulle vide et le raisonnement continue dans la sienne.
|
||||||
|
if(T.contentEl){
|
||||||
|
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
|
||||||
|
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
|
||||||
|
T.contentEl=null;
|
||||||
|
}
|
||||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function onModelSwitch(sel){
|
|||||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||||
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
||||||
// pendant les quelques secondes du redémarrage.
|
// pendant les quelques secondes du redémarrage.
|
||||||
switchTo(n, name);
|
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||||
@@ -143,6 +143,7 @@ function paintModelSwitch(){
|
|||||||
MS_PRESETS.forEach((p, i) => {
|
MS_PRESETS.forEach((p, i) => {
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(i + 1);
|
o.value = String(i + 1);
|
||||||
|
o.dataset.id = p.id || '';
|
||||||
o.textContent = p.name;
|
o.textContent = p.name;
|
||||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||||
g.appendChild(o);
|
g.appendChild(o);
|
||||||
|
|||||||
@@ -1219,8 +1219,14 @@ func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// id = identité stable du preset (nom de fichier). Le numéro seul est
|
||||||
|
// dangereux : si la liste a changé depuis l'affichage (réordonnée, preset
|
||||||
|
// ajouté ou supprimé depuis un autre appareil), la même position désigne un
|
||||||
|
// AUTRE preset, et on chargeait le mauvais (AJEAN 0.17.4). n reste accepté
|
||||||
|
// pour les anciens clients.
|
||||||
var req struct {
|
var req struct {
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
@@ -1231,11 +1237,26 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.N < 1 || req.N > len(list) {
|
var target Preset
|
||||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
|
if id := strings.TrimSpace(req.ID); id != "" {
|
||||||
return
|
found := false
|
||||||
|
for _, p := range list {
|
||||||
|
if p.ID == id {
|
||||||
|
target, found = p, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "preset introuvable (supprimé ou renommé ?)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if req.N < 1 || req.N > len(list) {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target = list[req.N-1]
|
||||||
}
|
}
|
||||||
target := list[req.N-1]
|
|
||||||
// On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
|
// On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
|
||||||
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
|
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
|
||||||
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
|
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// La bascule vise le preset par son id, pas par sa position : une liste
|
||||||
|
// réordonnée sur un autre appareil faisait charger un AUTRE preset. Presets
|
||||||
|
// externes ici : la bascule n'a alors aucun moteur local à redémarrer.
|
||||||
|
func TestBasculeParIdentifiant(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
if _, err := SavePreset("", "Alpha", externalPresetContent("https://a.example/v1", "modele-a", "", "", false)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
idB, err := SavePreset("", "Beta", externalPresetContent("https://b.example/v1", "modele-b", "", "", false))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
post := func(body string) int {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handleSwitch(w, httptest.NewRequest("POST", "/api/switch", strings.NewReader(body)))
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
if code := post(`{"id":"` + idB + `","n":1}`); code != 200 {
|
||||||
|
t.Fatalf("bascule par id : code %d", code)
|
||||||
|
}
|
||||||
|
if got := ReadConfig()[extKeyModel]; got != "modele-b" {
|
||||||
|
t.Fatalf("preset chargé = %q, attendu celui de l'id (modele-b) et non la position 1", got)
|
||||||
|
}
|
||||||
|
if code := post(`{"id":"inexistant"}`); code != 404 {
|
||||||
|
t.Fatalf("id inconnu : code %d, attendu 404", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user