mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf8635c61 | ||
|
|
69bf8d985d | ||
|
|
50fccdc9ad | ||
|
|
66072e4047 | ||
|
|
62573ff972 | ||
|
|
481028b023 | ||
|
|
86d711e772 | ||
|
|
ad107ed284 | ||
|
|
43489769f4 | ||
|
|
a333603ca6 | ||
|
|
f4090059f1 |
No files matched your search
+18
@@ -31,6 +31,20 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
|||||||
# limité à cette étape de build.
|
# limité à cette étape de build.
|
||||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||||
|
|
||||||
|
# ── Étape 1 bis : whisper-cli (dictée vocale) ───────────────────────────
|
||||||
|
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
|
||||||
|
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
|
||||||
|
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
|
||||||
|
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
|
||||||
|
FROM ubuntu:22.04 AS whisperbuild
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential cmake git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||||
|
&& cmake -S /w -B /w/build -DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
|
||||||
|
&& cmake --build /w/build -j --target whisper-cli
|
||||||
|
|
||||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||||
|
|
||||||
@@ -84,6 +98,9 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
|||||||
|
|
||||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||||
|
# Dictée vocale (POST /api/transcribe). Le modèle (~190 Mo) n'est PAS dans
|
||||||
|
# l'image : téléchargé au premier usage dans /data/whisper/.
|
||||||
|
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||||
|
|
||||||
@@ -92,6 +109,7 @@ ENV LOKI_CONTAINER=1 \
|
|||||||
LOKI_HOME=/data \
|
LOKI_HOME=/data \
|
||||||
LOKI_MODEL_DIRS=/models \
|
LOKI_MODEL_DIRS=/models \
|
||||||
LOKI_ENGINE_BIN=/app/llama-server \
|
LOKI_ENGINE_BIN=/app/llama-server \
|
||||||
|
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||||
LD_LIBRARY_PATH=/app \
|
LD_LIBRARY_PATH=/app \
|
||||||
NVIDIA_VISIBLE_DEVICES=all \
|
NVIDIA_VISIBLE_DEVICES=all \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
|||||||
@@ -320,8 +320,24 @@ Ajoutées par ce fork :
|
|||||||
`AGENT_BUDGET=off` le désactive complètement.
|
`AGENT_BUDGET=off` le désactive complètement.
|
||||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||||
dans le fil.
|
dans le fil.
|
||||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||||
OpenAI, actions) sont regroupés à part des réglages d'IA.
|
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||||
|
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||||
|
récentes en tête) et le moniteur machine.
|
||||||
|
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||||
|
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||||
|
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||||
|
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||||
|
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||||
|
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||||
|
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||||
|
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||||
|
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||||
|
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||||
|
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||||
|
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||||
|
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||||
|
l'affichage ne se fige plus.
|
||||||
|
|
||||||
Retirées par ce fork :
|
Retirées par ce fork :
|
||||||
|
|
||||||
|
|||||||
+43
-9
@@ -1,19 +1,53 @@
|
|||||||
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
|
# Loki 0.12.0
|
||||||
|
|
||||||
## La vision, configurable par preset
|
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
|
||||||
|
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
|
||||||
|
|
||||||
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
|
## Le sélecteur de l'en-tête charge vraiment un modèle
|
||||||
|
|
||||||
Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision.
|
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
|
||||||
|
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
|
||||||
|
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
|
||||||
|
créé, le sélecteur n'offrait aucun choix.
|
||||||
|
|
||||||
## Les images arrivent vraiment au modèle
|
## Dictée vocale
|
||||||
|
|
||||||
Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler.
|
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
|
||||||
|
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
|
||||||
|
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
|
||||||
|
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
|
||||||
|
|
||||||
|
## Cartes raisonnement/outils : taille fixe, défilement qui suit
|
||||||
|
|
||||||
|
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
|
||||||
|
génération — remonter lire le début est respecté. Les cartes d'outil suivent
|
||||||
|
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
|
||||||
|
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
|
||||||
|
texte complet est posé à la fin) : l'affichage ne se fige plus.
|
||||||
|
|
||||||
|
## Mesures
|
||||||
|
|
||||||
|
- La carte du temps total affiche la **vitesse moyenne de la conversation**
|
||||||
|
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
|
||||||
|
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
|
||||||
|
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
|
||||||
|
seulement les octets lus).
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
- Discussions triées par date de **création** (récentes en tête) : une
|
||||||
|
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
|
||||||
|
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
|
||||||
|
Performance.
|
||||||
|
|
||||||
|
## Rappel 0.11.0
|
||||||
|
|
||||||
|
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
|
||||||
|
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
|
||||||
|
l'interface ; tâches planifiées reprises de l'amont.
|
||||||
|
|
||||||
## Mise à jour
|
## Mise à jour
|
||||||
|
|
||||||
```
|
```
|
||||||
ajean update
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure.
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
|||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": {
|
"FileVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 9,
|
"Minor": 12,
|
||||||
"Patch": 4,
|
"Patch": 3,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 9,
|
"Minor": 12,
|
||||||
"Patch": 4,
|
"Patch": 3,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||||
"OriginalFilename": "loki.exe",
|
"OriginalFilename": "loki.exe",
|
||||||
"ProductName": "Loki",
|
"ProductName": "Loki",
|
||||||
"ProductVersion": "0.9.4",
|
"ProductVersion": "0.12.3",
|
||||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
+162
-23
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,15 +24,128 @@ import (
|
|||||||
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
||||||
// tort qu'il ne gère pas le drapeau.
|
// tort qu'il ne gère pas le drapeau.
|
||||||
func binSupportsReasoningFlag(bin string) bool {
|
func binSupportsReasoningFlag(bin string) bool {
|
||||||
|
return strings.Contains(binHelp(bin), "--reasoning ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
|
||||||
|
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
|
||||||
|
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
|
||||||
|
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
|
||||||
|
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
|
||||||
|
var (
|
||||||
|
helpMu sync.Mutex
|
||||||
|
helpCache = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func binHelp(bin string) string {
|
||||||
|
helpMu.Lock()
|
||||||
|
defer helpMu.Unlock()
|
||||||
|
if h, ok := helpCache[bin]; ok {
|
||||||
|
return h
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, bin, "--help")
|
cmd := exec.CommandContext(ctx, bin, "--help")
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd.Stdout, cmd.Stderr = &out, &out
|
cmd.Stdout, cmd.Stderr = &out, &out
|
||||||
if err := cmd.Run(); err != nil && out.Len() == 0 {
|
h := ""
|
||||||
return false // aide illisible : on ne prend pas le risque
|
if err := cmd.Run(); err == nil || out.Len() > 0 {
|
||||||
|
h = out.String()
|
||||||
}
|
}
|
||||||
return strings.Contains(out.String(), "--reasoning ")
|
helpCache[bin] = h
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||||
|
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
|
||||||
|
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
|
||||||
|
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
|
||||||
|
func binSupportsLoadMode(bin string) bool {
|
||||||
|
return strings.Contains(binHelp(bin), "--load-mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||||
|
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
|
||||||
|
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
|
||||||
|
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
|
||||||
|
func binSupportsNGLAuto(bin string) bool {
|
||||||
|
h := binHelp(bin)
|
||||||
|
i := strings.Index(h, "--n-gpu-layers")
|
||||||
|
if i < 0 {
|
||||||
|
i = strings.Index(h, "-ngl")
|
||||||
|
}
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
end := i + 400
|
||||||
|
if end > len(h) {
|
||||||
|
end = len(h)
|
||||||
|
}
|
||||||
|
return strings.Contains(h[i:end], "'auto'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
|
||||||
|
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
|
||||||
|
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
|
||||||
|
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
|
||||||
|
// — le réglage explicite du preset serait donc silencieusement écrasé par le
|
||||||
|
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
|
||||||
|
func hasAnyFlag(args []string, flags ...string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
name := a
|
||||||
|
if i := strings.IndexByte(name, '='); i > 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
for _, f := range flags {
|
||||||
|
if name == f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||||
|
// --load-mode, sur les moteurs qui le connaissent :
|
||||||
|
//
|
||||||
|
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||||
|
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||||
|
// --mmap → --load-mode mmap
|
||||||
|
//
|
||||||
|
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||||
|
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||||
|
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||||
|
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||||
|
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||||
|
if !supportsLoadMode {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
explicit := hasAnyFlag(args, "--load-mode")
|
||||||
|
mlock, nommap, mmap := false, false, false
|
||||||
|
out := make([]string, 0, len(args)+2)
|
||||||
|
for _, a := range args {
|
||||||
|
switch a {
|
||||||
|
case "--mlock":
|
||||||
|
mlock = true
|
||||||
|
case "--no-mmap":
|
||||||
|
nommap = true
|
||||||
|
case "--mmap":
|
||||||
|
mmap = true
|
||||||
|
default:
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if explicit || !(mlock || nommap || mmap) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
mode := "mmap"
|
||||||
|
switch {
|
||||||
|
case mlock:
|
||||||
|
mode = "mlock"
|
||||||
|
case nommap:
|
||||||
|
mode = "none"
|
||||||
|
}
|
||||||
|
return append(out, "--load-mode", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||||
@@ -99,6 +213,12 @@ func cmdServe(args []string) error {
|
|||||||
ktv := get("KV_TYPE_K", kv)
|
ktv := get("KV_TYPE_K", kv)
|
||||||
vtv := get("KV_TYPE_V", kv)
|
vtv := get("KV_TYPE_V", kv)
|
||||||
|
|
||||||
|
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
|
||||||
|
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
|
||||||
|
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
|
||||||
|
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
|
||||||
|
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
|
||||||
|
|
||||||
llmArgs := []string{bin,
|
llmArgs := []string{bin,
|
||||||
"-m", model,
|
"-m", model,
|
||||||
"-c", get("CTX", "32768"),
|
"-c", get("CTX", "32768"),
|
||||||
@@ -108,24 +228,43 @@ func cmdServe(args []string) error {
|
|||||||
"-ub", get("UBATCH", "512"),
|
"-ub", get("UBATCH", "512"),
|
||||||
"--host", get("HOST", "0.0.0.0"),
|
"--host", get("HOST", "0.0.0.0"),
|
||||||
"--port", get("PORT", "8080"),
|
"--port", get("PORT", "8080"),
|
||||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent 4 slots par
|
|
||||||
// défaut, soit des tampons de calcul GPU ×4 — pour un serveur mono-
|
|
||||||
// utilisateur comme Loki, c'est de la VRAM brûlée pour rien. Vécu : une
|
|
||||||
// config 27B/60k ctx qui tournait très bien est morte en « cudaMalloc
|
|
||||||
// failed: out of memory » après une mise à jour du moteur, uniquement à
|
|
||||||
// cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui veut
|
|
||||||
// vraiment servir plusieurs requêtes à la fois.
|
|
||||||
"--parallel", get("PARALLEL", "1"),
|
|
||||||
}
|
}
|
||||||
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
|
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
|
||||||
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
|
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
|
||||||
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
|
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
|
||||||
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
|
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
|
||||||
// débit effondré alors que le GPU tourne à 100 %.
|
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
|
||||||
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
|
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
|
||||||
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
|
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||||
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
|
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||||
llmArgs = append(llmArgs, "-ngl", ngl)
|
if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||||
|
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
|
||||||
|
}
|
||||||
|
// Couches GPU. Trois cas, dans cet ordre :
|
||||||
|
//
|
||||||
|
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche)
|
||||||
|
// NGL=auto → rien du tout (compat : anciens presets)
|
||||||
|
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
|
||||||
|
//
|
||||||
|
// Imposer un nombre désarme common_fit_params, qui mesure la VRAM libre et
|
||||||
|
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
|
||||||
|
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
|
||||||
|
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
|
||||||
|
// D'où « auto » par défaut dès que le moteur sait le faire. Sur un moteur
|
||||||
|
// plus ancien, qui ne connaît pas la valeur, on garde 999 : omettre -ngl le
|
||||||
|
// ferait tourner 100 % CPU.
|
||||||
|
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||||
|
ngl := get("NGL", "")
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(ngl, "auto"):
|
||||||
|
// rien : le moteur décide seul
|
||||||
|
case ngl != "":
|
||||||
|
llmArgs = append(llmArgs, "-ngl", ngl)
|
||||||
|
case binSupportsNGLAuto(bin):
|
||||||
|
llmArgs = append(llmArgs, "-ngl", "auto")
|
||||||
|
default:
|
||||||
|
llmArgs = append(llmArgs, "-ngl", "999")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ktv != "" {
|
if ktv != "" {
|
||||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||||
@@ -187,9 +326,9 @@ func cmdServe(args []string) error {
|
|||||||
if k, _ := effectiveAPIKeyErr(); k != "" {
|
if k, _ := effectiveAPIKeyErr(); k != "" {
|
||||||
llmArgs = append(llmArgs, "--api-key", k)
|
llmArgs = append(llmArgs, "--api-key", k)
|
||||||
}
|
}
|
||||||
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
|
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
|
||||||
// together so a path with spaces stays one argument.
|
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
|
||||||
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
|
llmArgs = append(llmArgs, extra...)
|
||||||
|
|
||||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||||
// mmproj-F16.gguf) still resolve.
|
// mmproj-F16.gguf) still resolve.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHasAnyFlag(t *testing.T) {
|
||||||
|
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
|
||||||
|
if !hasAnyFlag(args, "--parallel", "-np") {
|
||||||
|
t.Fatal("--parallel devrait être détecté")
|
||||||
|
}
|
||||||
|
// Forme --clé=valeur : le nom seul compte.
|
||||||
|
if !hasAnyFlag(args, "--load-mode") {
|
||||||
|
t.Fatal("--load-mode=… devrait être détecté")
|
||||||
|
}
|
||||||
|
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
|
||||||
|
t.Fatal("-ngl absent, ne devrait pas être détecté")
|
||||||
|
}
|
||||||
|
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
|
||||||
|
// ici "-ngl" n'est pas posé, seul --parallel l'est.
|
||||||
|
if hasAnyFlag(nil, "--parallel") {
|
||||||
|
t.Fatal("liste vide : rien à détecter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLoadFlags(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in []string
|
||||||
|
supports bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||||
|
[]string{"--mlock", "--no-mmap"}},
|
||||||
|
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||||
|
[]string{"-fa", "--load-mode", "mlock"}},
|
||||||
|
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||||
|
[]string{"--load-mode", "none"}},
|
||||||
|
{"mmap traduit", []string{"--mmap"}, true,
|
||||||
|
[]string{"--load-mode", "mmap"}},
|
||||||
|
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||||
|
[]string{"--load-mode", "mlock"}},
|
||||||
|
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||||
|
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||||
|
[]string{"--load-mode", "dio"}},
|
||||||
|
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
|
||||||
|
[]string{"-fa", "--n-cpu-moe", "8"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := normalizeLoadFlags(c.in, c.supports)
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.9.4"
|
const Version = "0.12.3"
|
||||||
|
|
||||||
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
||||||
func Main() {
|
func Main() {
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// procVmRSS lit la mémoire résidente (octets) d'un process — /proc/<pid>/status,
|
||||||
|
// ligne « VmRSS: N kB ». 0 si illisible.
|
||||||
|
func procVmRSS(pid int) int64 {
|
||||||
|
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
|
||||||
|
v = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB"))
|
||||||
|
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||||
|
return n * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
|
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
|
||||||
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
|
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
|
||||||
func engineLoadPct() int {
|
func engineLoadPct() int {
|
||||||
@@ -54,6 +72,16 @@ func engineLoadPct() int {
|
|||||||
if read < 0 {
|
if read < 0 {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
// Modèle déjà dans le cache disque (redémarrage à chaud) : llama-server ne
|
||||||
|
// LIT plus rien sur le disque — les pages viennent du cache, read_bytes
|
||||||
|
// reste à zéro et le pourcentage restait figé à 0 jusqu'au « prêt ». La
|
||||||
|
// mémoire résidente (VmRSS), elle, grandit quoi qu'il arrive : les pages du
|
||||||
|
// .gguf touchées via mmap y comptent, cache ou pas. On prend le plus avancé
|
||||||
|
// des deux — approximatif (l'offload GPU peut relâcher des pages), mais le
|
||||||
|
// compteur BOUGE au lieu de mentir.
|
||||||
|
if rss := procVmRSS(pid); rss > read {
|
||||||
|
read = rss
|
||||||
|
}
|
||||||
pct := int(read * 100 / size)
|
pct := int(read * 100 / size)
|
||||||
if pct > 99 {
|
if pct > 99 {
|
||||||
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
|
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
|
||||||
|
|||||||
+557
-195
@@ -255,7 +255,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
||||||
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
||||||
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
||||||
.addbtn{font-size:11px;padding:4px 11px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:999px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
/* Même gabarit que les autres boutons des panneaux (angle 3 px, 12 px) : la
|
||||||
|
pilule arrondie détonnait au milieu de boutons rectangulaires. */
|
||||||
|
.addbtn{font-size:12px;padding:6px 10px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
||||||
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
||||||
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
||||||
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
||||||
@@ -687,6 +689,16 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
|||||||
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
||||||
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
||||||
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
||||||
|
/* Raisonnement / outils : hauteur BORNÉE avec défilement interne. Un long
|
||||||
|
raisonnement (des milliers de tokens) faisait grandir la page de plusieurs
|
||||||
|
écrans ; la carte reste maintenant à taille fixe et suit la génération en
|
||||||
|
défilant toute seule (voir flushRender, 09-stream.js). L'animation de repli
|
||||||
|
garde son overflow:hidden via la hauteur inline — les deux cohabitent. */
|
||||||
|
.msg.reasoning .bodywrap,.msg.tool .bodywrap{max-height:280px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
||||||
|
/* Une capture d'écran ne se lit pas par le trou d'une serrure : la carte qui en
|
||||||
|
porte une échappe au plafond (l'image a déjà son propre max-height à 420 px,
|
||||||
|
cliquable plein écran via la visionneuse). */
|
||||||
|
.msg.tool .bodywrap:has(.tool-shot){max-height:none}
|
||||||
.msg.collapsible .bodywrap>.body{min-width:0}
|
.msg.collapsible .bodywrap>.body{min-width:0}
|
||||||
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
||||||
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
||||||
@@ -1768,7 +1780,7 @@ button:active:not(:disabled){transform:translateY(1px)}
|
|||||||
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
||||||
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
||||||
taille, sa forme et son alignement d'une couche différente. */
|
taille, sa forme et son alignement d'une couche différente. */
|
||||||
#inputbar #attach,#inputbar #files-btn,#send,#stop{
|
#inputbar #attach,#inputbar #files-btn,#inputbar #mic,#send,#stop{
|
||||||
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
||||||
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
||||||
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||||
@@ -1779,8 +1791,8 @@ button:active:not(:disabled){transform:translateY(1px)}
|
|||||||
#stop{display:none}
|
#stop{display:none}
|
||||||
html[data-busy="1"] #send{display:none}
|
html[data-busy="1"] #send{display:none}
|
||||||
html[data-busy="1"] #stop{display:inline-flex}
|
html[data-busy="1"] #stop{display:inline-flex}
|
||||||
#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
#inputbar #attach svg,#inputbar #files-btn svg,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
||||||
#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
|
#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic:hover{background:var(--row-bg-on);color:var(--text)}
|
||||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||||
#send{color:var(--accent)}
|
#send{color:var(--accent)}
|
||||||
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
||||||
@@ -2052,6 +2064,51 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
#criteria-list li.crit-pending{color:var(--dim)}
|
#criteria-list li.crit-pending{color:var(--dim)}
|
||||||
|
|
||||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||||
|
|
||||||
|
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
|
||||||
|
transcription. Le gabarit (34 px, fond au survol) vient de la règle commune
|
||||||
|
des boutons de saisie — #mic y est listé avec #attach et #files-btn. */
|
||||||
|
#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
|
||||||
|
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
|
||||||
|
#mic.busy{opacity:.55}
|
||||||
|
|
||||||
|
/* ── Réglages en modale ─────────────────────────────────────────────────────
|
||||||
|
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
|
||||||
|
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
|
||||||
|
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */
|
||||||
|
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
|
||||||
|
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
|
||||||
|
font-size:13px;color:var(--text);cursor:pointer;text-align:left}
|
||||||
|
#side-settings-btn:hover{background:var(--bubble)}
|
||||||
|
#side-settings-btn svg{flex:none;opacity:.7}
|
||||||
|
.set-card{max-width:860px;height:min(660px,92vh)}
|
||||||
|
.set-shell{display:flex;flex:1;min-height:0}
|
||||||
|
.set-nav{flex:0 0 200px;border-right:1px solid var(--border);overflow-y:auto;
|
||||||
|
padding:10px 8px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.set-nav-h{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);margin:12px 8px 4px}
|
||||||
|
.set-nav-h:first-child{margin-top:2px}
|
||||||
|
.set-nav button{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:7px 10px;
|
||||||
|
background:none;border:0;border-radius:3px;box-shadow:none;
|
||||||
|
font-size:12.5px;color:var(--text);cursor:pointer;text-align:left}
|
||||||
|
.set-nav button:hover{background:var(--bubble)}
|
||||||
|
.set-nav button.active{background:var(--bubble);font-weight:600}
|
||||||
|
.set-nav button .sbadge{margin-left:auto}
|
||||||
|
.set-body{flex:1;min-width:0;overflow-y:auto;padding:16px}
|
||||||
|
.set-pane{display:none}
|
||||||
|
.set-pane.active{display:block}
|
||||||
|
.set-pane-h{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin:0 0 12px}
|
||||||
|
.set-pane-h>span:first-child{flex:1;min-width:0}
|
||||||
|
/* Téléphone : la nav passe en rangée défilante au-dessus du panneau, et la
|
||||||
|
modale prend tout l'écran — un panneau de réglages ne se lit pas en timbre. */
|
||||||
|
@media(max-width:720px){
|
||||||
|
.set-ov{padding:0}
|
||||||
|
.set-card{max-width:none;height:100vh;height:100dvh;max-height:none;border-radius:0;border:0}
|
||||||
|
.set-shell{flex-direction:column}
|
||||||
|
.set-nav{flex:none;flex-direction:row;align-items:center;overflow-x:auto;overflow-y:hidden;
|
||||||
|
border-right:0;border-bottom:1px solid var(--border);padding:8px;white-space:nowrap}
|
||||||
|
.set-nav-h{display:none}
|
||||||
|
.set-nav button{width:auto;flex:none}
|
||||||
|
}
|
||||||
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
||||||
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
||||||
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
||||||
@@ -2155,9 +2212,193 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
latérale par défaut, c'est les discussions et la machine. Les sections
|
latérale par défaut, c'est les discussions et la machine. Les sections
|
||||||
ouvertes par programme (journal du moteur, installation llama.cpp)
|
ouvertes par programme (journal du moteur, installation llama.cpp)
|
||||||
passent par openDetails(), qui déplie aussi les parents. -->
|
passent par openDetails(), qui déplie aussi les parents. -->
|
||||||
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
|
</div><!-- /#side-scroll -->
|
||||||
<div class="sidegroup">
|
<!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
|
||||||
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
|
droite) — voir js/22-settings-modal.js ; les ids historiques des sections
|
||||||
|
(svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
|
||||||
|
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
|
||||||
|
même endroit, quel que soit le nombre de discussions. -->
|
||||||
|
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
|
||||||
|
<span>Paramètres</span>
|
||||||
|
</button>
|
||||||
|
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
||||||
|
jauges fines — visible en permanence sans jamais disputer la place au
|
||||||
|
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
||||||
|
bloc entier se replie d'un clic. -->
|
||||||
|
<div id="side-perf">
|
||||||
|
<div class="perf-head"><span>Performance</span></div>
|
||||||
|
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main" style="position:relative">
|
||||||
|
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
||||||
|
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
||||||
|
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
||||||
|
<header id="topbar">
|
||||||
|
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="topbar-title">Nouvelle discussion</div>
|
||||||
|
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||||
|
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div id="chat"></div>
|
||||||
|
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
||||||
|
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
||||||
|
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
||||||
|
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
||||||
|
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
||||||
|
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
||||||
|
pour le dessin des lettres. -->
|
||||||
|
<div id="chat-empty" aria-hidden="true">
|
||||||
|
<div id="ce-logo"></div>
|
||||||
|
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
||||||
|
pas de civilité ni d'accord qui obligerait à deviner. -->
|
||||||
|
<div id="ce-title">On commence par quoi ?</div>
|
||||||
|
</div>
|
||||||
|
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
||||||
|
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
||||||
|
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
||||||
|
<div id="composer">
|
||||||
|
<div id="ctxmeta">
|
||||||
|
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
|
||||||
|
de code : critères, vérification, LSP). Dans le pied de carte, pas
|
||||||
|
dans la barre de saisie déjà chargée — ça se règle par discussion,
|
||||||
|
pas par message. -->
|
||||||
|
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
|
||||||
|
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
|
||||||
|
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
|
||||||
|
</div>
|
||||||
|
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||||||
|
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||||||
|
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
||||||
|
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
|
<span id="ctx-text" class="muted">0 / 0</span>
|
||||||
|
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||||||
|
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
||||||
|
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||||||
|
<div id="attach-list"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||||
|
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||||
|
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
|
||||||
|
moment d'écrire le message, pas dans un éditeur de preset. -->
|
||||||
|
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
|
||||||
|
<option value="">auto</option>
|
||||||
|
<option value="none">aucune</option>
|
||||||
|
<option value="low">basse</option>
|
||||||
|
<option value="medium">moyenne</option>
|
||||||
|
<option value="high">haute</option>
|
||||||
|
<option value="xhigh">maximale</option>
|
||||||
|
</select>
|
||||||
|
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
|
||||||
|
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
|
||||||
|
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
|
||||||
|
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
||||||
|
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
||||||
|
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
||||||
|
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
||||||
|
ce qui sort. -->
|
||||||
|
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
||||||
|
reste porté par aria-label et l'infobulle — l'action garde son nom
|
||||||
|
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
||||||
|
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||||
|
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||||
|
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||||
|
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||||
|
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||||
|
utile à personne. -->
|
||||||
|
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||||
|
<aside id="files" aria-label="Fichiers de cette discussion">
|
||||||
|
<div class="files-head">
|
||||||
|
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
||||||
|
complet reste porté par l'aria-label de la section. -->
|
||||||
|
<span class="stitle">Fichiers</span>
|
||||||
|
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||||
|
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||||||
|
captures), il faut pouvoir y descendre et en remonter. -->
|
||||||
|
<div id="files-crumb" class="files-crumb"></div>
|
||||||
|
<div id="files-list" class="files-list"></div>
|
||||||
|
<div id="files-foot" class="files-foot muted"></div>
|
||||||
|
</aside>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||||
|
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<strong>Benchmark</strong>
|
||||||
|
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||||
|
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||||
|
<button onclick="closeBenchModal()">fermer</button>
|
||||||
|
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||||
|
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||||
|
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||||
|
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||||
|
<!-- Réglages : modale à deux volets — nav de sections à gauche, LE panneau
|
||||||
|
choisi à droite. Chaque section d'antan (details de la barre latérale) est
|
||||||
|
devenue un panneau .set-pane ; les ids historiques (svc-log-box,
|
||||||
|
lc-details…) sont conservés — openDetails() les résout vers
|
||||||
|
openSettings(panneau), donc pastille d'état et jobs moteur rouvrent la
|
||||||
|
modale au bon endroit. -->
|
||||||
|
<div id="settings-modal" class="modal-ov set-ov" style="display:none" onclick="if(event.target===this)closeSettings()">
|
||||||
|
<div class="modal-card set-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<strong>Paramètres</strong>
|
||||||
|
<button class="modal-x" onclick="closeSettings()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="set-shell">
|
||||||
|
<nav class="set-nav" id="set-nav" aria-label="sections des réglages">
|
||||||
|
<div class="set-nav-h">Intelligence artificielle</div>
|
||||||
|
<button data-pane="config" onclick="settingsShow('config')">Config active</button>
|
||||||
|
<button data-pane="presets" onclick="settingsShow('presets')">Presets</button>
|
||||||
|
<button data-pane="agent" onclick="settingsShow('agent')"><span>Mode agent</span><span id="agent-badge" class="sbadge"></span></button>
|
||||||
|
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
|
||||||
|
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||||
|
<div class="set-nav-h">Moteur</div>
|
||||||
|
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
|
||||||
|
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||||
|
<div class="set-nav-h">Application</div>
|
||||||
|
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
|
||||||
|
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
|
||||||
|
<button data-pane="openai" onclick="settingsShow('openai')">Accès OpenAI</button>
|
||||||
|
<button data-pane="actions" onclick="settingsShow('actions')">Actions</button>
|
||||||
|
</nav>
|
||||||
|
<div class="set-body" id="set-body">
|
||||||
|
<section class="set-pane" data-pane="engine-log" id="svc-log-box">
|
||||||
|
<div class="set-pane-h"><span>Journal du moteur</span></div>
|
||||||
<div class="row" style="justify-content:flex-end;align-items:center">
|
<div class="row" style="justify-content:flex-end;align-items:center">
|
||||||
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2168,14 +2409,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<span class="io-cap">Emplacements</span>
|
<span class="io-cap">Emplacements</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||||
</details>
|
</section>
|
||||||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
<section class="set-pane" data-pane="config" id="cfg-details">
|
||||||
|
<div class="set-pane-h"><span>Config active</span></div>
|
||||||
<div id="cfg" class="slist">…</div>
|
<div id="cfg" class="slist">…</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
|
<section class="set-pane" data-pane="presets">
|
||||||
|
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
|
||||||
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
<section class="set-pane" data-pane="agent">
|
||||||
|
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||||
@@ -2261,8 +2505,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details id="tasks-details"><summary><span class="stitle">Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></summary>
|
<section class="set-pane" data-pane="tasks" id="tasks-details">
|
||||||
|
<div class="set-pane-h"><span>Tâches planifiées</span></div>
|
||||||
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
|
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
|
||||||
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
|
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
|
||||||
conversation — elle livre son résultat via les outils de l'IA (mail via
|
conversation — elle livre son résultat via les outils de l'IA (mail via
|
||||||
@@ -2278,13 +2523,15 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">System prompt</span></summary>
|
<section class="set-pane" data-pane="sysprompt">
|
||||||
|
<div class="set-pane-h"><span>System prompt</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
|
<section class="set-pane" data-pane="engine" id="lc-details">
|
||||||
|
<div class="set-pane-h"><span>Moteur</span></div>
|
||||||
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
||||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||||
étaient des cartes dans une carte). -->
|
étaient des cartes dans une carte). -->
|
||||||
@@ -2300,9 +2547,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||||
<div id="eng-latest" style="margin-top:6px"></div>
|
<div id="eng-latest" style="margin-top:6px"></div>
|
||||||
<div class="row" style="margin:8px 0 0">
|
<div class="row" style="margin:8px 0 0">
|
||||||
<!-- Pas de bouton « vérifier la version » : la mise à jour fait sa
|
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
|
||||||
propre vérification et dit s'il n'y a rien de neuf. Un bouton de
|
confirmation) ; « mettre à jour » télécharge et bascule. Les deux
|
||||||
moins pour la même information. -->
|
gestes n'engagent pas pareil, chacun son bouton. -->
|
||||||
|
<button onclick="engCheck()">vérifier les mises à jour</button>
|
||||||
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
||||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2349,18 +2597,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<button onclick="act('stop')">arrêter</button>
|
<button onclick="act('stop')">arrêter</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
</div><!-- /sidegroup -->
|
|
||||||
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||||||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
||||||
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||||||
l'application elle-même. -->
|
l'application elle-même. -->
|
||||||
<div class="side-sep">Paramètres</div>
|
|
||||||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||||||
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
||||||
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
||||||
<div class="sidegroup">
|
<section class="set-pane" data-pane="identity" id="ident-details">
|
||||||
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
|
<div class="set-pane-h"><span>Identité</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<div class="pe-row">
|
<div class="pe-row">
|
||||||
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
||||||
@@ -2380,8 +2626,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<div id="p-avatar-ai" class="avpick"></div>
|
<div id="p-avatar-ai" class="avpick"></div>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Apparence</span></summary>
|
<section class="set-pane" data-pane="appearance">
|
||||||
|
<div class="set-pane-h"><span>Apparence</span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<label class="switchrow rev"><span>mode sombre</span>
|
<label class="switchrow rev"><span>mode sombre</span>
|
||||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||||
@@ -2394,8 +2641,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
<section class="set-pane" data-pane="openai">
|
||||||
|
<div class="set-pane-h"><span>Accès OpenAI</span></div>
|
||||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<div class="rowbody">
|
<div class="rowbody">
|
||||||
@@ -2431,8 +2679,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<button onclick="apiKeyAction('clear')">retirer</button>
|
<button onclick="apiKeyAction('clear')">retirer</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
|
<section class="set-pane" data-pane="actions" id="upd-details">
|
||||||
|
<div class="set-pane-h"><span>Actions</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
||||||
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
||||||
@@ -2448,149 +2697,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
</div><!-- /sidegroup -->
|
</div><!-- /.set-body -->
|
||||||
</details><!-- /#side-settings -->
|
</div><!-- /.set-shell -->
|
||||||
</div><!-- /#side-scroll -->
|
|
||||||
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
|
||||||
jauges fines — visible en permanence sans jamais disputer la place au
|
|
||||||
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
|
||||||
bloc entier se replie d'un clic. -->
|
|
||||||
<div id="side-perf">
|
|
||||||
<div class="perf-head"><span>Performance</span></div>
|
|
||||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main" style="position:relative">
|
|
||||||
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
|
||||||
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
|
||||||
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
|
||||||
<header id="topbar">
|
|
||||||
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
|
||||||
</button>
|
|
||||||
<div id="topbar-title">Nouvelle discussion</div>
|
|
||||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
|
||||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div id="chat"></div>
|
|
||||||
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
|
||||||
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
|
||||||
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
|
||||||
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
|
||||||
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
|
||||||
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
|
||||||
pour le dessin des lettres. -->
|
|
||||||
<div id="chat-empty" aria-hidden="true">
|
|
||||||
<div id="ce-logo"></div>
|
|
||||||
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
|
||||||
pas de civilité ni d'accord qui obligerait à deviner. -->
|
|
||||||
<div id="ce-title">On commence par quoi ?</div>
|
|
||||||
</div>
|
|
||||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
|
||||||
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
|
||||||
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
|
||||||
<div id="composer">
|
|
||||||
<div id="ctxmeta">
|
|
||||||
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
|
|
||||||
de code : critères, vérification, LSP). Dans le pied de carte, pas
|
|
||||||
dans la barre de saisie déjà chargée — ça se règle par discussion,
|
|
||||||
pas par message. -->
|
|
||||||
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
|
|
||||||
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
|
|
||||||
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
|
|
||||||
</div>
|
|
||||||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
|
||||||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
|
||||||
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
|
||||||
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
|
||||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
|
||||||
<span id="ctx-text" class="muted">0 / 0</span>
|
|
||||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
|
||||||
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
|
||||||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
|
||||||
<div id="attach-list"></div>
|
|
||||||
<div id="inputbar">
|
|
||||||
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
|
||||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
|
||||||
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
|
|
||||||
moment d'écrire le message, pas dans un éditeur de preset. -->
|
|
||||||
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
|
|
||||||
<option value="">auto</option>
|
|
||||||
<option value="none">aucune</option>
|
|
||||||
<option value="low">basse</option>
|
|
||||||
<option value="medium">moyenne</option>
|
|
||||||
<option value="high">haute</option>
|
|
||||||
<option value="xhigh">maximale</option>
|
|
||||||
</select>
|
|
||||||
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
|
||||||
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
|
||||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
|
||||||
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
|
||||||
ce qui sort. -->
|
|
||||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
|
||||||
reste porté par aria-label et l'infobulle — l'action garde son nom
|
|
||||||
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
|
||||||
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
|
||||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
|
||||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
|
||||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
|
||||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
|
||||||
utile à personne. -->
|
|
||||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
|
||||||
<aside id="files" aria-label="Fichiers de cette discussion">
|
|
||||||
<div class="files-head">
|
|
||||||
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
|
||||||
complet reste porté par l'aria-label de la section. -->
|
|
||||||
<span class="stitle">Fichiers</span>
|
|
||||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
|
||||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
|
||||||
captures), il faut pouvoir y descendre et en remonter. -->
|
|
||||||
<div id="files-crumb" class="files-crumb"></div>
|
|
||||||
<div id="files-list" class="files-list"></div>
|
|
||||||
<div id="files-foot" class="files-foot muted"></div>
|
|
||||||
</aside>
|
|
||||||
<div id="toast"></div>
|
|
||||||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
|
||||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
|
||||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<strong>Benchmark</strong>
|
|
||||||
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
|
||||||
<button onclick="closeBenchModal()">fermer</button>
|
|
||||||
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
|
||||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
|
||||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
|
||||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
|
||||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||||
<div class="modal-card" style="max-width:460px">
|
<div class="modal-card" style="max-width:460px">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
@@ -4198,7 +4309,7 @@ async function loadAll(){
|
|||||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||||
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); }
|
||||||
@@ -5885,6 +5996,11 @@ function renderToolMsg(el, tu){
|
|||||||
body.appendChild(wait);
|
body.appendChild(wait);
|
||||||
}
|
}
|
||||||
addCopyButtons(body); scrollMaybe();
|
addCopyButtons(body); scrollMaybe();
|
||||||
|
// Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
|
||||||
|
// ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
|
||||||
|
// tête — c'est le résumé qu'on relit, pas la dernière ligne.
|
||||||
|
const bw = el.querySelector('.bodywrap');
|
||||||
|
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
|
||||||
}
|
}
|
||||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||||
function addCopyButtons(root){
|
function addCopyButtons(root){
|
||||||
@@ -6106,9 +6222,28 @@ function paintTurnClock(){
|
|||||||
}
|
}
|
||||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||||
if(!(ms > 0)){ el.remove(); return; }
|
if(!(ms > 0)){ el.remove(); return; }
|
||||||
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
|
// Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
|
||||||
|
// typique de ce modèle sur ce fil, toutes réponses confondues.
|
||||||
|
const avg = (CONV_MS > 1000 && CONV_TOK > 20)
|
||||||
|
? ' · moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
|
||||||
|
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
|
||||||
el.classList.toggle('done', !!T.doneTs);
|
el.classList.toggle('done', !!T.doneTs);
|
||||||
}
|
}
|
||||||
|
// --- Vitesse moyenne de la conversation --------------------------------------
|
||||||
|
// Alimentée par les événements `stats` (journalisés, donc rejoués au
|
||||||
|
// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
|
||||||
|
// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
|
||||||
|
// que le delta, et une valeur qui recule signale une nouvelle complétion.
|
||||||
|
let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
|
||||||
|
function feedConvSpeed(s){
|
||||||
|
const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
|
||||||
|
if(!gt) return;
|
||||||
|
if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
|
||||||
|
CONV_TOK += gt - _cgTok;
|
||||||
|
CONV_MS += Math.max(0, gms - _cgMs);
|
||||||
|
_cgTok = gt; _cgMs = gms;
|
||||||
|
}
|
||||||
|
function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
|
||||||
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
||||||
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
||||||
// c'est précisément le moment où l'on se demande si ça avance.
|
// c'est précisément le moment où l'on se demande si ça avance.
|
||||||
@@ -6295,11 +6430,15 @@ function syncSendBtn(){
|
|||||||
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
||||||
// tour, erreur, caught_up).
|
// tour, erreur, caught_up).
|
||||||
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
||||||
function scheduleRender(el, text){
|
function scheduleRender(el, text, final){
|
||||||
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
||||||
// valeur, sinon son ultime bout de texte serait perdu.
|
// valeur, sinon son ultime bout de texte serait perdu.
|
||||||
if(renderPending && renderPending.el!==el) flushRender();
|
if(renderPending && renderPending.el!==el) flushRender();
|
||||||
renderPending={el, text};
|
// `final` voyage AVEC le rendu en attente : le timer déjà armé (qui passe
|
||||||
|
// final=false) peut être celui qui consommera ce rendu — sans le drapeau sur
|
||||||
|
// le pending, le rendu de FIN de bloc repassait par le chemin « queue seule »
|
||||||
|
// et le début du raisonnement n'était jamais posé.
|
||||||
|
renderPending={el, text, final:!!final};
|
||||||
if(renderTimer) return;
|
if(renderTimer) return;
|
||||||
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
||||||
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
||||||
@@ -6307,15 +6446,34 @@ function scheduleRender(el, text){
|
|||||||
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
||||||
// le O(n²) est cassé sans jamais figer l'interface.
|
// le O(n²) est cassé sans jamais figer l'interface.
|
||||||
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
||||||
renderTimer=setTimeout(flushRender, delay);
|
renderTimer=setTimeout(()=>flushRender(false), delay);
|
||||||
}
|
}
|
||||||
function flushRender(){
|
// Au-delà de cette taille, un raisonnement EN COURS n'est re-parsé que sur sa
|
||||||
|
// fin : re-parser le bloc entier à chaque tick est en O(n²) et finissait par
|
||||||
|
// figer l'affichage (le texte semblait s'arrêter alors que le moteur débitait).
|
||||||
|
// La carte étant bornée en hauteur et collée en bas, on ne perd rien à l'écran ;
|
||||||
|
// le texte COMPLET est posé au rendu final (frontière de bloc → final=true).
|
||||||
|
const REASON_TAIL=6000;
|
||||||
|
function flushRender(final){
|
||||||
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
||||||
const p=renderPending; renderPending=null;
|
const p=renderPending; renderPending=null;
|
||||||
if(!p) return;
|
if(!p) return;
|
||||||
|
let text=p.text;
|
||||||
|
if(final===false && !p.final && text.length>REASON_TAIL+800 && p.el.closest('.msg.reasoning')){
|
||||||
|
let cut=text.length-REASON_TAIL;
|
||||||
|
const nl=text.indexOf('\n', cut); if(nl>-1 && nl<cut+400) cut=nl+1;
|
||||||
|
text='*… (début replié pendant la génération — le texte complet est posé à la fin)*\n\n'+text.slice(cut);
|
||||||
|
}
|
||||||
|
// Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
|
||||||
|
// la carte suit la génération. Un défilement manuel vers le haut est respecté.
|
||||||
|
// ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
|
||||||
|
const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
|
||||||
|
const scrollable=!!bw;
|
||||||
|
const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
|
||||||
const t0=performance.now();
|
const t0=performance.now();
|
||||||
renderBody(p.el, p.text);
|
renderBody(p.el, text);
|
||||||
lastRenderMs=performance.now()-t0;
|
lastRenderMs=performance.now()-t0;
|
||||||
|
if(scrollable && stick) bw.scrollTop=bw.scrollHeight;
|
||||||
}
|
}
|
||||||
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
||||||
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
||||||
@@ -6334,7 +6492,7 @@ function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf);
|
|||||||
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
||||||
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
||||||
// texte resterait en retard derrière le curseur de révélation.
|
// texte resterait en retard derrière le curseur de révélation.
|
||||||
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target); }
|
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target, true); }
|
||||||
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
||||||
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
||||||
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
||||||
@@ -6392,7 +6550,7 @@ function handleDelta(d){
|
|||||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||||
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||||
// --- Mode code (20-mode.js) -----------------------------------------------
|
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||||
@@ -6440,7 +6598,7 @@ function handleDelta(d){
|
|||||||
// une trace du fil et DOIT être rejouée.
|
// une trace du fil et DOIT être rejouée.
|
||||||
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
||||||
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
||||||
if(d.stats){ T.serverStats=d.stats;
|
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
|
||||||
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
||||||
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
||||||
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
||||||
@@ -6450,12 +6608,24 @@ function handleDelta(d){
|
|||||||
if(d.tool_used){
|
if(d.tool_used){
|
||||||
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
||||||
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
||||||
|
// Une carte reste OUVERTE tant que SA section travaille (hauteur bornée à
|
||||||
|
// 280 px, défilement qui suit l'écriture), et se replie DÈS qu'elle est
|
||||||
|
// finie : l'outil à son résultat (tu.done, plus bas), le raisonnement quand
|
||||||
|
// le bloc suivant démarre (collapseAll à la création de la carte suivante).
|
||||||
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
||||||
renderToolMsg(T.pendingToolEl, tu);
|
renderToolMsg(T.pendingToolEl, tu);
|
||||||
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
||||||
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
||||||
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
||||||
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
||||||
|
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
||||||
|
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
||||||
|
if(tu.done){
|
||||||
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
|
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||||
|
// tour (collapseAll) la fermerait quand même.
|
||||||
|
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
|
||||||
|
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||||
return; }
|
return; }
|
||||||
if(d.drop_reasoning){
|
if(d.drop_reasoning){
|
||||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||||
@@ -7978,6 +8148,13 @@ async function loadConversations(){
|
|||||||
let r;
|
let r;
|
||||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||||
CONVS = r.conversations || [];
|
CONVS = r.conversations || [];
|
||||||
|
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
|
||||||
|
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
|
||||||
|
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
|
||||||
|
// fichier d'index pour les créations dans la même seconde.
|
||||||
|
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
|
||||||
|
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|
||||||
|
|| ord.get(b.id) - ord.get(a.id));
|
||||||
CONV_ACTIVE = r.active || '';
|
CONV_ACTIVE = r.active || '';
|
||||||
CONV_BUSY = !!r.busy;
|
CONV_BUSY = !!r.busy;
|
||||||
renderConversations();
|
renderConversations();
|
||||||
@@ -8287,6 +8464,12 @@ function openDetails(el){
|
|||||||
if(typeof el === 'string') el = document.getElementById(el);
|
if(typeof el === 'string') el = document.getElementById(el);
|
||||||
while(el){
|
while(el){
|
||||||
if(el.tagName === 'DETAILS') el.open = true;
|
if(el.tagName === 'DETAILS') el.open = true;
|
||||||
|
// La cible vit dans un panneau de la modale de réglages : ouvrir la modale
|
||||||
|
// sur ce panneau. C'est ce qui garde tous les appelants historiques
|
||||||
|
// (pastille d'état, jobs moteur) valides depuis la refonte en modale.
|
||||||
|
if(el.classList && el.classList.contains('set-pane') && typeof openSettings === 'function'){
|
||||||
|
openSettings(el.dataset.pane);
|
||||||
|
}
|
||||||
el = el.parentElement;
|
el = el.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8295,7 +8478,14 @@ function openDetails(el){
|
|||||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||||
// confirmation puis relance le moteur).
|
// confirmation puis relance le moteur).
|
||||||
function onModelSwitch(sel){
|
function onModelSwitch(sel){
|
||||||
const n = parseInt(sel.value, 10);
|
const v = sel.value;
|
||||||
|
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
|
||||||
|
// active garde son contexte et son échantillonnage, seul MODEL change.
|
||||||
|
if(v && v.startsWith('m:')){
|
||||||
|
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(v, 10);
|
||||||
if(!n) return;
|
if(!n) return;
|
||||||
const name = sel.options[sel.selectedIndex].textContent;
|
const name = sel.options[sel.selectedIndex].textContent;
|
||||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||||
@@ -8304,6 +8494,22 @@ function onModelSwitch(sel){
|
|||||||
switchTo(n, name);
|
switchTo(n, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||||
|
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
|
||||||
|
// revient sur le modèle réellement chargé.
|
||||||
|
async function switchModelFile(value, name){
|
||||||
|
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
|
||||||
|
paintModelSwitch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('changement de modèle…');
|
||||||
|
let r = null;
|
||||||
|
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
|
||||||
|
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
|
||||||
|
toast('modèle en cours de chargement…');
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
||||||
//
|
//
|
||||||
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
||||||
@@ -8316,12 +8522,22 @@ function onModelSwitch(sel){
|
|||||||
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
||||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||||
// en parallèle (loadAll), aucun ordre n'est garanti.
|
// en parallèle (loadAll), aucun ordre n'est garanti.
|
||||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
|
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
|
||||||
|
|
||||||
function renderModelSwitch(presets, active){
|
function renderModelSwitch(presets, active){
|
||||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||||
paintModelSwitch();
|
paintModelSwitch();
|
||||||
}
|
}
|
||||||
|
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
|
||||||
|
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
|
||||||
|
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
|
||||||
|
async function loadModelSwitchFiles(){
|
||||||
|
try{
|
||||||
|
const r = await jget('/api/models');
|
||||||
|
MS_FILES = Array.isArray(r) ? r : [];
|
||||||
|
}catch(_){ return; }
|
||||||
|
paintModelSwitch();
|
||||||
|
}
|
||||||
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
||||||
function setLoadedModel(name){
|
function setLoadedModel(name){
|
||||||
MS_MODEL = (name || '').trim();
|
MS_MODEL = (name || '').trim();
|
||||||
@@ -8348,14 +8564,31 @@ function paintModelSwitch(){
|
|||||||
o.selected = true;
|
o.selected = true;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
}
|
}
|
||||||
MS_PRESETS.forEach((p, i) => {
|
if(MS_PRESETS.length){
|
||||||
const o = document.createElement('option');
|
const g = document.createElement('optgroup'); g.label = 'Presets';
|
||||||
o.value = String(i + 1);
|
MS_PRESETS.forEach((p, i) => {
|
||||||
o.textContent = p.name;
|
const o = document.createElement('option');
|
||||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
o.value = String(i + 1);
|
||||||
sel.appendChild(o);
|
o.textContent = p.name;
|
||||||
});
|
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||||
sel.disabled = !MS_PRESETS.length;
|
g.appendChild(o);
|
||||||
|
});
|
||||||
|
sel.appendChild(g);
|
||||||
|
}
|
||||||
|
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
|
||||||
|
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
|
||||||
|
if(MS_FILES.length){
|
||||||
|
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
|
||||||
|
for(const m of MS_FILES){
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = 'm:' + m.value;
|
||||||
|
o.textContent = modelLabel(m.name);
|
||||||
|
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
|
||||||
|
g.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.appendChild(g);
|
||||||
|
}
|
||||||
|
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
|
||||||
sel.title = MS_MODEL
|
sel.title = MS_MODEL
|
||||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||||
: 'aucun modèle configuré';
|
: 'aucun modèle configuré';
|
||||||
@@ -8921,4 +9154,133 @@ async function runTaskNow(){
|
|||||||
st.style.color = 'var(--err)';
|
st.style.color = 'var(--err)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ─── Réglages en modale ──────────────────────────────────────────────────────
|
||||||
|
// Toutes les sections de réglages vivent dans une modale à deux volets : la nav
|
||||||
|
// des sections à gauche, LE panneau choisi à droite. La barre latérale ne garde
|
||||||
|
// que les discussions et le moniteur machine — visuellement plus simple, et
|
||||||
|
// chaque section dispose enfin de toute la largeur.
|
||||||
|
//
|
||||||
|
// Les ids historiques des sections (svc-log-box, lc-details, tasks-details…)
|
||||||
|
// sont posés sur les .set-pane : openDetails() (18-shell.js) les résout vers le
|
||||||
|
// bon panneau, si bien que tous les chemins existants — pastille d'état du
|
||||||
|
// moteur, pastille d'installation, jobs llama.cpp — rouvrent la modale au bon
|
||||||
|
// endroit sans changer une ligne de leurs appelants.
|
||||||
|
let SET_LAST = 'config';
|
||||||
|
// Panneaux à contenu paresseux : chargés à l'affichage — l'équivalent de
|
||||||
|
// l'ancien ontoggle des <details>.
|
||||||
|
const SET_HOOKS = {
|
||||||
|
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||||
|
};
|
||||||
|
function openSettings(pane){
|
||||||
|
showModal('settings-modal');
|
||||||
|
settingsShow(pane || SET_LAST);
|
||||||
|
}
|
||||||
|
function closeSettings(){ hideModal('settings-modal'); }
|
||||||
|
function settingsShow(pane){
|
||||||
|
SET_LAST = pane;
|
||||||
|
document.querySelectorAll('#set-nav [data-pane]').forEach(b => b.classList.toggle('active', b.dataset.pane === pane));
|
||||||
|
document.querySelectorAll('#set-body .set-pane').forEach(s => s.classList.toggle('active', s.dataset.pane === pane));
|
||||||
|
const body = document.getElementById('set-body');
|
||||||
|
if(body) body.scrollTop = 0;
|
||||||
|
const h = SET_HOOKS[pane]; if(h) h();
|
||||||
|
}
|
||||||
|
// Échap ferme la modale — capture:false, donc les modales à confirmation
|
||||||
|
// (ask-modal, qui écoute en capture) gardent la main quand elles sont ouvertes.
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if(e.key !== 'Escape') return;
|
||||||
|
const m = document.getElementById('settings-modal');
|
||||||
|
if(m && m.classList.contains('show')) closeSettings();
|
||||||
|
});
|
||||||
|
// ─── Dictée vocale ───────────────────────────────────────────────────────────
|
||||||
|
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
|
||||||
|
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
|
||||||
|
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
|
||||||
|
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
|
||||||
|
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
|
||||||
|
//
|
||||||
|
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
|
||||||
|
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
|
||||||
|
// l'explique plutôt que d'échouer en silence.
|
||||||
|
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
|
||||||
|
|
||||||
|
async function dictateToggle(){
|
||||||
|
if(MIC){ dictateStop(); return; }
|
||||||
|
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||||||
|
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stream;
|
||||||
|
try{
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||||||
|
}catch(e){ toast('accès au micro refusé'); return; }
|
||||||
|
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
|
||||||
|
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
|
||||||
|
// impossible dans un fichier unique servi inline).
|
||||||
|
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||||
|
const chunks = [];
|
||||||
|
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
|
||||||
|
src.connect(node); node.connect(ctx.destination);
|
||||||
|
// Garde-fou : une dictée oubliée ne tourne pas des heures.
|
||||||
|
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
|
||||||
|
document.getElementById('mic').classList.add('rec');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dictateStop(){
|
||||||
|
const m = MIC; if(!m) return; MIC = null;
|
||||||
|
clearTimeout(m.timer);
|
||||||
|
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
|
||||||
|
m.stream.getTracks().forEach(t => t.stop());
|
||||||
|
try{ m.ctx.close(); }catch(_){}
|
||||||
|
const btn = document.getElementById('mic');
|
||||||
|
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
|
||||||
|
try{
|
||||||
|
const wav = encodeWav16k(m.chunks, m.rate);
|
||||||
|
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
|
||||||
|
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
|
||||||
|
const j = await r.json().catch(()=>({}));
|
||||||
|
if(r.status === 503 && j.downloading){
|
||||||
|
// Le serveur relance le téléchargement à chaque tentative ; s'il a déjà
|
||||||
|
// échoué (conteneur sans accès réseau, Hugging Face injoignable), il nous
|
||||||
|
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
|
||||||
|
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
|
||||||
|
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
|
||||||
|
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
|
||||||
|
const text = (j.text||'').trim();
|
||||||
|
if(!text){ toast('rien compris — réessaie'); return; }
|
||||||
|
const ta = document.getElementById('input');
|
||||||
|
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
|
||||||
|
autoGrow(ta); ta.focus();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
finally{ btn.classList.remove('busy'); btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
|
||||||
|
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
|
||||||
|
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
|
||||||
|
function encodeWav16k(chunks, fromRate){
|
||||||
|
const toRate = 16000;
|
||||||
|
let n = 0; for(const c of chunks) n += c.length;
|
||||||
|
const all = new Float32Array(n); let o = 0;
|
||||||
|
for(const c of chunks){ all.set(c, o); o += c.length; }
|
||||||
|
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
|
||||||
|
const pcm = new Int16Array(outN);
|
||||||
|
for(let i = 0; i < outN; i++){
|
||||||
|
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
|
||||||
|
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
|
||||||
|
}
|
||||||
|
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
|
||||||
|
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
|
||||||
|
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
|
||||||
|
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
|
||||||
|
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||||
|
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
|
||||||
|
new Int16Array(buf, 44).set(pcm);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
@@ -75,9 +75,193 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
latérale par défaut, c'est les discussions et la machine. Les sections
|
latérale par défaut, c'est les discussions et la machine. Les sections
|
||||||
ouvertes par programme (journal du moteur, installation llama.cpp)
|
ouvertes par programme (journal du moteur, installation llama.cpp)
|
||||||
passent par openDetails(), qui déplie aussi les parents. -->
|
passent par openDetails(), qui déplie aussi les parents. -->
|
||||||
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
|
</div><!-- /#side-scroll -->
|
||||||
<div class="sidegroup">
|
<!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
|
||||||
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
|
droite) — voir js/22-settings-modal.js ; les ids historiques des sections
|
||||||
|
(svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
|
||||||
|
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
|
||||||
|
même endroit, quel que soit le nombre de discussions. -->
|
||||||
|
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
|
||||||
|
<span>Paramètres</span>
|
||||||
|
</button>
|
||||||
|
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
||||||
|
jauges fines — visible en permanence sans jamais disputer la place au
|
||||||
|
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
||||||
|
bloc entier se replie d'un clic. -->
|
||||||
|
<div id="side-perf">
|
||||||
|
<div class="perf-head"><span>Performance</span></div>
|
||||||
|
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main" style="position:relative">
|
||||||
|
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
||||||
|
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
||||||
|
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
||||||
|
<header id="topbar">
|
||||||
|
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="topbar-title">Nouvelle discussion</div>
|
||||||
|
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||||
|
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div id="chat"></div>
|
||||||
|
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
||||||
|
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
||||||
|
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
||||||
|
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
||||||
|
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
||||||
|
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
||||||
|
pour le dessin des lettres. -->
|
||||||
|
<div id="chat-empty" aria-hidden="true">
|
||||||
|
<div id="ce-logo"></div>
|
||||||
|
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
||||||
|
pas de civilité ni d'accord qui obligerait à deviner. -->
|
||||||
|
<div id="ce-title">On commence par quoi ?</div>
|
||||||
|
</div>
|
||||||
|
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
||||||
|
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
||||||
|
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
||||||
|
<div id="composer">
|
||||||
|
<div id="ctxmeta">
|
||||||
|
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
|
||||||
|
de code : critères, vérification, LSP). Dans le pied de carte, pas
|
||||||
|
dans la barre de saisie déjà chargée — ça se règle par discussion,
|
||||||
|
pas par message. -->
|
||||||
|
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
|
||||||
|
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
|
||||||
|
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
|
||||||
|
</div>
|
||||||
|
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||||||
|
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||||||
|
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
||||||
|
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
|
<span id="ctx-text" class="muted">0 / 0</span>
|
||||||
|
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||||||
|
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
||||||
|
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||||||
|
<div id="attach-list"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||||
|
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||||
|
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
|
||||||
|
moment d'écrire le message, pas dans un éditeur de preset. -->
|
||||||
|
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
|
||||||
|
<option value="">auto</option>
|
||||||
|
<option value="none">aucune</option>
|
||||||
|
<option value="low">basse</option>
|
||||||
|
<option value="medium">moyenne</option>
|
||||||
|
<option value="high">haute</option>
|
||||||
|
<option value="xhigh">maximale</option>
|
||||||
|
</select>
|
||||||
|
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
|
||||||
|
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
|
||||||
|
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
|
||||||
|
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
||||||
|
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
||||||
|
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
||||||
|
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
||||||
|
ce qui sort. -->
|
||||||
|
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
||||||
|
reste porté par aria-label et l'infobulle — l'action garde son nom
|
||||||
|
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
||||||
|
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||||
|
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||||
|
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||||
|
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||||
|
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||||
|
utile à personne. -->
|
||||||
|
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||||
|
<aside id="files" aria-label="Fichiers de cette discussion">
|
||||||
|
<div class="files-head">
|
||||||
|
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
||||||
|
complet reste porté par l'aria-label de la section. -->
|
||||||
|
<span class="stitle">Fichiers</span>
|
||||||
|
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||||
|
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||||||
|
captures), il faut pouvoir y descendre et en remonter. -->
|
||||||
|
<div id="files-crumb" class="files-crumb"></div>
|
||||||
|
<div id="files-list" class="files-list"></div>
|
||||||
|
<div id="files-foot" class="files-foot muted"></div>
|
||||||
|
</aside>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||||
|
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<strong>Benchmark</strong>
|
||||||
|
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||||
|
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||||
|
<button onclick="closeBenchModal()">fermer</button>
|
||||||
|
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||||
|
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||||
|
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||||
|
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||||
|
<!-- Réglages : modale à deux volets — nav de sections à gauche, LE panneau
|
||||||
|
choisi à droite. Chaque section d'antan (details de la barre latérale) est
|
||||||
|
devenue un panneau .set-pane ; les ids historiques (svc-log-box,
|
||||||
|
lc-details…) sont conservés — openDetails() les résout vers
|
||||||
|
openSettings(panneau), donc pastille d'état et jobs moteur rouvrent la
|
||||||
|
modale au bon endroit. -->
|
||||||
|
<div id="settings-modal" class="modal-ov set-ov" style="display:none" onclick="if(event.target===this)closeSettings()">
|
||||||
|
<div class="modal-card set-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<strong>Paramètres</strong>
|
||||||
|
<button class="modal-x" onclick="closeSettings()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="set-shell">
|
||||||
|
<nav class="set-nav" id="set-nav" aria-label="sections des réglages">
|
||||||
|
<div class="set-nav-h">Intelligence artificielle</div>
|
||||||
|
<button data-pane="config" onclick="settingsShow('config')">Config active</button>
|
||||||
|
<button data-pane="presets" onclick="settingsShow('presets')">Presets</button>
|
||||||
|
<button data-pane="agent" onclick="settingsShow('agent')"><span>Mode agent</span><span id="agent-badge" class="sbadge"></span></button>
|
||||||
|
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
|
||||||
|
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||||
|
<div class="set-nav-h">Moteur</div>
|
||||||
|
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
|
||||||
|
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||||
|
<div class="set-nav-h">Application</div>
|
||||||
|
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
|
||||||
|
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
|
||||||
|
<button data-pane="openai" onclick="settingsShow('openai')">Accès OpenAI</button>
|
||||||
|
<button data-pane="actions" onclick="settingsShow('actions')">Actions</button>
|
||||||
|
</nav>
|
||||||
|
<div class="set-body" id="set-body">
|
||||||
|
<section class="set-pane" data-pane="engine-log" id="svc-log-box">
|
||||||
|
<div class="set-pane-h"><span>Journal du moteur</span></div>
|
||||||
<div class="row" style="justify-content:flex-end;align-items:center">
|
<div class="row" style="justify-content:flex-end;align-items:center">
|
||||||
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,14 +272,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<span class="io-cap">Emplacements</span>
|
<span class="io-cap">Emplacements</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||||
</details>
|
</section>
|
||||||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
<section class="set-pane" data-pane="config" id="cfg-details">
|
||||||
|
<div class="set-pane-h"><span>Config active</span></div>
|
||||||
<div id="cfg" class="slist">…</div>
|
<div id="cfg" class="slist">…</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
|
<section class="set-pane" data-pane="presets">
|
||||||
|
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
|
||||||
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
<section class="set-pane" data-pane="agent">
|
||||||
|
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||||
@@ -181,8 +368,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details id="tasks-details"><summary><span class="stitle">Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></summary>
|
<section class="set-pane" data-pane="tasks" id="tasks-details">
|
||||||
|
<div class="set-pane-h"><span>Tâches planifiées</span></div>
|
||||||
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
|
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
|
||||||
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
|
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
|
||||||
conversation — elle livre son résultat via les outils de l'IA (mail via
|
conversation — elle livre son résultat via les outils de l'IA (mail via
|
||||||
@@ -198,13 +386,15 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">System prompt</span></summary>
|
<section class="set-pane" data-pane="sysprompt">
|
||||||
|
<div class="set-pane-h"><span>System prompt</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
|
<section class="set-pane" data-pane="engine" id="lc-details">
|
||||||
|
<div class="set-pane-h"><span>Moteur</span></div>
|
||||||
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
||||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||||
étaient des cartes dans une carte). -->
|
étaient des cartes dans une carte). -->
|
||||||
@@ -220,9 +410,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||||
<div id="eng-latest" style="margin-top:6px"></div>
|
<div id="eng-latest" style="margin-top:6px"></div>
|
||||||
<div class="row" style="margin:8px 0 0">
|
<div class="row" style="margin:8px 0 0">
|
||||||
<!-- Pas de bouton « vérifier la version » : la mise à jour fait sa
|
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
|
||||||
propre vérification et dit s'il n'y a rien de neuf. Un bouton de
|
confirmation) ; « mettre à jour » télécharge et bascule. Les deux
|
||||||
moins pour la même information. -->
|
gestes n'engagent pas pareil, chacun son bouton. -->
|
||||||
|
<button onclick="engCheck()">vérifier les mises à jour</button>
|
||||||
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
||||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,18 +460,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button onclick="act('stop')">arrêter</button>
|
<button onclick="act('stop')">arrêter</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
</div><!-- /sidegroup -->
|
|
||||||
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||||||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
||||||
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||||||
l'application elle-même. -->
|
l'application elle-même. -->
|
||||||
<div class="side-sep">Paramètres</div>
|
|
||||||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||||||
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
||||||
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
||||||
<div class="sidegroup">
|
<section class="set-pane" data-pane="identity" id="ident-details">
|
||||||
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
|
<div class="set-pane-h"><span>Identité</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<div class="pe-row">
|
<div class="pe-row">
|
||||||
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
||||||
@@ -300,8 +489,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<div id="p-avatar-ai" class="avpick"></div>
|
<div id="p-avatar-ai" class="avpick"></div>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Apparence</span></summary>
|
<section class="set-pane" data-pane="appearance">
|
||||||
|
<div class="set-pane-h"><span>Apparence</span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<label class="switchrow rev"><span>mode sombre</span>
|
<label class="switchrow rev"><span>mode sombre</span>
|
||||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||||
@@ -314,8 +504,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
<section class="set-pane" data-pane="openai">
|
||||||
|
<div class="set-pane-h"><span>Accès OpenAI</span></div>
|
||||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<div class="rowbody">
|
<div class="rowbody">
|
||||||
@@ -351,8 +542,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button onclick="apiKeyAction('clear')">retirer</button>
|
<button onclick="apiKeyAction('clear')">retirer</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
|
<section class="set-pane" data-pane="actions" id="upd-details">
|
||||||
|
<div class="set-pane-h"><span>Actions</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
||||||
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
||||||
@@ -368,149 +560,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
</div><!-- /sidegroup -->
|
</div><!-- /.set-body -->
|
||||||
</details><!-- /#side-settings -->
|
</div><!-- /.set-shell -->
|
||||||
</div><!-- /#side-scroll -->
|
|
||||||
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
|
||||||
jauges fines — visible en permanence sans jamais disputer la place au
|
|
||||||
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
|
||||||
bloc entier se replie d'un clic. -->
|
|
||||||
<div id="side-perf">
|
|
||||||
<div class="perf-head"><span>Performance</span></div>
|
|
||||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main" style="position:relative">
|
|
||||||
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
|
||||||
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
|
||||||
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
|
||||||
<header id="topbar">
|
|
||||||
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
|
||||||
</button>
|
|
||||||
<div id="topbar-title">Nouvelle discussion</div>
|
|
||||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
|
||||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div id="chat"></div>
|
|
||||||
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
|
||||||
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
|
||||||
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
|
||||||
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
|
||||||
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
|
||||||
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
|
||||||
pour le dessin des lettres. -->
|
|
||||||
<div id="chat-empty" aria-hidden="true">
|
|
||||||
<div id="ce-logo"></div>
|
|
||||||
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
|
||||||
pas de civilité ni d'accord qui obligerait à deviner. -->
|
|
||||||
<div id="ce-title">On commence par quoi ?</div>
|
|
||||||
</div>
|
|
||||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
|
||||||
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
|
||||||
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
|
||||||
<div id="composer">
|
|
||||||
<div id="ctxmeta">
|
|
||||||
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
|
|
||||||
de code : critères, vérification, LSP). Dans le pied de carte, pas
|
|
||||||
dans la barre de saisie déjà chargée — ça se règle par discussion,
|
|
||||||
pas par message. -->
|
|
||||||
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
|
|
||||||
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
|
|
||||||
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
|
|
||||||
</div>
|
|
||||||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
|
||||||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
|
||||||
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
|
||||||
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
|
||||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
|
||||||
<span id="ctx-text" class="muted">0 / 0</span>
|
|
||||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
|
||||||
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
|
||||||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
|
||||||
<div id="attach-list"></div>
|
|
||||||
<div id="inputbar">
|
|
||||||
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
|
||||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
|
||||||
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
|
|
||||||
moment d'écrire le message, pas dans un éditeur de preset. -->
|
|
||||||
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
|
|
||||||
<option value="">auto</option>
|
|
||||||
<option value="none">aucune</option>
|
|
||||||
<option value="low">basse</option>
|
|
||||||
<option value="medium">moyenne</option>
|
|
||||||
<option value="high">haute</option>
|
|
||||||
<option value="xhigh">maximale</option>
|
|
||||||
</select>
|
|
||||||
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
|
||||||
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
|
||||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
|
||||||
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
|
||||||
ce qui sort. -->
|
|
||||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
|
||||||
reste porté par aria-label et l'infobulle — l'action garde son nom
|
|
||||||
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
|
||||||
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
|
||||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
|
||||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
|
||||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
|
||||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
|
||||||
utile à personne. -->
|
|
||||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
|
||||||
<aside id="files" aria-label="Fichiers de cette discussion">
|
|
||||||
<div class="files-head">
|
|
||||||
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
|
||||||
complet reste porté par l'aria-label de la section. -->
|
|
||||||
<span class="stitle">Fichiers</span>
|
|
||||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
|
||||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
|
||||||
captures), il faut pouvoir y descendre et en remonter. -->
|
|
||||||
<div id="files-crumb" class="files-crumb"></div>
|
|
||||||
<div id="files-list" class="files-list"></div>
|
|
||||||
<div id="files-foot" class="files-foot muted"></div>
|
|
||||||
</aside>
|
|
||||||
<div id="toast"></div>
|
|
||||||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
|
||||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
|
||||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<strong>Benchmark</strong>
|
|
||||||
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
|
||||||
<button onclick="closeBenchModal()">fermer</button>
|
|
||||||
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
|
||||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
|
||||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
|
||||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
|
||||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||||
<div class="modal-card" style="max-width:460px">
|
<div class="modal-card" style="max-width:460px">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ async function loadAll(){
|
|||||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||||
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); }
|
||||||
@@ -368,6 +368,11 @@ function renderToolMsg(el, tu){
|
|||||||
body.appendChild(wait);
|
body.appendChild(wait);
|
||||||
}
|
}
|
||||||
addCopyButtons(body); scrollMaybe();
|
addCopyButtons(body); scrollMaybe();
|
||||||
|
// Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
|
||||||
|
// ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
|
||||||
|
// tête — c'est le résumé qu'on relit, pas la dernière ligne.
|
||||||
|
const bw = el.querySelector('.bodywrap');
|
||||||
|
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
|
||||||
}
|
}
|
||||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||||
function addCopyButtons(root){
|
function addCopyButtons(root){
|
||||||
|
|||||||
@@ -89,9 +89,28 @@ function paintTurnClock(){
|
|||||||
}
|
}
|
||||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||||
if(!(ms > 0)){ el.remove(); return; }
|
if(!(ms > 0)){ el.remove(); return; }
|
||||||
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
|
// Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
|
||||||
|
// typique de ce modèle sur ce fil, toutes réponses confondues.
|
||||||
|
const avg = (CONV_MS > 1000 && CONV_TOK > 20)
|
||||||
|
? ' · moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
|
||||||
|
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
|
||||||
el.classList.toggle('done', !!T.doneTs);
|
el.classList.toggle('done', !!T.doneTs);
|
||||||
}
|
}
|
||||||
|
// --- Vitesse moyenne de la conversation --------------------------------------
|
||||||
|
// Alimentée par les événements `stats` (journalisés, donc rejoués au
|
||||||
|
// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
|
||||||
|
// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
|
||||||
|
// que le delta, et une valeur qui recule signale une nouvelle complétion.
|
||||||
|
let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
|
||||||
|
function feedConvSpeed(s){
|
||||||
|
const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
|
||||||
|
if(!gt) return;
|
||||||
|
if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
|
||||||
|
CONV_TOK += gt - _cgTok;
|
||||||
|
CONV_MS += Math.max(0, gms - _cgMs);
|
||||||
|
_cgTok = gt; _cgMs = gms;
|
||||||
|
}
|
||||||
|
function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
|
||||||
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
||||||
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
||||||
// c'est précisément le moment où l'on se demande si ça avance.
|
// c'est précisément le moment où l'on se demande si ça avance.
|
||||||
@@ -278,11 +297,15 @@ function syncSendBtn(){
|
|||||||
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
||||||
// tour, erreur, caught_up).
|
// tour, erreur, caught_up).
|
||||||
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
||||||
function scheduleRender(el, text){
|
function scheduleRender(el, text, final){
|
||||||
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
||||||
// valeur, sinon son ultime bout de texte serait perdu.
|
// valeur, sinon son ultime bout de texte serait perdu.
|
||||||
if(renderPending && renderPending.el!==el) flushRender();
|
if(renderPending && renderPending.el!==el) flushRender();
|
||||||
renderPending={el, text};
|
// `final` voyage AVEC le rendu en attente : le timer déjà armé (qui passe
|
||||||
|
// final=false) peut être celui qui consommera ce rendu — sans le drapeau sur
|
||||||
|
// le pending, le rendu de FIN de bloc repassait par le chemin « queue seule »
|
||||||
|
// et le début du raisonnement n'était jamais posé.
|
||||||
|
renderPending={el, text, final:!!final};
|
||||||
if(renderTimer) return;
|
if(renderTimer) return;
|
||||||
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
||||||
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
||||||
@@ -290,15 +313,34 @@ function scheduleRender(el, text){
|
|||||||
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
||||||
// le O(n²) est cassé sans jamais figer l'interface.
|
// le O(n²) est cassé sans jamais figer l'interface.
|
||||||
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
||||||
renderTimer=setTimeout(flushRender, delay);
|
renderTimer=setTimeout(()=>flushRender(false), delay);
|
||||||
}
|
}
|
||||||
function flushRender(){
|
// Au-delà de cette taille, un raisonnement EN COURS n'est re-parsé que sur sa
|
||||||
|
// fin : re-parser le bloc entier à chaque tick est en O(n²) et finissait par
|
||||||
|
// figer l'affichage (le texte semblait s'arrêter alors que le moteur débitait).
|
||||||
|
// La carte étant bornée en hauteur et collée en bas, on ne perd rien à l'écran ;
|
||||||
|
// le texte COMPLET est posé au rendu final (frontière de bloc → final=true).
|
||||||
|
const REASON_TAIL=6000;
|
||||||
|
function flushRender(final){
|
||||||
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
||||||
const p=renderPending; renderPending=null;
|
const p=renderPending; renderPending=null;
|
||||||
if(!p) return;
|
if(!p) return;
|
||||||
|
let text=p.text;
|
||||||
|
if(final===false && !p.final && text.length>REASON_TAIL+800 && p.el.closest('.msg.reasoning')){
|
||||||
|
let cut=text.length-REASON_TAIL;
|
||||||
|
const nl=text.indexOf('\n', cut); if(nl>-1 && nl<cut+400) cut=nl+1;
|
||||||
|
text='*… (début replié pendant la génération — le texte complet est posé à la fin)*\n\n'+text.slice(cut);
|
||||||
|
}
|
||||||
|
// Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
|
||||||
|
// la carte suit la génération. Un défilement manuel vers le haut est respecté.
|
||||||
|
// ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
|
||||||
|
const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
|
||||||
|
const scrollable=!!bw;
|
||||||
|
const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
|
||||||
const t0=performance.now();
|
const t0=performance.now();
|
||||||
renderBody(p.el, p.text);
|
renderBody(p.el, text);
|
||||||
lastRenderMs=performance.now()-t0;
|
lastRenderMs=performance.now()-t0;
|
||||||
|
if(scrollable && stick) bw.scrollTop=bw.scrollHeight;
|
||||||
}
|
}
|
||||||
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
||||||
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
||||||
@@ -317,7 +359,7 @@ function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf);
|
|||||||
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
||||||
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
||||||
// texte resterait en retard derrière le curseur de révélation.
|
// texte resterait en retard derrière le curseur de révélation.
|
||||||
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target); }
|
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target, true); }
|
||||||
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
||||||
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
||||||
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
||||||
@@ -375,7 +417,7 @@ function handleDelta(d){
|
|||||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||||
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||||
// --- Mode code (20-mode.js) -----------------------------------------------
|
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||||
@@ -423,7 +465,7 @@ function handleDelta(d){
|
|||||||
// une trace du fil et DOIT être rejouée.
|
// une trace du fil et DOIT être rejouée.
|
||||||
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
||||||
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
||||||
if(d.stats){ T.serverStats=d.stats;
|
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
|
||||||
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
||||||
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
||||||
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
||||||
@@ -433,12 +475,24 @@ function handleDelta(d){
|
|||||||
if(d.tool_used){
|
if(d.tool_used){
|
||||||
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
||||||
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
||||||
|
// Une carte reste OUVERTE tant que SA section travaille (hauteur bornée à
|
||||||
|
// 280 px, défilement qui suit l'écriture), et se replie DÈS qu'elle est
|
||||||
|
// finie : l'outil à son résultat (tu.done, plus bas), le raisonnement quand
|
||||||
|
// le bloc suivant démarre (collapseAll à la création de la carte suivante).
|
||||||
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
||||||
renderToolMsg(T.pendingToolEl, tu);
|
renderToolMsg(T.pendingToolEl, tu);
|
||||||
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
||||||
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
||||||
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
||||||
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
||||||
|
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
||||||
|
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
||||||
|
if(tu.done){
|
||||||
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
|
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||||
|
// tour (collapseAll) la fermerait quand même.
|
||||||
|
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
|
||||||
|
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||||
return; }
|
return; }
|
||||||
if(d.drop_reasoning){
|
if(d.drop_reasoning){
|
||||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ async function loadConversations(){
|
|||||||
let r;
|
let r;
|
||||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||||
CONVS = r.conversations || [];
|
CONVS = r.conversations || [];
|
||||||
|
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
|
||||||
|
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
|
||||||
|
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
|
||||||
|
// fichier d'index pour les créations dans la même seconde.
|
||||||
|
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
|
||||||
|
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|
||||||
|
|| ord.get(b.id) - ord.get(a.id));
|
||||||
CONV_ACTIVE = r.active || '';
|
CONV_ACTIVE = r.active || '';
|
||||||
CONV_BUSY = !!r.busy;
|
CONV_BUSY = !!r.busy;
|
||||||
renderConversations();
|
renderConversations();
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function openDetails(el){
|
|||||||
if(typeof el === 'string') el = document.getElementById(el);
|
if(typeof el === 'string') el = document.getElementById(el);
|
||||||
while(el){
|
while(el){
|
||||||
if(el.tagName === 'DETAILS') el.open = true;
|
if(el.tagName === 'DETAILS') el.open = true;
|
||||||
|
// La cible vit dans un panneau de la modale de réglages : ouvrir la modale
|
||||||
|
// sur ce panneau. C'est ce qui garde tous les appelants historiques
|
||||||
|
// (pastille d'état, jobs moteur) valides depuis la refonte en modale.
|
||||||
|
if(el.classList && el.classList.contains('set-pane') && typeof openSettings === 'function'){
|
||||||
|
openSettings(el.dataset.pane);
|
||||||
|
}
|
||||||
el = el.parentElement;
|
el = el.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +52,14 @@ function openDetails(el){
|
|||||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||||
// confirmation puis relance le moteur).
|
// confirmation puis relance le moteur).
|
||||||
function onModelSwitch(sel){
|
function onModelSwitch(sel){
|
||||||
const n = parseInt(sel.value, 10);
|
const v = sel.value;
|
||||||
|
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
|
||||||
|
// active garde son contexte et son échantillonnage, seul MODEL change.
|
||||||
|
if(v && v.startsWith('m:')){
|
||||||
|
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(v, 10);
|
||||||
if(!n) return;
|
if(!n) return;
|
||||||
const name = sel.options[sel.selectedIndex].textContent;
|
const name = sel.options[sel.selectedIndex].textContent;
|
||||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||||
@@ -55,6 +68,22 @@ function onModelSwitch(sel){
|
|||||||
switchTo(n, name);
|
switchTo(n, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||||
|
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
|
||||||
|
// revient sur le modèle réellement chargé.
|
||||||
|
async function switchModelFile(value, name){
|
||||||
|
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
|
||||||
|
paintModelSwitch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('changement de modèle…');
|
||||||
|
let r = null;
|
||||||
|
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
|
||||||
|
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
|
||||||
|
toast('modèle en cours de chargement…');
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
||||||
//
|
//
|
||||||
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
||||||
@@ -67,12 +96,22 @@ function onModelSwitch(sel){
|
|||||||
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
||||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||||
// en parallèle (loadAll), aucun ordre n'est garanti.
|
// en parallèle (loadAll), aucun ordre n'est garanti.
|
||||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
|
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
|
||||||
|
|
||||||
function renderModelSwitch(presets, active){
|
function renderModelSwitch(presets, active){
|
||||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||||
paintModelSwitch();
|
paintModelSwitch();
|
||||||
}
|
}
|
||||||
|
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
|
||||||
|
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
|
||||||
|
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
|
||||||
|
async function loadModelSwitchFiles(){
|
||||||
|
try{
|
||||||
|
const r = await jget('/api/models');
|
||||||
|
MS_FILES = Array.isArray(r) ? r : [];
|
||||||
|
}catch(_){ return; }
|
||||||
|
paintModelSwitch();
|
||||||
|
}
|
||||||
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
||||||
function setLoadedModel(name){
|
function setLoadedModel(name){
|
||||||
MS_MODEL = (name || '').trim();
|
MS_MODEL = (name || '').trim();
|
||||||
@@ -99,14 +138,31 @@ function paintModelSwitch(){
|
|||||||
o.selected = true;
|
o.selected = true;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
}
|
}
|
||||||
MS_PRESETS.forEach((p, i) => {
|
if(MS_PRESETS.length){
|
||||||
const o = document.createElement('option');
|
const g = document.createElement('optgroup'); g.label = 'Presets';
|
||||||
o.value = String(i + 1);
|
MS_PRESETS.forEach((p, i) => {
|
||||||
o.textContent = p.name;
|
const o = document.createElement('option');
|
||||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
o.value = String(i + 1);
|
||||||
sel.appendChild(o);
|
o.textContent = p.name;
|
||||||
});
|
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||||
sel.disabled = !MS_PRESETS.length;
|
g.appendChild(o);
|
||||||
|
});
|
||||||
|
sel.appendChild(g);
|
||||||
|
}
|
||||||
|
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
|
||||||
|
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
|
||||||
|
if(MS_FILES.length){
|
||||||
|
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
|
||||||
|
for(const m of MS_FILES){
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = 'm:' + m.value;
|
||||||
|
o.textContent = modelLabel(m.name);
|
||||||
|
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
|
||||||
|
g.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.appendChild(g);
|
||||||
|
}
|
||||||
|
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
|
||||||
sel.title = MS_MODEL
|
sel.title = MS_MODEL
|
||||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||||
: 'aucun modèle configuré';
|
: 'aucun modèle configuré';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// ─── Réglages en modale ──────────────────────────────────────────────────────
|
||||||
|
// Toutes les sections de réglages vivent dans une modale à deux volets : la nav
|
||||||
|
// des sections à gauche, LE panneau choisi à droite. La barre latérale ne garde
|
||||||
|
// que les discussions et le moniteur machine — visuellement plus simple, et
|
||||||
|
// chaque section dispose enfin de toute la largeur.
|
||||||
|
//
|
||||||
|
// Les ids historiques des sections (svc-log-box, lc-details, tasks-details…)
|
||||||
|
// sont posés sur les .set-pane : openDetails() (18-shell.js) les résout vers le
|
||||||
|
// bon panneau, si bien que tous les chemins existants — pastille d'état du
|
||||||
|
// moteur, pastille d'installation, jobs llama.cpp — rouvrent la modale au bon
|
||||||
|
// endroit sans changer une ligne de leurs appelants.
|
||||||
|
let SET_LAST = 'config';
|
||||||
|
// Panneaux à contenu paresseux : chargés à l'affichage — l'équivalent de
|
||||||
|
// l'ancien ontoggle des <details>.
|
||||||
|
const SET_HOOKS = {
|
||||||
|
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||||
|
};
|
||||||
|
function openSettings(pane){
|
||||||
|
showModal('settings-modal');
|
||||||
|
settingsShow(pane || SET_LAST);
|
||||||
|
}
|
||||||
|
function closeSettings(){ hideModal('settings-modal'); }
|
||||||
|
function settingsShow(pane){
|
||||||
|
SET_LAST = pane;
|
||||||
|
document.querySelectorAll('#set-nav [data-pane]').forEach(b => b.classList.toggle('active', b.dataset.pane === pane));
|
||||||
|
document.querySelectorAll('#set-body .set-pane').forEach(s => s.classList.toggle('active', s.dataset.pane === pane));
|
||||||
|
const body = document.getElementById('set-body');
|
||||||
|
if(body) body.scrollTop = 0;
|
||||||
|
const h = SET_HOOKS[pane]; if(h) h();
|
||||||
|
}
|
||||||
|
// Échap ferme la modale — capture:false, donc les modales à confirmation
|
||||||
|
// (ask-modal, qui écoute en capture) gardent la main quand elles sont ouvertes.
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if(e.key !== 'Escape') return;
|
||||||
|
const m = document.getElementById('settings-modal');
|
||||||
|
if(m && m.classList.contains('show')) closeSettings();
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ─── Dictée vocale ───────────────────────────────────────────────────────────
|
||||||
|
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
|
||||||
|
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
|
||||||
|
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
|
||||||
|
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
|
||||||
|
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
|
||||||
|
//
|
||||||
|
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
|
||||||
|
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
|
||||||
|
// l'explique plutôt que d'échouer en silence.
|
||||||
|
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
|
||||||
|
|
||||||
|
async function dictateToggle(){
|
||||||
|
if(MIC){ dictateStop(); return; }
|
||||||
|
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||||||
|
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stream;
|
||||||
|
try{
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||||||
|
}catch(e){ toast('accès au micro refusé'); return; }
|
||||||
|
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
|
||||||
|
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
|
||||||
|
// impossible dans un fichier unique servi inline).
|
||||||
|
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||||
|
const chunks = [];
|
||||||
|
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
|
||||||
|
src.connect(node); node.connect(ctx.destination);
|
||||||
|
// Garde-fou : une dictée oubliée ne tourne pas des heures.
|
||||||
|
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
|
||||||
|
document.getElementById('mic').classList.add('rec');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dictateStop(){
|
||||||
|
const m = MIC; if(!m) return; MIC = null;
|
||||||
|
clearTimeout(m.timer);
|
||||||
|
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
|
||||||
|
m.stream.getTracks().forEach(t => t.stop());
|
||||||
|
try{ m.ctx.close(); }catch(_){}
|
||||||
|
const btn = document.getElementById('mic');
|
||||||
|
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
|
||||||
|
try{
|
||||||
|
const wav = encodeWav16k(m.chunks, m.rate);
|
||||||
|
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
|
||||||
|
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
|
||||||
|
const j = await r.json().catch(()=>({}));
|
||||||
|
if(r.status === 503 && j.downloading){
|
||||||
|
// Le serveur relance le téléchargement à chaque tentative ; s'il a déjà
|
||||||
|
// échoué (conteneur sans accès réseau, Hugging Face injoignable), il nous
|
||||||
|
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
|
||||||
|
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
|
||||||
|
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
|
||||||
|
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
|
||||||
|
const text = (j.text||'').trim();
|
||||||
|
if(!text){ toast('rien compris — réessaie'); return; }
|
||||||
|
const ta = document.getElementById('input');
|
||||||
|
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
|
||||||
|
autoGrow(ta); ta.focus();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
finally{ btn.classList.remove('busy'); btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
|
||||||
|
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
|
||||||
|
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
|
||||||
|
function encodeWav16k(chunks, fromRate){
|
||||||
|
const toRate = 16000;
|
||||||
|
let n = 0; for(const c of chunks) n += c.length;
|
||||||
|
const all = new Float32Array(n); let o = 0;
|
||||||
|
for(const c of chunks){ all.set(c, o); o += c.length; }
|
||||||
|
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
|
||||||
|
const pcm = new Int16Array(outN);
|
||||||
|
for(let i = 0; i < outN; i++){
|
||||||
|
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
|
||||||
|
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
|
||||||
|
}
|
||||||
|
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
|
||||||
|
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
|
||||||
|
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
|
||||||
|
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
|
||||||
|
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||||
|
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
|
||||||
|
new Int16Array(buf, 44).set(pcm);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
@@ -224,7 +224,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
||||||
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
||||||
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
||||||
.addbtn{font-size:11px;padding:4px 11px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:999px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
/* Même gabarit que les autres boutons des panneaux (angle 3 px, 12 px) : la
|
||||||
|
pilule arrondie détonnait au milieu de boutons rectangulaires. */
|
||||||
|
.addbtn{font-size:12px;padding:6px 10px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
||||||
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
||||||
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
||||||
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
||||||
@@ -656,6 +658,16 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
|||||||
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
||||||
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
||||||
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
||||||
|
/* Raisonnement / outils : hauteur BORNÉE avec défilement interne. Un long
|
||||||
|
raisonnement (des milliers de tokens) faisait grandir la page de plusieurs
|
||||||
|
écrans ; la carte reste maintenant à taille fixe et suit la génération en
|
||||||
|
défilant toute seule (voir flushRender, 09-stream.js). L'animation de repli
|
||||||
|
garde son overflow:hidden via la hauteur inline — les deux cohabitent. */
|
||||||
|
.msg.reasoning .bodywrap,.msg.tool .bodywrap{max-height:280px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
||||||
|
/* Une capture d'écran ne se lit pas par le trou d'une serrure : la carte qui en
|
||||||
|
porte une échappe au plafond (l'image a déjà son propre max-height à 420 px,
|
||||||
|
cliquable plein écran via la visionneuse). */
|
||||||
|
.msg.tool .bodywrap:has(.tool-shot){max-height:none}
|
||||||
.msg.collapsible .bodywrap>.body{min-width:0}
|
.msg.collapsible .bodywrap>.body{min-width:0}
|
||||||
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
||||||
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
||||||
@@ -1737,7 +1749,7 @@ button:active:not(:disabled){transform:translateY(1px)}
|
|||||||
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
||||||
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
||||||
taille, sa forme et son alignement d'une couche différente. */
|
taille, sa forme et son alignement d'une couche différente. */
|
||||||
#inputbar #attach,#inputbar #files-btn,#send,#stop{
|
#inputbar #attach,#inputbar #files-btn,#inputbar #mic,#send,#stop{
|
||||||
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
||||||
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
||||||
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||||
@@ -1748,8 +1760,8 @@ button:active:not(:disabled){transform:translateY(1px)}
|
|||||||
#stop{display:none}
|
#stop{display:none}
|
||||||
html[data-busy="1"] #send{display:none}
|
html[data-busy="1"] #send{display:none}
|
||||||
html[data-busy="1"] #stop{display:inline-flex}
|
html[data-busy="1"] #stop{display:inline-flex}
|
||||||
#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
#inputbar #attach svg,#inputbar #files-btn svg,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
||||||
#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
|
#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic:hover{background:var(--row-bg-on);color:var(--text)}
|
||||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||||
#send{color:var(--accent)}
|
#send{color:var(--accent)}
|
||||||
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
||||||
@@ -2021,6 +2033,51 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
#criteria-list li.crit-pending{color:var(--dim)}
|
#criteria-list li.crit-pending{color:var(--dim)}
|
||||||
|
|
||||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||||
|
|
||||||
|
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
|
||||||
|
transcription. Le gabarit (34 px, fond au survol) vient de la règle commune
|
||||||
|
des boutons de saisie — #mic y est listé avec #attach et #files-btn. */
|
||||||
|
#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
|
||||||
|
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
|
||||||
|
#mic.busy{opacity:.55}
|
||||||
|
|
||||||
|
/* ── Réglages en modale ─────────────────────────────────────────────────────
|
||||||
|
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
|
||||||
|
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
|
||||||
|
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */
|
||||||
|
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
|
||||||
|
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
|
||||||
|
font-size:13px;color:var(--text);cursor:pointer;text-align:left}
|
||||||
|
#side-settings-btn:hover{background:var(--bubble)}
|
||||||
|
#side-settings-btn svg{flex:none;opacity:.7}
|
||||||
|
.set-card{max-width:860px;height:min(660px,92vh)}
|
||||||
|
.set-shell{display:flex;flex:1;min-height:0}
|
||||||
|
.set-nav{flex:0 0 200px;border-right:1px solid var(--border);overflow-y:auto;
|
||||||
|
padding:10px 8px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.set-nav-h{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);margin:12px 8px 4px}
|
||||||
|
.set-nav-h:first-child{margin-top:2px}
|
||||||
|
.set-nav button{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:7px 10px;
|
||||||
|
background:none;border:0;border-radius:3px;box-shadow:none;
|
||||||
|
font-size:12.5px;color:var(--text);cursor:pointer;text-align:left}
|
||||||
|
.set-nav button:hover{background:var(--bubble)}
|
||||||
|
.set-nav button.active{background:var(--bubble);font-weight:600}
|
||||||
|
.set-nav button .sbadge{margin-left:auto}
|
||||||
|
.set-body{flex:1;min-width:0;overflow-y:auto;padding:16px}
|
||||||
|
.set-pane{display:none}
|
||||||
|
.set-pane.active{display:block}
|
||||||
|
.set-pane-h{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin:0 0 12px}
|
||||||
|
.set-pane-h>span:first-child{flex:1;min-width:0}
|
||||||
|
/* Téléphone : la nav passe en rangée défilante au-dessus du panneau, et la
|
||||||
|
modale prend tout l'écran — un panneau de réglages ne se lit pas en timbre. */
|
||||||
|
@media(max-width:720px){
|
||||||
|
.set-ov{padding:0}
|
||||||
|
.set-card{max-width:none;height:100vh;height:100dvh;max-height:none;border-radius:0;border:0}
|
||||||
|
.set-shell{flex-direction:column}
|
||||||
|
.set-nav{flex:none;flex-direction:row;align-items:center;overflow-x:auto;overflow-y:hidden;
|
||||||
|
border-right:0;border-bottom:1px solid var(--border);padding:8px;white-space:nowrap}
|
||||||
|
.set-nav-h{display:none}
|
||||||
|
.set-nav button{width:auto;flex:none}
|
||||||
|
}
|
||||||
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
||||||
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
||||||
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
||||||
|
|||||||
@@ -940,6 +940,38 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 200, map[string]any{"ok": true})
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
|
||||||
|
// preset : seul MODEL change dans la configuration active — contexte, NGL et
|
||||||
|
// échantillonnage sont conservés — et le service redémarre en arrière-plan,
|
||||||
|
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
|
||||||
|
// utilisable même sans avoir créé de preset.
|
||||||
|
func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
|
||||||
|
// doit pas laisser un moteur qui boucle sur un modèle introuvable.
|
||||||
|
if _, err := resolveServeModelPath(req.Value); err != nil {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := SetConfigKey("MODEL", req.Value); err != nil {
|
||||||
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
|
||||||
|
go func() {
|
||||||
|
if err := serviceAction("restart"); err != nil {
|
||||||
|
fmt.Println(red("[err] redémarrage : " + err.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
||||||
api("/api/ping", handlePing)
|
api("/api/ping", handlePing)
|
||||||
api("/api/status", handleStatus)
|
api("/api/status", handleStatus)
|
||||||
|
api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local)
|
||||||
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
||||||
api("/api/vram", handleVram)
|
api("/api/vram", handleVram)
|
||||||
api("/api/ram", handleRam)
|
api("/api/ram", handleRam)
|
||||||
@@ -178,6 +179,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/models", handleModels)
|
api("/api/models", handleModels)
|
||||||
api("/api/models/delete", handleModelDelete)
|
api("/api/models/delete", handleModelDelete)
|
||||||
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
||||||
|
api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
|
||||||
api("/api/models/download", handleModelDownload)
|
api("/api/models/download", handleModelDownload)
|
||||||
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
||||||
api("/api/models/download/status", handleModelDownloadStatus)
|
api("/api/models/download/status", handleModelDownloadStatus)
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté
|
||||||
|
// navigateur) entre, le texte transcrit sort. La transcription est 100 % locale,
|
||||||
|
// par whisper-cli (whisper.cpp, même famille que llama.cpp), compilé dans
|
||||||
|
// l'image Docker. Le modèle (ggml-small-q5_1, ~190 Mo, multilingue) est
|
||||||
|
// téléchargé au premier usage dans LOKI_HOME/whisper/ — comme le moteur, il ne
|
||||||
|
// gonfle pas l'image et survit aux recréations du conteneur via /data.
|
||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const whisperModelURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin"
|
||||||
|
|
||||||
|
// whisperBin localise whisper-cli : variable d'environnement (posée par le
|
||||||
|
// Dockerfile), sinon le PATH. Vide = dictée indisponible (image trop ancienne,
|
||||||
|
// ou poste de dev sans whisper.cpp) — le handler le dit clairement.
|
||||||
|
func whisperBin() string {
|
||||||
|
if p := os.Getenv("LOKI_WHISPER_BIN"); p != "" {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath("whisper-cli"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperModelPath() string {
|
||||||
|
return filepath.Join(LokiHome(), "whisper", "ggml-small-q5_1.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Téléchargement du modèle : UNE goroutine, progression consultable. Le premier
|
||||||
|
// POST le déclenche et répond 503 {downloading, pct} ; l'UI invite à réessayer.
|
||||||
|
var wspMu sync.Mutex
|
||||||
|
var wspDl struct {
|
||||||
|
running bool
|
||||||
|
pct int
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperStartDownload() {
|
||||||
|
wspMu.Lock()
|
||||||
|
defer wspMu.Unlock()
|
||||||
|
if wspDl.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wspDl.running, wspDl.pct, wspDl.err = true, 0, ""
|
||||||
|
go func() {
|
||||||
|
err := whisperDownload()
|
||||||
|
wspMu.Lock()
|
||||||
|
wspDl.running = false
|
||||||
|
if err != nil {
|
||||||
|
wspDl.err = err.Error()
|
||||||
|
}
|
||||||
|
wspMu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperDownload() error {
|
||||||
|
dst := whisperModelPath()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.Get(whisperModelURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
tmp := dst + ".part"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total := resp.ContentLength
|
||||||
|
var done int64
|
||||||
|
buf := make([]byte, 1<<20)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
done += int64(n)
|
||||||
|
if total > 0 {
|
||||||
|
wspMu.Lock()
|
||||||
|
wspDl.pct = int(done * 100 / total)
|
||||||
|
wspMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bin := whisperBin()
|
||||||
|
if bin == "" {
|
||||||
|
sendJSON(w, 501, map[string]any{"error": "dictée indisponible : whisper-cli absent (image à reconstruire, ou poste sans whisper.cpp)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model := whisperModelPath()
|
||||||
|
if _, err := os.Stat(model); err != nil {
|
||||||
|
wspMu.Lock()
|
||||||
|
derr := wspDl.err
|
||||||
|
pct := wspDl.pct
|
||||||
|
wspMu.Unlock()
|
||||||
|
if derr != "" {
|
||||||
|
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
|
||||||
|
wspMu.Lock()
|
||||||
|
wspDl.err = ""
|
||||||
|
wspMu.Unlock()
|
||||||
|
whisperStartDownload()
|
||||||
|
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
whisperStartDownload()
|
||||||
|
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audio, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
|
||||||
|
if err != nil || len(audio) < 1000 {
|
||||||
|
sendJSON(w, 400, map[string]any{"error": "audio vide ou trop court"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp("", "loki-dictee-*.wav")
|
||||||
|
if err != nil {
|
||||||
|
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
if _, err := tmp.Write(audio); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
// -l auto : détection de langue (le français sort en français) ; -nt sans
|
||||||
|
// horodatages ; -np sans bavardage de chargement. CPU seul : une dictée de
|
||||||
|
// quelques dizaines de secondes se transcrit en 1-3 s avec small-q5_1.
|
||||||
|
threads := runtime.NumCPU()
|
||||||
|
if threads > 8 {
|
||||||
|
threads = 8
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, bin, "-m", model, "-f", tmpPath, "-l", "auto", "-nt", "-np", "-t", strconv.Itoa(threads))
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user