v0.8.0 : code mort supprime, UI et docs en ajean, release unique

- suppression des fonctions et types inutilises (staticcheck U1000)
- sources UI, workflows, README passes en ajean
- release.yml ne publie plus que les binaires ajean-*
- notes de release redigees
This commit is contained in:
nathaninline committed 2026-08-07 17:12:16 +02:00
1 parent 62ad4155a2
commit 687ec81023
26 files changed
+123 -214

No files matched your search

+6 -6
View File
@@ -18,17 +18,17 @@ jobs:
mkdir -p dist mkdir -p dist
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-arm64 ./cmd/ajean
mkdir -p /tmp/jean.iconset mkdir -p /tmp/ajean.iconset
sips -s format png 'cmd/ajean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true sips -s format png 'cmd/ajean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true
for s in 16 32 128 256 512; do for s in 16 32 128 256 512; do
sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true sips -z $s $s /tmp/icon.png --out "/tmp/ajean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true
done done
iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns >/dev/null 2>&1 || true
app="dist/AJEAN.app" app="dist/AJEAN.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean" cp dist/ajean-darwin-arm64 "$app/Contents/MacOS/ajean"
chmod +x "$app/Contents/MacOS/ajean" chmod +x "$app/Contents/MacOS/ajean"
cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true
printf '%s\n' \ printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \ '<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
@@ -48,5 +48,5 @@ jobs:
ls -l dist ls -l dist
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: Jean-macos-arm64 name: AJEAN-macos-arm64
path: dist/Jean-macos-arm64.zip path: dist/AJEAN-macos-arm64.zip
+13 -25
View File
@@ -1,9 +1,9 @@
# Release : sur tag vX.Y.Z, compile les 6 cibles et publie la release GitHub # Release : sur tag vX.Y.Z, compile les 6 cibles et publie la release GitHub
# avec les binaires (mêmes noms d'assets que les releases manuelles historiques) # avec les binaires (mêmes noms d'assets que les releases manuelles historiques)
# ET le fichier SHA256SUMS que `jean update` (v0.4.6+) vérifie avant d'installer. # ET le fichier SHA256SUMS que `ajean update` (v0.4.6+) vérifie avant d'installer.
# ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les # ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les
# assets du même nom (softprops remplace), et des sommes qui ne correspondent # assets du même nom (softprops remplace), et des sommes qui ne correspondent
# plus aux binaires font échouer `jean update` (vécu sur v0.4.7). # plus aux binaires font échouer `ajean update` (vécu sur v0.4.7).
# #
# Deux jobs de build : macOS a besoin d'un vrai runner Apple (l'icône de barre de # Deux jobs de build : macOS a besoin d'un vrai runner Apple (l'icône de barre de
# menus passe par Cocoa → CGO, pas cross-compilable depuis Linux) ; Linux et # menus passe par Cocoa → CGO, pas cross-compilable depuis Linux) ; Linux et
@@ -34,29 +34,29 @@ jobs:
CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean go build -trimpath -ldflags="-s -w" -o dist/ajean-darwin-amd64 ./cmd/ajean
file dist/ajean-darwin-* file dist/ajean-darwin-*
- name: bundles Jean.app - name: bundles AJEAN.app
run: | run: |
# Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc
# aussi un vrai bundle Jean.app (zippé pour garder le bit exécutable) : # aussi un vrai bundle AJEAN.app (zippé pour garder le bit exécutable) :
# au clic, le Finder lance Contents/MacOS/jean, qui détecte le bundle # au clic, le Finder lance Contents/MacOS/ajean, qui détecte le bundle
# et bascule en mode « application » (UI web + navigateur + icône dans # et bascule en mode « application » (UI web + navigateur + icône dans
# la barre de menus), comme le double-clic sur jean.exe sous Windows. # la barre de menus), comme le double-clic sur ajean.exe sous Windows.
# LSUIElement : app de barre de menus, aucune icône dans le Dock. # LSUIElement : app de barre de menus, aucune icône dans le Dock.
# L'icône part du PNG 1024 généré avec le reste de la marque # L'icône part du PNG 1024 généré avec le reste de la marque
# (cmd/ajean/icon.png, voir tools/gen-icon) : plus de conversion depuis # (cmd/ajean/icon.png, voir tools/gen-icon) : plus de conversion depuis
# le .ico, dont l'échec silencieux donnait une app sans icône. # le .ico, dont l'échec silencieux donnait une app sans icône.
mkdir -p /tmp/jean.iconset mkdir -p /tmp/ajean.iconset
for s in 16 32 128 256 512; do for s in 16 32 128 256 512; do
sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/ajean.iconset/icon_${s}x${s}.png" >/dev/null
done done
iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns
v="${GITHUB_REF_NAME#v}" v="${GITHUB_REF_NAME#v}"
for arch in amd64 arm64; do for arch in amd64 arm64; do
app="dist/mac-$arch/AJEAN.app" app="dist/mac-$arch/AJEAN.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean" cp "dist/ajean-darwin-$arch" "$app/Contents/MacOS/ajean"
chmod +x "$app/Contents/MacOS/ajean" chmod +x "$app/Contents/MacOS/ajean"
cp /tmp/jean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true
printf '%s\n' \ printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \ '<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
@@ -80,7 +80,7 @@ jobs:
# Nom hérité conservé : les liens de téléchargement déjà publiés # Nom hérité conservé : les liens de téléchargement déjà publiés
# (site, mails, messages) pointent dessus et doivent continuer de # (site, mails, messages) pointent dessus et doivent continuer de
# fonctionner. Même contenu, deux noms. # fonctionner. Même contenu, deux noms.
cp "dist/AJEAN-macos-$arch.zip" "dist/Jean-macos-$arch.zip" cp "dist/AJEAN-macos-$arch.zip" "dist/AJEAN-macos-$arch.zip"
rm -rf "dist/mac-$arch" rm -rf "dist/mac-$arch"
done done
ls -l dist ls -l dist
@@ -112,22 +112,10 @@ jobs:
with: with:
name: macos name: macos
path: dist path: dist
- name: assets hérités jean-* (transition)
run: |
# Le parc déjà installé ne sait chercher QUE « jean-<os>-<arch> » : sans
# ces copies, aucun des utilisateurs actuels ne pourrait se mettre à
# jour — pas même vers la version qui apprend à lire « ajean-* ».
# Après cette étape SEULEMENT, donc une fois les binaires macOS
# récupérés, les deux jeux de noms sont complets.
# À supprimer quand le parc aura basculé (quelques versions).
for f in dist/ajean-*; do
cp "$f" "dist/jean-${f#dist/ajean-}"
done
ls -l dist
- name: SHA256SUMS - name: SHA256SUMS
run: | run: |
chmod +x dist/ajean-darwin-* dist/jean-darwin-* chmod +x dist/ajean-darwin-*
(cd dist && sha256sum ajean-* jean-* *.zip > SHA256SUMS) (cd dist && sha256sum ajean-* *.zip > SHA256SUMS)
ls -l dist && cat dist/SHA256SUMS ls -l dist && cat dist/SHA256SUMS
- name: publier la release - name: publier la release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
+14 -10
View File
@@ -41,7 +41,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur
### 1. Le binaire ### 1. Le binaire
```bash ```bash
curl -L -o jean https://github.com/nathaninline/ajean/releases/latest/download/jean-linux-amd64 curl -L -o ajean https://github.com/nathaninline/ajean/releases/latest/download/ajean-linux-amd64
chmod +x ajean chmod +x ajean
sudo mv ajean /usr/local/bin/ajean sudo mv ajean /usr/local/bin/ajean
``` ```
@@ -78,7 +78,7 @@ Service :
start | stop | restart gérer le service start | stop | restart gérer le service
status | logs état / logs en direct status | logs état / logs en direct
enable | disable démarrage au boot enable | disable démarrage au boot
edit éditer $AJEAN_HOME/config.env edit éditer la configuration dans $EDITOR
test | bench [N] vérifier que le modèle répond / mesurer les tok/s test | bench [N] vérifier que le modèle répond / mesurer les tok/s
vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) vram | gpu [index…] VRAM / choix des GPU (gpu all = tous)
set-api-key [clé] protéger le moteur d'inférence (Bearer) set-api-key [clé] protéger le moteur d'inférence (Bearer)
@@ -90,7 +90,7 @@ Capacités de l'IA :
internet [on|off|engine <go|crawl4ai>|url <url>|key <clé>] accès web internet [on|off|engine <go|crawl4ai>|url <url>|key <clé>] accès web
Presets et moteur : Presets et moteur :
switch [N] changer de preset (configs/) switch [N] changer de preset (presets/)
llamacpp install|update|status llamacpp install|update|status
Accès distant (ajean.link) : Accès distant (ajean.link) :
@@ -107,7 +107,11 @@ Installation :
## Configuration ## Configuration
Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows). Le service lit `config.env` : Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows), qui contient exactement six dossiers — `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/` — et la base `ajean.db`.
Cette base, un unique fichier [bbolt](https://github.com/etcd-io/bbolt), remplace la dizaine de fichiers d'état d'autrefois : configuration, préférences de l'interface, conversation, clés et interrupteurs y vivent désormais ensemble. Restent des fichiers ce qui se lit et s'édite à la main : les presets (`presets/*.env`), les pages de mémoire (`memory/*.md`) et, bien sûr, les modèles.
La configuration du moteur s'édite avec `ajean edit`, qui la déroule au format `clé=valeur` dans `$EDITOR` :
| Clé | Signification | Défaut | | Clé | Signification | Défaut |
|-----|---------------|--------| |-----|---------------|--------|
@@ -127,9 +131,9 @@ Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\a
| `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — | | `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — |
| `EXTRA_ARGS` | ajouté tel quel à la ligne de commande du moteur | — | | `EXTRA_ARGS` | ajouté tel quel à la ligne de commande du moteur | — |
La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset. La clé API (`ajean set-api-key`) est rangée hors de la configuration, afin de survivre aux changements de preset.
**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$AJEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre. **Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$AJEAN_HOME/models` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans la base, donc conservée d'un preset à l'autre.
### Variables d'environnement ### Variables d'environnement
@@ -137,7 +141,7 @@ La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin
|----------|------|--------| |----------|------|--------|
| `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` | | `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` |
| `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | | `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — |
| `JEAN_SERVICE` | nom de l'unité systemd | `ajean` | | `AJEAN_SERVICE` | nom de l'unité systemd | `ajean` |
| `HF_TOKEN` | token Hugging Face pour les modèles privés | — | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — |
| `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — | | `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — |
| `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` | | `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` |
@@ -146,7 +150,7 @@ La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin
### Mémoire ### Mémoire
L'IA tient des pages Markdown sous `$AJEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent : L'IA tient des pages Markdown sous `$AJEAN_HOME/memory/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent :
```bash ```bash
ajean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même ajean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même
@@ -182,7 +186,7 @@ Les outils web ne sont proposés au modèle que si le mode agent est actif, l'ac
AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp__<serveur>__<outil>`. AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp__<serveur>__<outil>`.
La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$AJEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel : La configuration se fait depuis l'interface web (section *Serveurs MCP*) (section *Serveurs MCP*). Le format des serveurs est celui de Claude Desktop, si bien qu'une configuration existante se recopie telle quelle :
```json ```json
{ {
@@ -199,7 +203,7 @@ Les transports **stdio** et **HTTP** sont pris en charge. Comme le terminal, un
- **Pas de systemd** : `ajean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée. - **Pas de systemd** : `ajean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée.
- `AJEAN_HOME` vaut `%ProgramData%\ajean` (repli `%LOCALAPPDATA%\ajean`). - `AJEAN_HOME` vaut `%ProgramData%\ajean` (repli `%LOCALAPPDATA%\ajean`).
- `ajean install` crée seulement le dossier de données et un `config.env` de départ. - `ajean install` crée seulement l'arborescence de données et une configuration de départ.
- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell, ce qui lui permet de produire des scripts contenant des guillemets. - Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell, ce qui lui permet de produire des scripts contenant des guillemets.
```powershell ```powershell
+12 -22
View File
@@ -1,33 +1,23 @@
Correctifs d'interface dans la foulée de la 0.7.11. Tout ce qui bougeait, sautait ou clignotait après un chargement se tient enfin tranquille. Le renommage est terminé. Plus rien ne s'appelle jean : ni le binaire, ni les services, ni les variables, ni les dossiers. Et le dossier de données, qui s'était couvert d'une douzaine de petits fichiers d'état, tient désormais dans six dossiers et une base.
## L'éditeur de preset ne s'ouvre plus au milieu **Cette version ne reprend pas les installations précédentes.** Il n'y a aucune migration : ni des dossiers, ni des unités système, ni des réglages. Réinstallez, puis retéléchargez vos modèles ou repointez-les avec *Dossiers de modèles*.
Ouvrir un preset après en avoir fait défiler un autre rouvrait la fiche à la position précédente. La remise à zéro du défilement existait, mais elle était faite avant l'affichage : écrire une position de défilement sur un élément non affiché ne fait rien du tout, le navigateur restaurait donc l'ancienne. Elle se fait maintenant après l'affichage, puis une seconde fois une fois le contenu en place. ## Une base à la place des fichiers d'état
## Le sélecteur de moteur ne glisse plus tout seul `config.env`, `webprefs.json`, `conversation.json`, `model_dirs.json`, `mcp.json`, `.api_key`, `.web_key`, `.link_token`, `.agent_enabled`, `.internet_enabled` et le reste ont disparu. Tout cela vit maintenant dans **`ajean.db`**, un fichier unique en [bbolt](https://github.com/etcd-io/bbolt) — pur Go, transactionnel, sans dépendance système.
En passant d'un preset à l'autre, on voyait le curseur du sélecteur (précompilé, compilé, personnalisé) glisser de l'ancienne valeur vers la nouvelle une fois le chargement terminé. Le changement d'état avait pourtant lieu pendant que le formulaire était caché : WebKit diffère les transitions des éléments non rendus et les rejoue à leur retour à l'écran. Les transitions du formulaire sont donc coupées tant qu'il est masqué, et pendant l'image où il réapparaît. Ce n'est pas qu'un rangement. Chaque fichier avait sa façon d'être écrit, et donc sa façon de rater une écriture concurrente : l'interface et un tour de chat qui touchaient au même réglage au même instant pouvaient en perdre un. Une transaction remplace tout ça. Une bascule de preset, en particulier, remplace la configuration d'un bloc : il n'existe plus d'instant où elle serait à moitié l'ancienne et à moitié la nouvelle.
Dans le même esprit, le voile de chargement de la modale est devenu opaque, sous le titre resté lisible. Rien du preset précédent ne peut plus apparaître une fraction de seconde. Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardés à la main : les presets, les pages de mémoire, les modèles. `ajean edit` déroule donc la configuration au format `clé=valeur` dans votre éditeur, puis la relit — le fichier n'existe que le temps de l'édition.
## Le menu ne saute plus au chargement ## Six dossiers, et rien d'autre
Plusieurs blocs (config active, jauges machine, liste des presets, état de l'accès internet, serveurs MCP) sont vides tant que le serveur n'a pas répondu, puis prennent leur taille réelle : tout le menu se décalait d'un cran, ce qui se voyait surtout sur les sections du bas. Leur hauteur est désormais mémorisée d'une session à l'autre et réservée dès le démarrage, puis rendue quand le vrai contenu arrive. La réserve tient la place, elle n'affiche jamais d'information fausse. `$AJEAN_HOME` contient exactement `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine.
Les champs Crawl4AI, eux, étaient visibles au départ puis repliés dès que l'état arrivait, le moteur par défaut étant l'intégré. Ils partent maintenant masqués. La section Moteur ne grandit plus non plus : son texte d'en-tête part à sa taille définitive et la ligne d'état des trois moteurs a sa hauteur réservée. ## Ce qui a été retiré
Au passage, un chargement en échec (accès distant coupé par exemple) n'interrompt plus les autres. Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second.
## Détails La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée. Chaque commande a désormais un seul nom.
Les pastilles « actif » et « inactif » disparaissent de l'accès internet et des serveurs MCP : l'interrupteur et la liste le disent déjà. Reste la seule information utile, l'anomalie « injoignable », qui signale que les outils web ne sont pas fournis au modèle. Les releases ne publient plus qu'un jeu de binaires, `ajean-<os>-<arch>`. La double publication qui accompagnait la transition n'a plus d'objet.
Dans la mémoire, la ligne « Voir la mémoire » perd son chevron et son libellé est enfin centré. Créer une page annonçait « Nouveau Page » et demandait un « Nom du preset » : chaque type a maintenant ses propres libellés.
## Mise à jour
```
ajean update
```
Chaque correction a été vérifiée en mesurant le rendu réel, position et hauteur des éléments avant et après chargement, pas seulement à l'oeil.
+1 -3
View File
@@ -100,9 +100,7 @@ func cmdServe(args []string) error {
llmArgs = append(llmArgs, "--api-key", k) llmArgs = append(llmArgs, "--api-key", k)
} }
// EXTRA_ARGS is appended verbatim, split on whitespace like the shell would. // EXTRA_ARGS is appended verbatim, split on whitespace like the shell would.
for _, a := range trimSplit(cfg["EXTRA_ARGS"], " ") { llmArgs = append(llmArgs, trimSplit(cfg["EXTRA_ARGS"], " ")...)
llmArgs = append(llmArgs, a)
}
// Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve. // mmproj-F16.gguf) still resolve.
-1
View File
@@ -471,7 +471,6 @@ type searchResult struct {
} }
var ( var (
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
numEntityRe = regexp.MustCompile(`&#(\d+);`) numEntityRe = regexp.MustCompile(`&#(\d+);`)
ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`) ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`)
-20
View File
@@ -368,23 +368,3 @@ func cmdMemory(args []string) error {
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m]) fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
return nil return nil
} }
// memorySystemPrompt liste les pages mémoire à injecter quand le mode agent est
// actif, pour que l'IA sache ce qu'elle a déjà retenu.
func memorySystemPrompt(caps Caps) string {
if !caps.Agent {
return ""
}
list := MemList()
var b strings.Builder
b.WriteString("Memory: persistent Markdown pages under MEMORY/. Use mem_search FIRST whenever the user asks about something you might already know (preferences, ongoing projects, past decisions). Open the most relevant page with mem_read. Save anything worth keeping across sessions with mem_add (one topic per page, descriptive kebab-case name); update a page with mem_edit (old must match exactly once). The first content line is a short title (#).\n")
if len(list) == 0 {
b.WriteString("Pages: none yet.")
return b.String()
}
b.WriteString("Pages:\n")
for _, p := range list {
fmt.Fprintf(&b, "- %s: %s\n", p.Name, p.Title)
}
return strings.TrimRight(b.String(), "\n")
}
-5
View File
@@ -112,11 +112,6 @@ func (c MCPServerConfig) Validate() error {
return nil return nil
} }
// mcpConfigFile est la représentation sur disque de mcp.json.
type mcpConfigFile struct {
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
}
// mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs // mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs
// MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle). // MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle).
var mcpConfigMu sync.Mutex var mcpConfigMu sync.Mutex
-31
View File
@@ -257,34 +257,3 @@ func writeE2EResp(w http.ResponseWriter, gcm cipher.AEAD, status int, body []byt
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(sealed))) _, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(sealed)))
} }
// e2eSeal (côté agent : utilisé uniquement pour les tests) scelle un secret vers
// une clé publique X25519. La version navigateur est en WASM (wasmclient).
func e2eSeal(agentPub, secret []byte) ([]byte, error) {
pub, err := ecdh.X25519().NewPublicKey(agentPub)
if err != nil {
return nil, err
}
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
shared, err := eph.ECDH(pub)
if err != nil {
return nil, err
}
key := sealKey(shared, eph.PublicKey().Bytes(), agentPub)
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ct := gcm.Seal(nil, nonce, secret, nil)
out := append([]byte{}, eph.PublicKey().Bytes()...)
out = append(out, nonce...)
out = append(out, ct...)
return out, nil
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
const Version = "0.7.13" const Version = "0.8.0"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() { func Main() {
+1 -9
View File
@@ -32,7 +32,7 @@ var glyphRects = [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}}
// brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le // brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le
// « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône // « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône
// « template » de macOS (voir brandTemplatePNG). // « template » de macOS.
func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA { func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA {
const r = 2.0 // rayon des coins, en unités de la grille 12 const r = 2.0 // rayon des coins, en unités de la grille 12
outside := func(gx, gy float64) bool { outside := func(gx, gy float64) bool {
@@ -84,14 +84,6 @@ func encodePNG(img *image.RGBA) []byte {
// Exporté pour le générateur d'icône du .exe (tools/gen-icon). // Exporté pour le générateur d'icône du .exe (tools/gen-icon).
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) } func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
// couche alpha compte, le système colore la forme selon le thème de la barre de
// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque — sans ça,
// une icône entièrement noire disparaît sur une barre de menus sombre.
func brandTemplatePNG(n int) []byte {
return encodePNG(brandIconImage(n, brandBlack, brandClear))
}
// BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows // BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows
// accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins // accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins
// arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches // arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches
+2 -5
View File
@@ -16,9 +16,8 @@ import (
"time" "time"
) )
// defaultAjeanHome is the data root when neither $AJEAN_HOME/$AJEAN_HOME nor // defaultAjeanHome est la racine des données quand ni $AJEAN_HOME ni
// /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le // /etc/default/ajean n'en imposent une.
// renommage, migré au premier lancement (voir sys_migrate.go).
// //
// Linux : /etc/ajean (machine de prod, service systemd lancé par root). // Linux : /etc/ajean (machine de prod, service systemd lancé par root).
// macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder // macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder
@@ -27,8 +26,6 @@ import (
// un `sudo ajean install` a déjà créé /etc/ajean à notre nom. // un `sudo ajean install` a déjà créé /etc/ajean à notre nom.
func defaultAjeanHome() string { return unixHome("ajean") } func defaultAjeanHome() string { return unixHome("ajean") }
func legacyDefaultHome() string { return unixHome("ajean") }
func unixHome(name string) string { func unixHome(name string) string {
if runtime.GOOS != "darwin" || os.Geteuid() == 0 { if runtime.GOOS != "darwin" || os.Geteuid() == 0 {
return "/etc/" + name return "/etc/" + name
+1 -4
View File
@@ -54,12 +54,9 @@ func homeRoot() string {
return os.TempDir() return os.TempDir()
} }
// defaultAjeanHome is the data root when no AJEAN_HOME/AJEAN_HOME is set. // defaultAjeanHome est la racine des données quand $AJEAN_HOME est absent.
func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") } func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
// legacyDefaultHome is the pre-rename data root, migrated on first run.
func legacyDefaultHome() string { return filepath.Join(homeRoot(), "ajean") }
// defaultEditor is used by `ajean edit` when $EDITOR is unset. // defaultEditor is used by `ajean edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" } func defaultEditor() string { return "notepad" }
+36 -36
View File
@@ -22,7 +22,7 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. --> garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
<script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script> <script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script>
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. --> <!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{var t=localStorage.getItem('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script> <script>try{var t=localStorage.getItem('ajean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('ajean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style> <style>
/* ===== Palette ===================================================== /* ===== Palette =====================================================
Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par
@@ -172,7 +172,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.ask-ok.danger{border-color:var(--err);color:var(--err)} .ask-ok.danger{border-color:var(--err);color:var(--err)}
#brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px} #brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px}
/* Mode agent : le A passe en couleur d'accent (il était masqué hors agent, /* Mode agent : le A passe en couleur d'accent (il était masqué hors agent,
la marque se lisant alors « JEAN »). L'état agent reste signalé par sa la marque se lisant alors « AJEAN »). L'état agent reste signalé par sa
pastille dans les réglages. */ pastille dans les réglages. */
#brand.agent #brand-a{color:var(--accent)} #brand.agent #brand-a{color:var(--accent)}
/* interrupteur (switch) */ /* interrupteur (switch) */
@@ -961,7 +961,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* ===== Mise en page de la conversation ============================= /* ===== Mise en page de la conversation =============================
Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond), Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond),
question de l'utilisateur en pastille arrondie à droite, composer en question de l'utilisateur en pastille arrondie à droite, composer en
« pilule », pas d'étiquettes VOUS/JEAN. Ces règles surchargent la mise en « pilule », pas d'étiquettes VOUS/AJEAN. Ces règles surchargent la mise en
page générique plus haut : elles doivent donc rester APRÈS elle. */ page générique plus haut : elles doivent donc rester APRÈS elle. */
body{font-size:15px} body{font-size:15px}
h1{color:var(--text);font-weight:600;letter-spacing:-.01em} h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
@@ -975,7 +975,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */ /* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6} #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
#chat .msg.user .label{color:var(--dim)} #chat .msg.user .label{color:var(--dim)}
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les /* Étiquettes VOUS / AJEAN : inutiles ici (l'alignement suffit), sauf pour les
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */ bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
#chat .msg .label{display:none} #chat .msg .label{display:none}
#chat .msg.collapsible .label{display:block} #chat .msg.collapsible .label{display:block}
@@ -1227,7 +1227,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
trois îlots pour un même réglage. --> trois îlots pour un même réglage. -->
<div class="slist"> <div class="slist">
<div class="rowbody"> <div class="rowbody">
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>jean</code></div> <div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>ajean</code></div>
<div class="row" style="margin:0"> <div class="row" style="margin:0">
<input id="oai-url" readonly class="io-input"> <input id="oai-url" readonly class="io-input">
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button> <button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
@@ -1257,7 +1257,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div></div> </div></div>
</details> </details>
<details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary> <details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div> <div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur AJEAN depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). --> <!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
<div id="remote-off" class="slist"><div class="rowbody"> <div id="remote-off" class="slist"><div class="rowbody">
<p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p> <p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p>
@@ -1764,14 +1764,14 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open');
let _sysT = null; let _sysT = null;
function saveSys(){ function saveSys(){
const v = document.getElementById('sysprompt').value; const v = document.getElementById('sysprompt').value;
localStorage.setItem('jean.sys', v); localStorage.setItem('ajean.sys', v);
clearTimeout(_sysT); clearTimeout(_sysT);
_sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600); _sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600);
} }
async function loadSys(){ async function loadSys(){
try{ try{
const d = await jget('/api/sysprompt'); const d = await jget('/api/sysprompt');
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('jean.sys', d.text || ''); } if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('ajean.sys', d.text || ''); }
}catch(e){} }catch(e){}
} }
// ===== Apparence ============================================================= // ===== Apparence =============================================================
@@ -1785,7 +1785,7 @@ function applyTheme(id){
// désormais la variante, déduite du suffixe. // désormais la variante, déduite du suffixe.
const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark')); const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark'));
document.documentElement.setAttribute('data-theme', dark?'dark':'light'); document.documentElement.setAttribute('data-theme', dark?'dark':'light');
try{ localStorage.setItem('jean-theme', dark?'dark':'light'); }catch(e){} try{ localStorage.setItem('ajean-theme', dark?'dark':'light'); }catch(e){}
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark; const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark;
} }
function toggleThemeDark(){ function toggleThemeDark(){
@@ -1793,7 +1793,7 @@ function toggleThemeDark(){
savePrefs(); savePrefs();
} }
function initTheme(){ function initTheme(){
let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){} let id='light'; try{ id=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
applyTheme(id); applyTheme(id);
} }
// ===== Affichage ============================================================ // ===== Affichage ============================================================
@@ -1810,7 +1810,7 @@ const VIEW_OPTS=[
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; } function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
function applyView(id, on){ function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0'); document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){} try{ localStorage.setItem('ajean-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on; const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe. // La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){ if(id==='hide-side' && !on){
@@ -1822,18 +1822,18 @@ function applyView(id, on){
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); } function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){ function initView(){
VIEW_OPTS.forEach(o=>{ VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){} let v=null; try{ v=localStorage.getItem('ajean-'+o.id); }catch(e){}
applyView(o.id, v==='1'); applyView(o.id, v==='1');
}); });
} }
// ===== Persistance côté serveur (partagée entre appareils) ================== // ===== Persistance côté serveur (partagée entre appareils) ==================
// L'apparence est aussi enregistrée sur la machine jean (/api/prefs) : ainsi le // L'apparence est aussi enregistrée sur la machine ajean (/api/prefs) : ainsi le
// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le // thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le
// localStorage reste utilisé pour appliquer instantanément au chargement (sans // localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité). // flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){ function savePrefs(){
let theme='light'; let theme='light';
try{ theme=localStorage.getItem('jean-theme')||'light'; }catch(e){} try{ theme=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
const p={theme}; const p={theme};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; }); VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{}); jpost('/api/prefs', p).catch(()=>{});
@@ -1855,7 +1855,7 @@ async function loadPrefs(){
} }
}catch(e){} }catch(e){}
} }
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); }); document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('ajean.sys') || ''; loadSys(); restoreChat(); });
// Pastille d'état d'une section du menu (mode agent, internet, MCP, distant). // Pastille d'état d'une section du menu (mode agent, internet, MCP, distant).
// `state` : true = actif (pilule pleine), false = inactif (contour), 'warn'/'err' // `state` : true = actif (pilule pleine), false = inactif (contour), 'warn'/'err'
// = état signalé, null = pastille masquée. Un seul endroit décide de leur allure. // = état signalé, null = pastille masquée. Un seul endroit décide de leur allure.
@@ -1907,7 +1907,7 @@ function _askKey(e){
else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); } else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); }
} }
function _openAsk(kind, message, opts){ function _openAsk(kind, message, opts){
// Compat 2 conventions : (message, opts) [UI jean] ET l'objet unique // Compat 2 conventions : (message, opts) [UI ajean] ET l'objet unique
// {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html, // {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html,
// où cette fonction remplace le window.askConfirm du bootstrap boîte noire). // où cette fonction remplace le window.askConfirm du bootstrap boîte noire).
if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; }
@@ -1939,14 +1939,14 @@ function askPrompt(message,opts){ return _openAsk('prompt',message,opts); }
function askAlert(message,opts){ return _openAsk('alert',message,opts); } function askAlert(message,opts){ return _openAsk('alert',message,opts); }
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*. // Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage. // Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
let TOKEN = localStorage.getItem('jean.key') || ''; let TOKEN = localStorage.getItem('ajean.key') || '';
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; } function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link. // Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel. // Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, ''); const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le // Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint // navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS : // dès qu'on laisse traîner plusieurs onglets AJEAN) reste suspendue POUR TOUJOURS :
// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher. // ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal // Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
// et ne sont donc jamais concernés. // et ne sont donc jamais concernés.
@@ -1969,8 +1969,8 @@ async function jfetch(u, opts){
} }
finally{ if(timer) clearTimeout(timer); } finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){ if(r.status === 401){
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'}); const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
} }
return r; return r;
} }
@@ -2154,7 +2154,7 @@ const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list
function reserveHeights(){ function reserveHeights(){
HRESERVE.forEach(id=>{ HRESERVE.forEach(id=>{
try{ try{
const h=parseInt(localStorage.getItem('jean-h-'+id)||'0',10); const h=parseInt(localStorage.getItem('ajean-h-'+id)||'0',10);
const el=document.getElementById(id); const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px'; if(el && h>0) el.style.minHeight=h+'px';
}catch(e){} }catch(e){}
@@ -2164,7 +2164,7 @@ function releaseHeights(){
HRESERVE.forEach(id=>{ HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return; const el=document.getElementById(id); if(!el) return;
el.style.minHeight=''; el.style.minHeight='';
try{ localStorage.setItem('jean-h-'+id, String(el.offsetHeight||0)); }catch(e){} try{ localStorage.setItem('ajean-h-'+id, String(el.offsetHeight||0)); }catch(e){}
}); });
} }
document.addEventListener('DOMContentLoaded', reserveHeights); document.addEventListener('DOMContentLoaded', reserveHeights);
@@ -2243,7 +2243,7 @@ async function loadPresets(){
}); });
} }
// « Mode agent » = accès machine + skills réunis en un seul interrupteur. // « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « jean » → « ajean ». // Quand il est actif, un « a » blanc apparaît en fondu devant « ajean » → « ajean ».
async function loadAgent(){ async function loadAgent(){
const s=await jget('/api/agent'); const s=await jget('/api/agent');
const on = s.enabled; const on = s.enabled;
@@ -2662,7 +2662,7 @@ async function populateModelPicker(){
const list = await jget('/api/models'); const list = await jget('/api/models');
const cur = currentModelInTextarea(); const cur = currentModelInTextarea();
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre). // regroupe par dossier, AJEAN_HOME d'abord (l'API les renvoie dans cet ordre).
const groups = []; const groups = [];
for(const m of (list||[])){ for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir); let g = groups.find(x => x.dir === m.dir);
@@ -2681,7 +2681,7 @@ async function populateModelPicker(){
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>'; opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
} }
html += groups.length > 1 html += groups.length > 1
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : g.dir)+'">'+opts+'</optgroup>' ? '<optgroup label="'+escHtml(g.home ? 'dossier ajean' : g.dir)+'">'+opts+'</optgroup>'
: opts; : opts;
} }
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
@@ -2692,7 +2692,7 @@ async function populateModelPicker(){
sel.innerHTML = html; sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true); if(cur && !matched) setModelDirsOpen(true);
} }
// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) --- // ---- Dossiers de modèles : AJEAN_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. // Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){ function setModelDirsOpen(open){
const b = document.getElementById('m-dirs-body'); const b = document.getElementById('m-dirs-body');
@@ -2721,7 +2721,7 @@ async function populateModelDirs(){
info.className = 'muted'; info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : ''; const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')'; info.textContent = '(' + n + free + (x.home ? ', dossier ajean' : '') + ')';
row.append(p, info); row.append(p, info);
if(!x.home){ if(!x.home){
const del = document.createElement('button'); const del = document.createElement('button');
@@ -2792,13 +2792,13 @@ function underDir(p, dir){
} }
let beFastPath = '', beOptPath = '', beFastDir = ''; let beFastPath = '', beOptPath = '', beFastDir = '';
async function populateBackend(){ async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean). // Chemins des deux moteurs gérés + liste des backends détectés (dossier ajean).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){} let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || ''; beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || ''; beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || ''; beOptPath = lc.bin || '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans // Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de jean (l'utilisateur peut y déposer son propre build). // le dossier backends de ajean (l'utilisateur peut y déposer son propre build).
const detected = await jget('/api/backends'); const detected = await jget('/api/backends');
const sel = document.getElementById('m-backend-detected'); const sel = document.getElementById('m-backend-detected');
let html = '<option value="">— ou choisir un backend détecté —</option>'; let html = '<option value="">— ou choisir un backend détecté —</option>';
@@ -3132,7 +3132,7 @@ function populateSettings(){
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ---------------- // --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée // Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version // par des virgules ; une valeur composée (ou un type sorti après cette version
// de jean) ne correspondrait à aucune option et serait silencieusement effacée // de ajean) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de // au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre. // la perdre.
function setSpecType(v){ function setSpecType(v){
@@ -3328,7 +3328,7 @@ function watchDownload(fname){
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop(); stop();
await Promise.all([populateModelPicker(), populateDlDirs()]); await Promise.all([populateModelPicker(), populateDlDirs()]);
// Le modèle a pu atterrir hors du dossier jean : l'option porte alors le // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le
// chemin complet, pas le simple nom de fichier. // chemin complet, pas le simple nom de fichier.
const sel = document.getElementById('m-model'); const sel = document.getElementById('m-model');
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
@@ -3483,7 +3483,7 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt); lab.appendChild(cnt);
} }
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN // Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/AJEAN
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
// par la préférence d'affichage « masquer la vitesse de génération » (CSS). // par la préférence d'affichage « masquer la vitesse de génération » (CSS).
@@ -3644,7 +3644,7 @@ async function compactContext(){
} }
// Persistance de la conversation : on garde user+assistant en localStorage pour // Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées). // survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} } function saveChat(){ try{ localStorage.setItem('ajean.chat', JSON.stringify(msgs)); }catch(e){} }
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement // Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte, // permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de // appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
@@ -3723,7 +3723,7 @@ function autoGrow(ta){
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden'; ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
} }
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) ===== // ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur jean. Le client ouvre un // L'historique et la génération vivent sur le serveur ajean. Le client ouvre un
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis // flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris. // serveur) ; se reconnecter rejoue tout le fil, détails compris.
@@ -3921,7 +3921,7 @@ function handleDelta(d){
} }
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de // Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil). // re-téléchargement complet après une coupure / bascule d'appareil).
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé // Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet AJEAN laissé
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine : // ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
// au-delà, toute requête (journal, installation du moteur…) reste en file // au-delà, toute requête (journal, installation du moteur…) reste en file
// d'attente sans jamais partir ni échouer — un blocage silencieux très // d'attente sans jamais partir ni échouer — un blocage silencieux très
@@ -4477,7 +4477,7 @@ async function deleteMcp(){
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal. // Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte + // « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à // abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service). // l'agent local (/api/link/connect) qui fait le `ajean link` (token + service).
const AJEAN_APP_ORIGIN = 'https://app.ajean.link'; const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
+3 -3
View File
@@ -22,7 +22,7 @@
garde `display-mode` en second pour les PWA Android/desktop à manifeste. --> garde `display-mode` en second pour les PWA Android/desktop à manifeste. -->
<script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script> <script>try{var pwa=(window.navigator.standalone===true)||(window.matchMedia&&window.matchMedia('(display-mode:standalone)').matches)||(window.matchMedia&&window.matchMedia('(display-mode:fullscreen)').matches);document.documentElement.setAttribute('data-pwa',pwa?'1':'0');}catch(e){document.documentElement.setAttribute('data-pwa','0');}</script>
<!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. --> <!-- Applique le thème AVANT le rendu (pas de flash) : lit le choix persisté. -->
<script>try{var t=localStorage.getItem('jean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('jean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script> <script>try{var t=localStorage.getItem('ajean-theme')||'light';document.documentElement.setAttribute('data-theme',(t==='dark'||t.endsWith('-dark'))?'dark':'light');['hide-reasoning','hide-tools','fold-tools','hide-side','hide-stats'].forEach(function(k){document.documentElement.setAttribute('data-'+k,localStorage.getItem('ajean-'+k)==='1'?'1':'0')})}catch(e){document.documentElement.setAttribute('data-theme','light');}</script>
<style> <style>
@@CSS@@ @@CSS@@
</style></head><body> </style></head><body>
@@ -180,7 +180,7 @@
trois îlots pour un même réglage. --> trois îlots pour un même réglage. -->
<div class="slist"> <div class="slist">
<div class="rowbody"> <div class="rowbody">
<div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>jean</code></div> <div class="muted" style="margin:0 0 6px">Adresse locale (réseau) — modèle <code>ajean</code></div>
<div class="row" style="margin:0"> <div class="row" style="margin:0">
<input id="oai-url" readonly class="io-input"> <input id="oai-url" readonly class="io-input">
<button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button> <button class="iconbtn" onclick="copyText(document.getElementById('oai-url').value,'adresse copiée')" title="copier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2.5"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>
@@ -210,7 +210,7 @@
</div></div> </div></div>
</details> </details>
<details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary> <details id="remote-details"><summary><span class="stitle">Accès distant</span><span id="remote-badge" class="sbadge"></span></summary>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div> <div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur AJEAN depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). --> <!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
<div id="remote-off" class="slist"><div class="rowbody"> <div id="remote-off" class="slist"><div class="rowbody">
<p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p> <p class="muted" style="margin:0 0 8px">Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.</p>
+2 -2
View File
@@ -56,13 +56,13 @@ function toggleSide(){ document.getElementById('side').classList.toggle('open');
let _sysT = null; let _sysT = null;
function saveSys(){ function saveSys(){
const v = document.getElementById('sysprompt').value; const v = document.getElementById('sysprompt').value;
localStorage.setItem('jean.sys', v); localStorage.setItem('ajean.sys', v);
clearTimeout(_sysT); clearTimeout(_sysT);
_sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600); _sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600);
} }
async function loadSys(){ async function loadSys(){
try{ try{
const d = await jget('/api/sysprompt'); const d = await jget('/api/sysprompt');
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('jean.sys', d.text || ''); } if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('ajean.sys', d.text || ''); }
}catch(e){} }catch(e){}
} }
+4 -4
View File
@@ -9,7 +9,7 @@ function applyTheme(id){
// désormais la variante, déduite du suffixe. // désormais la variante, déduite du suffixe.
const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark')); const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark'));
document.documentElement.setAttribute('data-theme', dark?'dark':'light'); document.documentElement.setAttribute('data-theme', dark?'dark':'light');
try{ localStorage.setItem('jean-theme', dark?'dark':'light'); }catch(e){} try{ localStorage.setItem('ajean-theme', dark?'dark':'light'); }catch(e){}
const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark; const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark;
} }
function toggleThemeDark(){ function toggleThemeDark(){
@@ -17,7 +17,7 @@ function toggleThemeDark(){
savePrefs(); savePrefs();
} }
function initTheme(){ function initTheme(){
let id='light'; try{ id=localStorage.getItem('jean-theme')||'light'; }catch(e){} let id='light'; try{ id=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
applyTheme(id); applyTheme(id);
} }
// ===== Affichage ============================================================ // ===== Affichage ============================================================
@@ -34,7 +34,7 @@ const VIEW_OPTS=[
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; } function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
function applyView(id, on){ function applyView(id, on){
document.documentElement.setAttribute('data-'+id, on?'1':'0'); document.documentElement.setAttribute('data-'+id, on?'1':'0');
try{ localStorage.setItem('jean-'+id, on?'1':'0'); }catch(e){} try{ localStorage.setItem('ajean-'+id, on?'1':'0'); }catch(e){}
const box=document.getElementById('view-'+id); if(box) box.checked=!!on; const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
// La barre latérale peut rester ouverte quand on repasse en mode fixe. // La barre latérale peut rester ouverte quand on repasse en mode fixe.
if(id==='hide-side' && !on){ if(id==='hide-side' && !on){
@@ -46,7 +46,7 @@ function applyView(id, on){
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); } function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
function initView(){ function initView(){
VIEW_OPTS.forEach(o=>{ VIEW_OPTS.forEach(o=>{
let v=null; try{ v=localStorage.getItem('jean-'+o.id); }catch(e){} let v=null; try{ v=localStorage.getItem('ajean-'+o.id); }catch(e){}
applyView(o.id, v==='1'); applyView(o.id, v==='1');
}); });
} }
+3 -3
View File
@@ -1,11 +1,11 @@
// ===== Persistance côté serveur (partagée entre appareils) ================== // ===== Persistance côté serveur (partagée entre appareils) ==================
// L'apparence est aussi enregistrée sur la machine jean (/api/prefs) : ainsi le // L'apparence est aussi enregistrée sur la machine ajean (/api/prefs) : ainsi le
// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le // thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le
// localStorage reste utilisé pour appliquer instantanément au chargement (sans // localStorage reste utilisé pour appliquer instantanément au chargement (sans
// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité). // flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité).
function savePrefs(){ function savePrefs(){
let theme='light'; let theme='light';
try{ theme=localStorage.getItem('jean-theme')||'light'; }catch(e){} try{ theme=localStorage.getItem('ajean-theme')||'light'; }catch(e){}
const p={theme}; const p={theme};
VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; }); VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; });
jpost('/api/prefs', p).catch(()=>{}); jpost('/api/prefs', p).catch(()=>{});
@@ -27,4 +27,4 @@ async function loadPrefs(){
} }
}catch(e){} }catch(e){}
} }
document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('jean.sys') || ''; loadSys(); restoreChat(); }); document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('ajean.sys') || ''; loadSys(); restoreChat(); });
+1 -1
View File
@@ -49,7 +49,7 @@ function _askKey(e){
else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); } else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); }
} }
function _openAsk(kind, message, opts){ function _openAsk(kind, message, opts){
// Compat 2 conventions : (message, opts) [UI jean] ET l'objet unique // Compat 2 conventions : (message, opts) [UI ajean] ET l'objet unique
// {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html, // {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html,
// où cette fonction remplace le window.askConfirm du bootstrap boîte noire). // où cette fonction remplace le window.askConfirm du bootstrap boîte noire).
if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; }
+4 -4
View File
@@ -1,13 +1,13 @@
// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*. // Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*.
// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage. // Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage.
let TOKEN = localStorage.getItem('jean.key') || ''; let TOKEN = localStorage.getItem('ajean.key') || '';
function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; } function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; }
// Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link. // Base d'URL : "" en local (page à "/"), "/u/<id>" servie via le relais ajean.link.
// Garde les chemins absolus (/api/…) corrects derrière le tunnel. // Garde les chemins absolus (/api/…) corrects derrière le tunnel.
const API_BASE = location.pathname.replace(/\/(index\.html)?$/, ''); const API_BASE = location.pathname.replace(/\/(index\.html)?$/, '');
// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le // Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le
// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint // navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint
// dès qu'on laisse traîner plusieurs onglets Jean) reste suspendue POUR TOUJOURS : // dès qu'on laisse traîner plusieurs onglets AJEAN) reste suspendue POUR TOUJOURS :
// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher. // ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher.
// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal // Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal
// et ne sont donc jamais concernés. // et ne sont donc jamais concernés.
@@ -30,8 +30,8 @@ async function jfetch(u, opts){
} }
finally{ if(timer) clearTimeout(timer); } finally{ if(timer) clearTimeout(timer); }
if(r.status === 401){ if(r.status === 401){
const k = await askPrompt('Clé de pilotage jean requise :', {title:'Authentification', placeholder:'clé…'}); const k = await askPrompt('Clé de pilotage ajean requise :', {title:'Authentification', placeholder:'clé…'});
if(k){ TOKEN = k.trim(); localStorage.setItem('jean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } if(k){ TOKEN = k.trim(); localStorage.setItem('ajean.key', TOKEN); opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
} }
return r; return r;
} }
+3 -3
View File
@@ -9,7 +9,7 @@ const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list
function reserveHeights(){ function reserveHeights(){
HRESERVE.forEach(id=>{ HRESERVE.forEach(id=>{
try{ try{
const h=parseInt(localStorage.getItem('jean-h-'+id)||'0',10); const h=parseInt(localStorage.getItem('ajean-h-'+id)||'0',10);
const el=document.getElementById(id); const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px'; if(el && h>0) el.style.minHeight=h+'px';
}catch(e){} }catch(e){}
@@ -19,7 +19,7 @@ function releaseHeights(){
HRESERVE.forEach(id=>{ HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return; const el=document.getElementById(id); if(!el) return;
el.style.minHeight=''; el.style.minHeight='';
try{ localStorage.setItem('jean-h-'+id, String(el.offsetHeight||0)); }catch(e){} try{ localStorage.setItem('ajean-h-'+id, String(el.offsetHeight||0)); }catch(e){}
}); });
} }
document.addEventListener('DOMContentLoaded', reserveHeights); document.addEventListener('DOMContentLoaded', reserveHeights);
@@ -98,7 +98,7 @@ async function loadPresets(){
}); });
} }
// « Mode agent » = accès machine + skills réunis en un seul interrupteur. // « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « jean » → « ajean ». // Quand il est actif, un « a » blanc apparaît en fondu devant « ajean » → « ajean ».
async function loadAgent(){ async function loadAgent(){
const s=await jget('/api/agent'); const s=await jget('/api/agent');
const on = s.enabled; const on = s.enabled;
+8 -8
View File
@@ -194,7 +194,7 @@ async function populateModelPicker(){
const list = await jget('/api/models'); const list = await jget('/api/models');
const cur = currentModelInTextarea(); const cur = currentModelInTextarea();
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre). // regroupe par dossier, AJEAN_HOME d'abord (l'API les renvoie dans cet ordre).
const groups = []; const groups = [];
for(const m of (list||[])){ for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir); let g = groups.find(x => x.dir === m.dir);
@@ -213,7 +213,7 @@ async function populateModelPicker(){
opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>'; opts += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
} }
html += groups.length > 1 html += groups.length > 1
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : g.dir)+'">'+opts+'</optgroup>' ? '<optgroup label="'+escHtml(g.home ? 'dossier ajean' : g.dir)+'">'+opts+'</optgroup>'
: opts; : opts;
} }
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
@@ -224,7 +224,7 @@ async function populateModelPicker(){
sel.innerHTML = html; sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true); if(cur && !matched) setModelDirsOpen(true);
} }
// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) --- // ---- Dossiers de modèles : AJEAN_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. // Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){ function setModelDirsOpen(open){
const b = document.getElementById('m-dirs-body'); const b = document.getElementById('m-dirs-body');
@@ -253,7 +253,7 @@ async function populateModelDirs(){
info.className = 'muted'; info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : ''; const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')'; info.textContent = '(' + n + free + (x.home ? ', dossier ajean' : '') + ')';
row.append(p, info); row.append(p, info);
if(!x.home){ if(!x.home){
const del = document.createElement('button'); const del = document.createElement('button');
@@ -324,13 +324,13 @@ function underDir(p, dir){
} }
let beFastPath = '', beOptPath = '', beFastDir = ''; let beFastPath = '', beOptPath = '', beFastDir = '';
async function populateBackend(){ async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean). // Chemins des deux moteurs gérés + liste des backends détectés (dossier ajean).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){} let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || ''; beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || ''; beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || ''; beOptPath = lc.bin || '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans // Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de jean (l'utilisateur peut y déposer son propre build). // le dossier backends de ajean (l'utilisateur peut y déposer son propre build).
const detected = await jget('/api/backends'); const detected = await jget('/api/backends');
const sel = document.getElementById('m-backend-detected'); const sel = document.getElementById('m-backend-detected');
let html = '<option value="">— ou choisir un backend détecté —</option>'; let html = '<option value="">— ou choisir un backend détecté —</option>';
@@ -664,7 +664,7 @@ function populateSettings(){
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ---------------- // --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée // Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version // par des virgules ; une valeur composée (ou un type sorti après cette version
// de jean) ne correspondrait à aucune option et serait silencieusement effacée // de ajean) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de // au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre. // la perdre.
function setSpecType(v){ function setSpecType(v){
@@ -860,7 +860,7 @@ function watchDownload(fname){
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop(); stop();
await Promise.all([populateModelPicker(), populateDlDirs()]); await Promise.all([populateModelPicker(), populateDlDirs()]);
// Le modèle a pu atterrir hors du dossier jean : l'option porte alors le // Le modèle a pu atterrir hors du dossier ajean : l'option porte alors le
// chemin complet, pas le simple nom de fichier. // chemin complet, pas le simple nom de fichier.
const sel = document.getElementById('m-model'); const sel = document.getElementById('m-model');
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname)); const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
+2 -2
View File
@@ -115,7 +115,7 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt); lab.appendChild(cnt);
} }
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/JEAN // Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/AJEAN
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait // sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée // avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
// par la préférence d'affichage « masquer la vitesse de génération » (CSS). // par la préférence d'affichage « masquer la vitesse de génération » (CSS).
@@ -276,7 +276,7 @@ async function compactContext(){
} }
// Persistance de la conversation : on garde user+assistant en localStorage pour // Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées). // survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} } function saveChat(){ try{ localStorage.setItem('ajean.chat', JSON.stringify(msgs)); }catch(e){} }
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement // Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte, // permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de // appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
+2 -2
View File
@@ -1,5 +1,5 @@
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) ===== // ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur jean. Le client ouvre un // L'historique et la génération vivent sur le serveur ajean. Le client ouvre un
// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis // flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris. // serveur) ; se reconnecter rejoue tout le fil, détails compris.
@@ -197,7 +197,7 @@ function handleDelta(d){
} }
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de // Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil). // re-téléchargement complet après une coupure / bascule d'appareil).
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Jean laissé // Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet AJEAN laissé
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine : // ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
// au-delà, toute requête (journal, installation du moteur…) reste en file // au-delà, toute requête (journal, installation du moteur…) reste en file
// d'attente sans jamais partir ni échouer — un blocage silencieux très // d'attente sans jamais partir ni échouer — un blocage silencieux très
+1 -1
View File
@@ -1,7 +1,7 @@
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal. // Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte + // « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à // abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service). // l'agent local (/api/link/connect) qui fait le `ajean link` (token + service).
const AJEAN_APP_ORIGIN = 'https://app.ajean.link'; const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
+3 -3
View File
@@ -146,7 +146,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
.ask-ok.danger{border-color:var(--err);color:var(--err)} .ask-ok.danger{border-color:var(--err);color:var(--err)}
#brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px} #brand-a{display:inline-flex;line-height:0;color:var(--text);margin-right:2px}
/* Mode agent : le A passe en couleur d'accent (il était masqué hors agent, /* Mode agent : le A passe en couleur d'accent (il était masqué hors agent,
la marque se lisant alors « JEAN »). L'état agent reste signalé par sa la marque se lisant alors « AJEAN »). L'état agent reste signalé par sa
pastille dans les réglages. */ pastille dans les réglages. */
#brand.agent #brand-a{color:var(--accent)} #brand.agent #brand-a{color:var(--accent)}
/* interrupteur (switch) */ /* interrupteur (switch) */
@@ -935,7 +935,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* ===== Mise en page de la conversation ============================= /* ===== Mise en page de la conversation =============================
Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond), Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond),
question de l'utilisateur en pastille arrondie à droite, composer en question de l'utilisateur en pastille arrondie à droite, composer en
« pilule », pas d'étiquettes VOUS/JEAN. Ces règles surchargent la mise en « pilule », pas d'étiquettes VOUS/AJEAN. Ces règles surchargent la mise en
page générique plus haut : elles doivent donc rester APRÈS elle. */ page générique plus haut : elles doivent donc rester APRÈS elle. */
body{font-size:15px} body{font-size:15px}
h1{color:var(--text);font-weight:600;letter-spacing:-.01em} h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
@@ -949,7 +949,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */ /* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6} #chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
#chat .msg.user .label{color:var(--dim)} #chat .msg.user .label{color:var(--dim)}
/* Étiquettes VOUS / JEAN : inutiles ici (l'alignement suffit), sauf pour les /* Étiquettes VOUS / AJEAN : inutiles ici (l'alignement suffit), sauf pour les
bulles repliables (raisonnement / outils) où le label est le bouton de repli. */ bulles repliables (raisonnement / outils) où le label est le bouton de repli. */
#chat .msg .label{display:none} #chat .msg .label{display:none}
#chat .msg.collapsible .label{display:block} #chat .msg.collapsible .label{display:block}