mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge branch 'claude/ajean-loki-container-fork-aep9r2'
This commit is contained in:
12 files changed
+586
-17
No files matched your search
@@ -71,7 +71,9 @@ var conv = func() *Conversation {
|
|||||||
// LoadConversation recharge l'état persisté au démarrage du process. Sans état
|
// LoadConversation recharge l'état persisté au démarrage du process. Sans état
|
||||||
// enregistré (première fois) on part d'une conversation vide.
|
// enregistré (première fois) on part d'une conversation vide.
|
||||||
func LoadConversation() {
|
func LoadConversation() {
|
||||||
b := getBytes(bkChat, "conversation")
|
// convEnsureActive reprend au passage le fil unique des versions
|
||||||
|
// précédentes (clé « conversation ») comme première discussion.
|
||||||
|
b := getBytes(bkChat, convKey(convEnsureActive()))
|
||||||
if len(b) == 0 {
|
if len(b) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,16 +85,44 @@ func LoadConversation() {
|
|||||||
conv.cancel = nil
|
conv.cancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
|
||||||
|
// neuf) et invalide les abonnés : l'epoch incrémenté leur fait vider l'écran et
|
||||||
|
// rejouer depuis zéro, exactement comme un reset. L'appelant NE doit PAS
|
||||||
|
// détenir mu.
|
||||||
|
func (c *Conversation) loadFrom(b []byte) {
|
||||||
|
c.Stop()
|
||||||
|
c.mu.Lock()
|
||||||
|
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
|
||||||
|
if len(b) > 0 {
|
||||||
|
_ = json.Unmarshal(b, c)
|
||||||
|
}
|
||||||
|
c.Generating = false
|
||||||
|
c.cancel = nil
|
||||||
|
c.epoch++
|
||||||
|
c.cond.Broadcast()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
||||||
// delta). L'appelant NE doit PAS détenir mu.
|
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
|
||||||
|
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||||
func (c *Conversation) persist() {
|
func (c *Conversation) persist() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
b, err := json.Marshal(c)
|
b, err := json.Marshal(c)
|
||||||
|
title := convSummary(c.Messages)
|
||||||
|
turns := 0
|
||||||
|
for _, ev := range c.Log {
|
||||||
|
if _, ok := ev.Delta["user"]; ok {
|
||||||
|
turns++
|
||||||
|
}
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = putBytes(bkChat, "conversation", b)
|
id := convEnsureActive()
|
||||||
|
_ = putBytes(bkChat, convKey(id), b)
|
||||||
|
convTouchMeta(id, title, turns)
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendDelta journalise un événement d'affichage et réveille les abonnés.
|
// appendDelta journalise un événement d'affichage et réveille les abonnés.
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// chat_sessions.go — plusieurs discussions au lieu d'un fil unique.
|
||||||
|
//
|
||||||
|
// L'amont ne connaît QU'UNE conversation, persistée sous la clé
|
||||||
|
// bkChat/"conversation" : « clear chat » l'effaçait définitivement. Ici on garde
|
||||||
|
// exactement la même machinerie (un seul `conv` en mémoire, mêmes flux SSE,
|
||||||
|
// même compactage) mais on la range dans un tiroir nommé :
|
||||||
|
//
|
||||||
|
// bkChat/index → liste des discussions (métadonnées, sans les messages)
|
||||||
|
// bkChat/active → identifiant de la discussion ouverte
|
||||||
|
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation)
|
||||||
|
//
|
||||||
|
// Basculer = enregistrer la discussion courante, charger l'autre en mémoire et
|
||||||
|
// incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et
|
||||||
|
// rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par
|
||||||
|
// Reset(), donc aucun code de rendu ni de flux à toucher.
|
||||||
|
//
|
||||||
|
// La discussion active est PARTAGÉE par tous les appareils, comme le fil unique
|
||||||
|
// de l'amont : changer de discussion sur le téléphone la change aussi sur le PC.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ckIndex = "index"
|
||||||
|
ckActive = "active"
|
||||||
|
ckLegacy = "conversation" // fil unique d'avant le multi-discussions
|
||||||
|
)
|
||||||
|
|
||||||
|
// convMeta décrit une discussion SANS ses messages : c'est ce que liste l'UI.
|
||||||
|
type convMeta struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Updated int64 `json:"updated"`
|
||||||
|
Turns int `json:"turns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func convKey(id string) string { return "conv:" + id }
|
||||||
|
|
||||||
|
func convIndex() []convMeta {
|
||||||
|
var idx []convMeta
|
||||||
|
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
|
||||||
|
_ = json.Unmarshal(b, &idx)
|
||||||
|
}
|
||||||
|
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
|
||||||
|
// discussions, et l'UI n'a rien à trier.
|
||||||
|
sort.SliceStable(idx, func(i, j int) bool { return idx[i].Updated > idx[j].Updated })
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func convIndexSave(idx []convMeta) {
|
||||||
|
b, err := json.Marshal(idx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = putBytes(bkChat, ckIndex, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
|
||||||
|
// unique de l'amont s'il y en a un (migration silencieuse, une seule fois).
|
||||||
|
func convEnsureActive() string {
|
||||||
|
if id := getStr(bkChat, ckActive); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
id := newConvID()
|
||||||
|
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
|
||||||
|
// Migration : le fil unique devient la première discussion. On garde la
|
||||||
|
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
|
||||||
|
_ = putBytes(bkChat, convKey(id), legacy)
|
||||||
|
}
|
||||||
|
_ = putStr(bkChat, ckActive, id)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConvID : horodatage en nanosecondes — monotone, lisible au tri, et sans
|
||||||
|
// dépendance à un générateur aléatoire.
|
||||||
|
func newConvID() string { return fmt.Sprintf("c%d", time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// convTouchMeta rafraîchit les métadonnées de la discussion active après un
|
||||||
|
// enregistrement : titre (déduit du premier message si l'utilisateur n'en a pas
|
||||||
|
// choisi), date de modification et nombre d'échanges.
|
||||||
|
func convTouchMeta(id string, title string, turns int) {
|
||||||
|
idx := convIndex()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for i := range idx {
|
||||||
|
if idx[i].ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx[i].Updated = now
|
||||||
|
idx[i].Turns = turns
|
||||||
|
if idx[i].Title == "" {
|
||||||
|
idx[i].Title = title
|
||||||
|
}
|
||||||
|
convIndexSave(idx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
|
||||||
|
// message, on laisse vide : l'UI affiche « Nouvelle discussion » et le titre se
|
||||||
|
// posera tout seul au premier échange.
|
||||||
|
func convSummary(msgs []Message) string {
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role != "user" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, _ := m.Content.(string)
|
||||||
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Découpe sur les RUNES : tronquer des octets couperait un caractère
|
||||||
|
// accentué en deux et produirait un titre invalide.
|
||||||
|
if r := []rune(s); len(r) > 60 {
|
||||||
|
s = strings.TrimSpace(string(r[:60])) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI).
|
||||||
|
func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() }
|
||||||
|
|
||||||
|
// convSwitch bascule sur une autre discussion : la courante est enregistrée,
|
||||||
|
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
|
||||||
|
// rejouent le nouveau fil.
|
||||||
|
func convSwitch(id string) error {
|
||||||
|
if id == "" {
|
||||||
|
return fmt.Errorf("identifiant manquant")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, m := range convIndex() {
|
||||||
|
if m.ID == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("discussion introuvable")
|
||||||
|
}
|
||||||
|
if id == getStr(bkChat, ckActive) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conv.persist() // fige la discussion qu'on quitte
|
||||||
|
_ = putStr(bkChat, ckActive, id)
|
||||||
|
conv.loadFrom(getBytes(bkChat, convKey(id)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convCreate crée une discussion vide et l'ouvre, SANS enregistrer la courante.
|
||||||
|
// Séparé de convNew parce que la suppression de la dernière discussion doit
|
||||||
|
// repartir d'un fil neuf sans ressusciter celui qu'on vient d'effacer.
|
||||||
|
func convCreate() string {
|
||||||
|
id := newConvID()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
|
||||||
|
_ = putStr(bkChat, ckActive, id)
|
||||||
|
conv.loadFrom(nil)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// convNew met de côté la discussion courante puis en ouvre une neuve.
|
||||||
|
func convNew() string {
|
||||||
|
conv.persist()
|
||||||
|
return convCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func convRename(id, title string) error {
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if id == "" || title == "" {
|
||||||
|
return fmt.Errorf("identifiant ou titre manquant")
|
||||||
|
}
|
||||||
|
idx := convIndex()
|
||||||
|
for i := range idx {
|
||||||
|
if idx[i].ID == id {
|
||||||
|
idx[i].Title = title
|
||||||
|
convIndexSave(idx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("discussion introuvable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// convDelete supprime une discussion. Supprimer celle qui est ouverte bascule
|
||||||
|
// sur la plus récente restante — ou sur une discussion neuve s'il n'en reste
|
||||||
|
// aucune : il y a TOUJOURS une discussion active.
|
||||||
|
func convDelete(id string) error {
|
||||||
|
idx := convIndex()
|
||||||
|
next := make([]convMeta, 0, len(idx))
|
||||||
|
found := false
|
||||||
|
for _, m := range idx {
|
||||||
|
if m.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, m)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("discussion introuvable")
|
||||||
|
}
|
||||||
|
convIndexSave(next)
|
||||||
|
_ = putBytes(bkChat, convKey(id), nil)
|
||||||
|
if id != getStr(bkChat, ckActive) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(next) == 0 {
|
||||||
|
convCreate() // surtout pas convNew : il réenregistrerait la supprimée
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = putStr(bkChat, ckActive, next[0].ID)
|
||||||
|
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -239,6 +239,10 @@ func cmdUpdate(args []string) error {
|
|||||||
checkOnly = true
|
checkOnly = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if containerManaged() {
|
||||||
|
fmt.Println(containerUpdateNote)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
fmt.Println("recherche de la dernière version…")
|
fmt.Println("recherche de la dernière version…")
|
||||||
info, err := checkForUpdate()
|
info, err := checkForUpdate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -422,8 +426,21 @@ func cleanupOldBinary() {
|
|||||||
removeOldBinaries(filepath.Join(filepath.Dir(exe), "loki.exe"))
|
removeOldBinaries(filepath.Join(filepath.Dir(exe), "loki.exe"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// containerManaged : le binaire appartient à l'image Docker, la mise à jour
|
||||||
|
// passe donc par elle (docker compose pull) et non par un remplacement du
|
||||||
|
// fichier — que l'image, en lecture seule à l'exécution, ne permettrait pas.
|
||||||
|
// Sans ce garde-fou, l'UI interrogeait les releases GitHub du dépôt du fork,
|
||||||
|
// qui n'en publie aucune : « GitHub a répondu 404 Not Found ».
|
||||||
|
func containerManaged() bool { return os.Getenv("LOKI_CONTAINER") == "1" }
|
||||||
|
|
||||||
|
const containerUpdateNote = "Mise à jour par l'image Docker : docker compose pull && docker compose up -d"
|
||||||
|
|
||||||
// handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI.
|
// handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI.
|
||||||
func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if containerManaged() {
|
||||||
|
sendJSON(w, 200, map[string]any{"current": Version, "available": false, "note": containerUpdateNote})
|
||||||
|
return
|
||||||
|
}
|
||||||
info, err := checkForUpdate()
|
info, err := checkForUpdate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendJSON(w, 200, map[string]any{"current": Version, "available": false, "error": err.Error()})
|
sendJSON(w, 200, map[string]any{"current": Version, "available": false, "error": err.Error()})
|
||||||
@@ -437,6 +454,10 @@ func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
// d'UI (loki-ui : il sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans
|
// d'UI (loki-ui : il sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans
|
||||||
// avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut.
|
// avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut.
|
||||||
func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
|
func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if containerManaged() {
|
||||||
|
sendJSON(w, 409, map[string]any{"ok": false, "error": containerUpdateNote})
|
||||||
|
return
|
||||||
|
}
|
||||||
newVer, err := applyUpdate()
|
newVer, err := applyUpdate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 500 : un client script peut tester le statut HTTP ; l'UI, elle, lit le JSON.
|
// 500 : un client script peut tester le statut HTTP ; l'UI, elle, lit le JSON.
|
||||||
|
|||||||
+121
-4
@@ -139,6 +139,13 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
.brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3}
|
.brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3}
|
||||||
.brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px}
|
.brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px}
|
||||||
.brand-fork a:hover{color:var(--accent)}
|
.brand-fork a:hover{color:var(--accent)}
|
||||||
|
/* Discussions : mêmes lignes que les presets, plus deux actions au survol.
|
||||||
|
Elles restent visibles au clavier (:focus-within) pour rester atteignables
|
||||||
|
sans souris. */
|
||||||
|
.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||||||
|
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
|
||||||
|
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
|
||||||
|
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||||
@@ -1213,6 +1220,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
|||||||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
||||||
|
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
|
||||||
|
actif est partagé par tous les appareils (comme la conversation unique
|
||||||
|
d'origine) : basculer ici bascule partout. -->
|
||||||
|
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion">+</button></summary>
|
||||||
|
<div id="conv-list" class="slist"></div>
|
||||||
|
</details>
|
||||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||||
@@ -2354,7 +2367,10 @@ async function checkUpdate(){
|
|||||||
b.disabled=true; msg.textContent='Vérification…';
|
b.disabled=true; msg.textContent='Vérification…';
|
||||||
try{
|
try{
|
||||||
const r=await jget('/api/update');
|
const r=await jget('/api/update');
|
||||||
if(r.error){ msg.textContent='Erreur : '+r.error; }
|
// En conteneur, la mise à jour passe par l'image : on l'explique au lieu de
|
||||||
|
// proposer un remplacement de binaire impossible ici.
|
||||||
|
if(r.note){ msg.textContent=r.note; }
|
||||||
|
else if(r.error){ msg.textContent='Erreur : '+r.error; }
|
||||||
else if(r.available){
|
else if(r.available){
|
||||||
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
||||||
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
||||||
@@ -2961,7 +2977,7 @@ async function loadAll(){
|
|||||||
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
||||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||||
// hauteurs réservées en place pour toujours.
|
// hauteurs réservées en place pour toujours.
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||||
}
|
}
|
||||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||||
@@ -4475,7 +4491,10 @@ function handleDelta(d){
|
|||||||
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
||||||
syncChatEmpty();
|
syncChatEmpty();
|
||||||
return; }
|
return; }
|
||||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
|
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
|
||||||
|
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||||
|
// a pu être déclenchée depuis un autre appareil.
|
||||||
|
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; }
|
||||||
if(d.user!==undefined){
|
if(d.user!==undefined){
|
||||||
newTurn();
|
newTurn();
|
||||||
let el=PENDING;
|
let el=PENDING;
|
||||||
@@ -4485,7 +4504,10 @@ function handleDelta(d){
|
|||||||
// bulle neuve — sinon elles apparaîtraient en double.
|
// bulle neuve — sinon elles apparaîtraient en double.
|
||||||
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
||||||
setBusy(true); T.typingEl=addTyping(); return; }
|
setBusy(true); T.typingEl=addTyping(); return; }
|
||||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
|
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||||
|
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||||
|
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||||
|
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
@@ -5727,4 +5749,99 @@ async function revokeNode(id){
|
|||||||
await jpost('/api/node/revoke', {id});
|
await jpost('/api/node/revoke', {id});
|
||||||
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
|
hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué');
|
||||||
}
|
}
|
||||||
|
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
||||||
|
//
|
||||||
|
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
|
||||||
|
// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un
|
||||||
|
// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat —
|
||||||
|
// on ne fait que rafraîchir la liste.
|
||||||
|
//
|
||||||
|
// Les titres viennent du premier message de l'utilisateur : ils sont construits
|
||||||
|
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
|
||||||
|
// balisage ni les gestionnaires d'événement.
|
||||||
|
|
||||||
|
async function loadConversations(){
|
||||||
|
let r;
|
||||||
|
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||||
|
const cont = document.getElementById('conv-list');
|
||||||
|
if(!cont) return;
|
||||||
|
cont.innerHTML = '';
|
||||||
|
const list = r.conversations || [];
|
||||||
|
if(!list.length){ cont.innerHTML = '<span class="muted">(aucune)</span>'; return; }
|
||||||
|
for(const c of list){
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'preset' + (c.id === r.active ? ' active' : '');
|
||||||
|
row.onclick = () => convSwitch(c.id);
|
||||||
|
|
||||||
|
const info = document.createElement('div'); info.className = 'preset-info';
|
||||||
|
const nm = document.createElement('div'); nm.className = 'preset-name';
|
||||||
|
if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
|
||||||
|
const title = c.title || 'Nouvelle discussion';
|
||||||
|
nm.appendChild(document.createTextNode(title));
|
||||||
|
nm.title = title;
|
||||||
|
const meta = document.createElement('div'); meta.className = 'preset-meta';
|
||||||
|
const when = document.createElement('span'); when.className = 'muted';
|
||||||
|
when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||||||
|
meta.appendChild(when);
|
||||||
|
info.appendChild(nm); info.appendChild(meta);
|
||||||
|
|
||||||
|
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
|
||||||
|
const acts = document.createElement('div'); acts.className = 'conv-acts';
|
||||||
|
const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎';
|
||||||
|
ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
|
||||||
|
const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑';
|
||||||
|
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
|
||||||
|
acts.appendChild(ren); acts.appendChild(del);
|
||||||
|
|
||||||
|
row.appendChild(info); row.appendChild(acts);
|
||||||
|
cont.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
|
||||||
|
// discussions se lit d'un coup d'œil, pas au timestamp complet.
|
||||||
|
function convWhen(unix){
|
||||||
|
if(!unix) return '';
|
||||||
|
const d = new Date(unix * 1000), now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
return sameDay
|
||||||
|
? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})
|
||||||
|
: d.toLocaleDateString([], {day:'2-digit', month:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convNew(){
|
||||||
|
try{
|
||||||
|
await jpost('/api/conversations/new', {});
|
||||||
|
toast('nouvelle discussion');
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convSwitch(id){
|
||||||
|
try{
|
||||||
|
const r = await jpost('/api/conversations/switch', {id});
|
||||||
|
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convRename(id, current){
|
||||||
|
const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'});
|
||||||
|
if(!t) return;
|
||||||
|
try{
|
||||||
|
const r = await jpost('/api/conversations/rename', {id, title:t});
|
||||||
|
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convDelete(id, title){
|
||||||
|
if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.',
|
||||||
|
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
|
||||||
|
try{
|
||||||
|
const r = await jpost('/api/conversations/delete', {id});
|
||||||
|
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
@@ -38,6 +38,12 @@
|
|||||||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
<div class="brand-fork">fork de <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener noreferrer" title="AJEAN par nathaninline — licence MIT">AJEAN</a></div>
|
||||||
|
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
|
||||||
|
actif est partagé par tous les appareils (comme la conversation unique
|
||||||
|
d'origine) : basculer ici bascule partout. -->
|
||||||
|
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion">+</button></summary>
|
||||||
|
<div id="conv-list" class="slist"></div>
|
||||||
|
</details>
|
||||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ async function checkUpdate(){
|
|||||||
b.disabled=true; msg.textContent='Vérification…';
|
b.disabled=true; msg.textContent='Vérification…';
|
||||||
try{
|
try{
|
||||||
const r=await jget('/api/update');
|
const r=await jget('/api/update');
|
||||||
if(r.error){ msg.textContent='Erreur : '+r.error; }
|
// En conteneur, la mise à jour passe par l'image : on l'explique au lieu de
|
||||||
|
// proposer un remplacement de binaire impossible ici.
|
||||||
|
if(r.note){ msg.textContent=r.note; }
|
||||||
|
else if(r.error){ msg.textContent='Erreur : '+r.error; }
|
||||||
else if(r.available){
|
else if(r.available){
|
||||||
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
||||||
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ async function loadAll(){
|
|||||||
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
|
||||||
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
|
||||||
// hauteurs réservées en place pour toujours.
|
// hauteurs réservées en place pour toujours.
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
|
||||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||||
}
|
}
|
||||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||||
@@ -152,7 +152,10 @@ function handleDelta(d){
|
|||||||
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
||||||
syncChatEmpty();
|
syncChatEmpty();
|
||||||
return; }
|
return; }
|
||||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
|
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
|
||||||
|
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||||
|
// a pu être déclenchée depuis un autre appareil.
|
||||||
|
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); return; }
|
||||||
if(d.user!==undefined){
|
if(d.user!==undefined){
|
||||||
newTurn();
|
newTurn();
|
||||||
let el=PENDING;
|
let el=PENDING;
|
||||||
@@ -162,7 +165,10 @@ function handleDelta(d){
|
|||||||
// bulle neuve — sinon elles apparaîtraient en double.
|
// bulle neuve — sinon elles apparaîtraient en double.
|
||||||
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
||||||
setBusy(true); T.typingEl=addTyping(); return; }
|
setBusy(true); T.typingEl=addTyping(); return; }
|
||||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
|
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||||
|
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||||
|
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||||
|
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
|
||||||
|
//
|
||||||
|
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
|
||||||
|
// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un
|
||||||
|
// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat —
|
||||||
|
// on ne fait que rafraîchir la liste.
|
||||||
|
//
|
||||||
|
// Les titres viennent du premier message de l'utilisateur : ils sont construits
|
||||||
|
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
|
||||||
|
// balisage ni les gestionnaires d'événement.
|
||||||
|
|
||||||
|
async function loadConversations(){
|
||||||
|
let r;
|
||||||
|
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||||
|
const cont = document.getElementById('conv-list');
|
||||||
|
if(!cont) return;
|
||||||
|
cont.innerHTML = '';
|
||||||
|
const list = r.conversations || [];
|
||||||
|
if(!list.length){ cont.innerHTML = '<span class="muted">(aucune)</span>'; return; }
|
||||||
|
for(const c of list){
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'preset' + (c.id === r.active ? ' active' : '');
|
||||||
|
row.onclick = () => convSwitch(c.id);
|
||||||
|
|
||||||
|
const info = document.createElement('div'); info.className = 'preset-info';
|
||||||
|
const nm = document.createElement('div'); nm.className = 'preset-name';
|
||||||
|
if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
|
||||||
|
const title = c.title || 'Nouvelle discussion';
|
||||||
|
nm.appendChild(document.createTextNode(title));
|
||||||
|
nm.title = title;
|
||||||
|
const meta = document.createElement('div'); meta.className = 'preset-meta';
|
||||||
|
const when = document.createElement('span'); when.className = 'muted';
|
||||||
|
when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||||||
|
meta.appendChild(when);
|
||||||
|
info.appendChild(nm); info.appendChild(meta);
|
||||||
|
|
||||||
|
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
|
||||||
|
const acts = document.createElement('div'); acts.className = 'conv-acts';
|
||||||
|
const ren = document.createElement('button'); ren.className = 'iconbtn'; ren.textContent = '✎';
|
||||||
|
ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
|
||||||
|
const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑';
|
||||||
|
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
|
||||||
|
acts.appendChild(ren); acts.appendChild(del);
|
||||||
|
|
||||||
|
row.appendChild(info); row.appendChild(acts);
|
||||||
|
cont.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
|
||||||
|
// discussions se lit d'un coup d'œil, pas au timestamp complet.
|
||||||
|
function convWhen(unix){
|
||||||
|
if(!unix) return '';
|
||||||
|
const d = new Date(unix * 1000), now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
return sameDay
|
||||||
|
? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})
|
||||||
|
: d.toLocaleDateString([], {day:'2-digit', month:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convNew(){
|
||||||
|
try{
|
||||||
|
await jpost('/api/conversations/new', {});
|
||||||
|
toast('nouvelle discussion');
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convSwitch(id){
|
||||||
|
try{
|
||||||
|
const r = await jpost('/api/conversations/switch', {id});
|
||||||
|
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convRename(id, current){
|
||||||
|
const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'});
|
||||||
|
if(!t) return;
|
||||||
|
try{
|
||||||
|
const r = await jpost('/api/conversations/rename', {id, title:t});
|
||||||
|
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convDelete(id, title){
|
||||||
|
if(!await askConfirm('Supprimer « ' + title + ' » ? Cette discussion et son historique seront perdus.',
|
||||||
|
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
|
||||||
|
try{
|
||||||
|
const r = await jpost('/api/conversations/delete', {id});
|
||||||
|
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
|
||||||
|
loadConversations();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
}
|
||||||
@@ -113,6 +113,13 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
.brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3}
|
.brand-fork{font-size:10px;color:var(--dim);margin:0 0 14px;line-height:1.3}
|
||||||
.brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px}
|
.brand-fork a{color:var(--dim);text-decoration:underline;text-underline-offset:2px}
|
||||||
.brand-fork a:hover{color:var(--accent)}
|
.brand-fork a:hover{color:var(--accent)}
|
||||||
|
/* Discussions : mêmes lignes que les presets, plus deux actions au survol.
|
||||||
|
Elles restent visibles au clavier (:focus-within) pour rester atteignables
|
||||||
|
sans souris. */
|
||||||
|
.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||||||
|
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
|
||||||
|
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
|
||||||
|
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||||
|
|||||||
@@ -120,6 +120,60 @@ func handleChatReset(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 200, map[string]any{"ok": true})
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Discussions (plusieurs fils) ------------------------------------------
|
||||||
|
// Le fil actif est PARTAGÉ par tous les appareils, comme la conversation unique
|
||||||
|
// de l'amont : basculer sur le téléphone bascule aussi le PC (l'epoch fait
|
||||||
|
// rejouer le nouveau fil à tous les abonnés SSE).
|
||||||
|
|
||||||
|
func handleConvList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, active := ConvList()
|
||||||
|
if list == nil {
|
||||||
|
list = []convMeta{}
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"conversations": list, "active": active})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConvNew(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true, "id": convNew()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConvSwitch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err := convSwitch(req.ID); err != nil {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConvRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err := convRename(req.ID, req.Title); err != nil {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConvDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err := convDelete(req.ID); err != nil {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// handleChatCompact lance une compaction manuelle du contexte (bouton UI). La
|
// handleChatCompact lance une compaction manuelle du contexte (bouton UI). La
|
||||||
// progression est diffusée via le flux d'abonnement (compacting/compacted).
|
// progression est diffusée via le flux d'abonnement (compacting/compacted).
|
||||||
func handleChatCompact(w http.ResponseWriter, r *http.Request) {
|
func handleChatCompact(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -194,12 +194,18 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/restart", svcHandler("restart"))
|
api("/api/restart", svcHandler("restart"))
|
||||||
api("/api/bench", handleBench)
|
api("/api/bench", handleBench)
|
||||||
api("/api/bench/last", handleBenchLast)
|
api("/api/bench/last", handleBenchLast)
|
||||||
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
||||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||||
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
||||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||||
api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils)
|
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||||
|
// Discussions : liste, création, bascule, renommage, suppression.
|
||||||
|
api("/api/conversations", handleConvList)
|
||||||
|
api("/api/conversations/new", handleConvNew)
|
||||||
|
api("/api/conversations/switch", handleConvSwitch)
|
||||||
|
api("/api/conversations/rename", handleConvRename)
|
||||||
|
api("/api/conversations/delete", handleConvDelete)
|
||||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||||
|
|||||||
Reference in new issue
Block a user