mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1294d42c | ||
|
|
533ff7d0d1 | ||
|
|
69e89e5c2f | ||
|
|
3d8a72754c | ||
|
|
88e1b38582 | ||
|
|
781520afd8 | ||
|
|
0a3af60715 | ||
|
|
ebe04f9d61 |
No files matched your search
@@ -172,13 +172,17 @@ Ajoutées par ce fork :
|
||||
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
|
||||
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
|
||||
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
|
||||
- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*,
|
||||
l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute
|
||||
— envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**,
|
||||
donc de fait par modèle. `none` coupe le raisonnement ; les autres valeurs sont
|
||||
passées au gabarit jinja du modèle, ce qui ne change le comportement que des
|
||||
modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans
|
||||
erreur, et le sous-titre du réglage le dit plutôt que de promettre un effet.
|
||||
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
|
||||
haute — envoyé à `llama-server` comme `reasoning_effort`. Réglable **dans la
|
||||
barre de saisie**, parce que ça se décide en écrivant le message : le
|
||||
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
|
||||
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
|
||||
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
|
||||
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
|
||||
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
|
||||
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
|
||||
que de promettre un effet. La liste est grisée quand le raisonnement est coupé
|
||||
pour ce modèle.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
@@ -233,6 +237,15 @@ Ajoutées par ce fork :
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
|
||||
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
|
||||
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
|
||||
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
|
||||
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
|
||||
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
|
||||
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
|
||||
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
|
||||
@@ -98,6 +98,44 @@ func reasoningEffortValue(v string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
|
||||
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
|
||||
//
|
||||
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
|
||||
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
|
||||
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
|
||||
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
|
||||
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
|
||||
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
|
||||
// simplement tomber le champ.
|
||||
//
|
||||
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
|
||||
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
|
||||
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
|
||||
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
|
||||
// de cette façon.
|
||||
//
|
||||
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
|
||||
// touche à rien, le gabarit garde son défaut.
|
||||
// Renvoie nil quand il n'y a rien à imposer.
|
||||
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
|
||||
if off || effort == "none" {
|
||||
return map[string]any{"enable_thinking": false}
|
||||
}
|
||||
if effort != "" {
|
||||
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
|
||||
// niveau au gabarit, celui-ci passe toujours.
|
||||
return map[string]any{"reasoning_effort": effort}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
|
||||
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
|
||||
func reasoningExplicitlyOff(v string) bool {
|
||||
return strings.TrimSpace(v) != "" && !reasoningActive(v)
|
||||
}
|
||||
|
||||
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
|
||||
// (manual override, with or without a leading '#') wins; otherwise it is
|
||||
// auto-detected from the MODEL= filename. Returns "" when unknown.
|
||||
|
||||
@@ -289,6 +289,15 @@ func (c *Conversation) state() map[string]any {
|
||||
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
}
|
||||
|
||||
// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
|
||||
// elle doit pouvoir montrer laquelle travaille dès le chargement de la page,
|
||||
// sans attendre que le rejeu du flux SSE ait rattrapé son retard.
|
||||
func (c *Conversation) isGenerating() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Generating
|
||||
}
|
||||
|
||||
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
|
||||
var ErrBusy = fmt.Errorf("génération en cours")
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ func screenshotVisionNote() string {
|
||||
// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le
|
||||
// base64 de l'image en TEXTE — la requête vue en production pesait 55 000
|
||||
// tokens pour 32 768 de contexte, et tous les tours suivants échouaient.
|
||||
//
|
||||
// La sonde est un appel interne comme les autres : elle DOIT s'authentifier.
|
||||
// Sans l'en-tête Bearer, un moteur protégé par API_KEY répond 401 et la sonde
|
||||
// conclut « pas de vision » — le projecteur était bien chargé, les images ne
|
||||
// partaient jamais et le modèle brodait à partir du seul chemin de fichier.
|
||||
var (
|
||||
visionProbeMu sync.Mutex
|
||||
visionProbeAt time.Time
|
||||
@@ -120,7 +125,12 @@ func engineSeesImages() bool {
|
||||
visionProbeAt = time.Now()
|
||||
visionProbeSeen = false
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort()))
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/props", LLMPort()), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
authHeader(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package loki
|
||||
|
||||
// llm_budget.go — budget SOUPLE d'appels d'outils par tour.
|
||||
//
|
||||
// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils
|
||||
// coupaient des tours parfaitement légitimes, une recherche enchaînant sans
|
||||
// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le
|
||||
// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en
|
||||
// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les
|
||||
// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite.
|
||||
//
|
||||
// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce
|
||||
// cas : sa clé est « nom + arguments bruts », et relire le même fichier par
|
||||
// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés
|
||||
// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux
|
||||
// tranches d'un même fichier renvoient un contenu différent, les confondre
|
||||
// casserait toute lecture paginée légitime.
|
||||
//
|
||||
// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle
|
||||
// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste
|
||||
// entièrement sous son contrôle — c'est de la pression, pas une barrière.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse
|
||||
// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour
|
||||
// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans
|
||||
// jamais voir le rappel.
|
||||
const agentBudgetDefault = 24
|
||||
|
||||
// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur
|
||||
// off/false/no/non — désactive complètement le mécanisme.
|
||||
func agentBudget() int {
|
||||
v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"]))
|
||||
switch v {
|
||||
case "":
|
||||
return agentBudgetDefault
|
||||
case "off", "false", "no", "non", "disable", "disabled":
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return agentBudgetDefault
|
||||
}
|
||||
|
||||
// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un
|
||||
// palier, ou "" s'il n'y a rien à dire.
|
||||
//
|
||||
// calls : appels d'outils exécutés depuis le début du tour
|
||||
// budget : taille d'un palier (0 = mécanisme désactivé)
|
||||
// sent : rappels déjà envoyés dans ce tour
|
||||
//
|
||||
// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc
|
||||
// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en
|
||||
// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est
|
||||
// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté
|
||||
// llama-server.
|
||||
//
|
||||
// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue
|
||||
// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que
|
||||
// soit celle de la conversation.
|
||||
func budgetNudge(calls, budget, sent int) string {
|
||||
if budget <= 0 || calls < budget*(sent+1) {
|
||||
return ""
|
||||
}
|
||||
n := fmt.Sprintf("%d tool calls", calls)
|
||||
switch sent {
|
||||
case 0:
|
||||
return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " +
|
||||
"Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing."
|
||||
case 1:
|
||||
return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " +
|
||||
"Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further."
|
||||
default:
|
||||
return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool."
|
||||
}
|
||||
}
|
||||
|
||||
// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans
|
||||
// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en
|
||||
// vrille est invisible côté serveur : on ne voit qu'une génération qui dure.
|
||||
func logBudget(calls, budget, sent int) {
|
||||
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
|
||||
calls, budget, sent)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le budget ne se déclenche pas avant son palier, et il revient à chaque
|
||||
// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un
|
||||
// second, plus ferme.
|
||||
func TestBudgetNudgePaliers(t *testing.T) {
|
||||
const budget = 24
|
||||
cases := []struct {
|
||||
calls, sent int
|
||||
want bool
|
||||
}{
|
||||
{0, 0, false},
|
||||
{1, 0, false},
|
||||
{23, 0, false},
|
||||
{24, 0, true}, // 1er palier
|
||||
{40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint
|
||||
{48, 1, true}, // 2e palier
|
||||
{72, 2, true}, // 3e palier
|
||||
{95, 3, false},
|
||||
{96, 3, true}, // 4e palier : la pression ne s'éteint pas
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := budgetNudge(c.calls, budget, c.sent) != ""
|
||||
if got != c.want {
|
||||
t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du
|
||||
// nombre d'appels (c'est LUI qui informe le modèle, pas le ton).
|
||||
func TestBudgetNudgeEscalade(t *testing.T) {
|
||||
const budget = 10
|
||||
seen := map[string]bool{}
|
||||
for sent := 0; sent < 3; sent++ {
|
||||
m := budgetNudge(budget*(sent+1), budget, sent)
|
||||
if m == "" {
|
||||
t.Fatalf("rappel n°%d vide", sent+1)
|
||||
}
|
||||
if seen[m] {
|
||||
t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m)
|
||||
}
|
||||
seen[m] = true
|
||||
}
|
||||
// Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un
|
||||
// nouveau à chaque palier (à nombre d'appels égal, le message est le même).
|
||||
if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b {
|
||||
t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre
|
||||
// d'appels — le mécanisme doit pouvoir être totalement coupé.
|
||||
func TestBudgetNudgeDesactive(t *testing.T) {
|
||||
for _, calls := range []int{0, 24, 500, 10000} {
|
||||
if m := budgetNudge(calls, 0, 0); m != "" {
|
||||
t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m)
|
||||
}
|
||||
}
|
||||
if m := budgetNudge(100, -5, 0); m != "" {
|
||||
t.Errorf("budget négatif devrait être inerte, obtenu %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentBudgetConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
set := func(v string) {
|
||||
cfg := ReadConfig()
|
||||
cfg["AGENT_BUDGET"] = v
|
||||
if err := WriteConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
val string
|
||||
want int
|
||||
}{
|
||||
{"", agentBudgetDefault},
|
||||
{"40", 40},
|
||||
{"0", 0},
|
||||
{"off", 0},
|
||||
{"NON", 0},
|
||||
{"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas
|
||||
{"-3", agentBudgetDefault},
|
||||
} {
|
||||
set(c.val)
|
||||
if got := agentBudget(); got != c.want {
|
||||
t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
|
||||
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
|
||||
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
|
||||
// modèle réfléchissait pendant que l'interface annonçait le contraire.
|
||||
reasoningKwargs := reasoningTemplateKwargs(reasoningExplicitlyOff(chatCfg["REASONING"]), reasoningEffort)
|
||||
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
|
||||
// retry the same turn once with tools removed so the model answers in plain
|
||||
// text from the tool results already gathered, instead of dying mid-chat.
|
||||
@@ -599,11 +604,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
|
||||
// explicite au lieu d'afficher « pas de réponse ».
|
||||
nudged := false
|
||||
// Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
|
||||
// parfaitement légitimes (une recherche enchaîne facilement des dizaines
|
||||
// d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
|
||||
// le contexte — c'est un choix assumé.
|
||||
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
|
||||
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
|
||||
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
|
||||
// demande de conclure — de la pression, pas une barrière. Sans ça, un modèle
|
||||
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
|
||||
toolRuns, budgetNudges, budget := 0, 0, agentBudget()
|
||||
for iter := 0; ; iter++ {
|
||||
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
|
||||
// llama-server reste valide, seul le nouveau message est à traiter.
|
||||
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
|
||||
budgetNudges++
|
||||
logBudget(toolRuns, budget, budgetNudges)
|
||||
messages = append(messages, Message{Role: "user", Content: msg})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"messages": messages,
|
||||
@@ -618,6 +632,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if reasoningKwargs != nil {
|
||||
payload["chat_template_kwargs"] = reasoningKwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
@@ -946,6 +963,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if ctx.Err() != nil {
|
||||
return extra, nil
|
||||
}
|
||||
toolRuns++ // alimente le budget souple (voir budgetNudge)
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
// Derive the human label (command / skill name) up front so we can
|
||||
|
||||
@@ -79,3 +79,40 @@ func TestReasoningEffortValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningExplicitlyOff(t *testing.T) {
|
||||
// Clé absente ou vide = pas de consigne, surtout pas une interdiction.
|
||||
for _, v := range []string{"", " ", "on", "auto", "deepseek"} {
|
||||
if reasoningExplicitlyOff(v) {
|
||||
t.Errorf("reasoningExplicitlyOff(%q) = true, attendu false", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"off", "none", "0", "false", " Disabled "} {
|
||||
if !reasoningExplicitlyOff(v) {
|
||||
t.Errorf("reasoningExplicitlyOff(%q) = false, attendu true", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningTemplateKwargs(t *testing.T) {
|
||||
// « aucune » doit parler aux gabarits hybrides, pas seulement à ceux qui
|
||||
// lisent reasoning_effort : sinon le modèle réfléchit quand même.
|
||||
if got := reasoningTemplateKwargs(false, "none"); got == nil || got["enable_thinking"] != false {
|
||||
t.Errorf("effort none : %v, attendu enable_thinking=false", got)
|
||||
}
|
||||
if got := reasoningTemplateKwargs(true, ""); got == nil || got["enable_thinking"] != false {
|
||||
t.Errorf("REASONING=off : %v, attendu enable_thinking=false", got)
|
||||
}
|
||||
// Un niveau explicite voyage aussi dans les kwargs : les binaires anciens ne
|
||||
// relaient pas le champ de haut niveau au gabarit.
|
||||
if got := reasoningTemplateKwargs(false, "high"); got == nil || got["reasoning_effort"] != "high" {
|
||||
t.Errorf("effort high : %v, attendu reasoning_effort=high", got)
|
||||
}
|
||||
if _, ok := reasoningTemplateKwargs(false, "high")["enable_thinking"]; ok {
|
||||
t.Error("un niveau explicite ne doit pas couper le raisonnement")
|
||||
}
|
||||
// Auto = aucune consigne : on n'écrit rien dans la requête.
|
||||
if got := reasoningTemplateKwargs(false, ""); got != nil {
|
||||
t.Errorf("auto : %v, attendu nil", got)
|
||||
}
|
||||
}
|
||||
+551
-130
File diff suppressed because it is too large.
Load diff
@@ -62,8 +62,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div id="conv-list" class="slist"></div>
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
|
||||
OU par son propre chevron — c'est une section comme les autres, sinon on ne
|
||||
trouvait pas comment la refermer.
|
||||
@@ -411,6 +411,15 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<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>
|
||||
</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">
|
||||
@@ -458,7 +467,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</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:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
<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>
|
||||
@@ -842,7 +851,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-reasoning-effort-row">
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">défaut de ce preset — ajustable à la volée depuis la barre de saisie</span></span>
|
||||
<span class="pe-row-c">
|
||||
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
|
||||
<option value="">auto</option>
|
||||
|
||||
@@ -26,6 +26,9 @@ const ICONS = {
|
||||
plus: '<path d="M12 5.5v13"/><path d="M5.5 12h13"/>',
|
||||
chevron: '<path d="M9.5 5.5 16 12l-6.5 6.5"/>',
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
|
||||
// Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
|
||||
// le dessin reste au même trait que le reste du jeu.
|
||||
spinner: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2"/><path d="M12 19.3v2.2"/><path d="M4.2 4.2l1.6 1.6"/><path d="M18.2 18.2l1.6 1.6"/><path d="M2.5 12h2.2"/><path d="M19.3 12h2.2"/><path d="M4.2 19.8l1.6-1.6"/><path d="M18.2 5.8l1.6-1.6"/>',
|
||||
};
|
||||
|
||||
|
||||
@@ -451,7 +451,7 @@ async function loadAll(){
|
||||
// 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
|
||||
// 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()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort()]);
|
||||
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); }
|
||||
@@ -755,9 +755,12 @@ function populateSettings(){
|
||||
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
|
||||
const effSub = document.getElementById('s-reasoning-effort-sub');
|
||||
if(effSub){
|
||||
// Deux emplacements, deux portées : ici c'est le défaut ENREGISTRÉ avec le
|
||||
// preset (donc rattaché au modèle) ; la liste de la barre de saisie le
|
||||
// dévie pour la suite de la session, jusqu'au prochain preset appliqué.
|
||||
effSub.textContent = eff.value
|
||||
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
|
||||
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
|
||||
? 'défaut de ce preset — sans effet si le gabarit du modèle l\'ignore'
|
||||
: 'défaut de ce preset — ajustable à la volée depuis la barre de saisie';
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
|
||||
@@ -153,13 +153,21 @@ function setLabelCounts(el, add, del){
|
||||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||||
lab.appendChild(cnt);
|
||||
}
|
||||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
|
||||
// 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
|
||||
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
|
||||
function setStats(el, text){
|
||||
// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki 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.
|
||||
//
|
||||
// Deux informations de nature différente y cohabitent, d'où deux éléments :
|
||||
// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
|
||||
// réponse, outils et raisonnement compris) ;
|
||||
// .spd — les mesures de vitesse (prefill / decode).
|
||||
// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
|
||||
// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
|
||||
// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
|
||||
function setStats(el, work, speed){
|
||||
if(!el) return;
|
||||
let s = el.querySelector(':scope > .statline');
|
||||
if(!work && !speed){ if(s) s.textContent=''; return; }
|
||||
if(!s){
|
||||
s=document.createElement('div'); s.className='statline';
|
||||
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
|
||||
@@ -169,7 +177,18 @@ function setStats(el, text){
|
||||
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
|
||||
el.appendChild(s);
|
||||
}
|
||||
s.textContent = text;
|
||||
// Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
|
||||
// est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
|
||||
// mise en page des dizaines de fois par seconde pour rien.
|
||||
let w = s.querySelector(':scope > .worktime');
|
||||
let p = s.querySelector(':scope > .spd');
|
||||
if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
|
||||
if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
|
||||
w.textContent = work || '';
|
||||
// Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
|
||||
// un « · » orphelin en bout de durée.
|
||||
p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
|
||||
s.classList.toggle('has-work', !!work);
|
||||
}
|
||||
function bodyOf(el){ return el.querySelector('.body'); }
|
||||
// Render markdown into a message body in place; safe because md() escapes HTML.
|
||||
@@ -191,6 +210,13 @@ function hydrateImages(root){
|
||||
img.dataset.hydrated = '1';
|
||||
const src = img.getAttribute('src');
|
||||
img.classList.add('chatimg');
|
||||
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
|
||||
let name = img.getAttribute('alt') || '';
|
||||
if(!name){
|
||||
const m = /[?&]path=([^&]*)/.exec(src);
|
||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||
}
|
||||
bindZoom(img, name);
|
||||
const cached = IMG_CACHE.get(src);
|
||||
if(cached){ img.src = cached; return; }
|
||||
try{
|
||||
|
||||
@@ -51,8 +51,67 @@ function confirmPending(text){
|
||||
let REPLAYING=true;
|
||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||
let T=null;
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; }
|
||||
newTurn();
|
||||
|
||||
// --- Temps de travail du tour ------------------------------------------------
|
||||
// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
|
||||
// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
|
||||
// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
|
||||
// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
|
||||
// où tout arrive d'un bloc côté client.
|
||||
//
|
||||
// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
|
||||
// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
|
||||
// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
|
||||
// entre les deux, réévalué à chaque événement reçu en direct.
|
||||
let TS_SKEW = 0; // horloge client − horloge serveur (ms)
|
||||
function nowServer(){ return Date.now() - TS_SKEW; }
|
||||
let WORK_TIMER = null;
|
||||
function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
|
||||
function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
|
||||
function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); }
|
||||
// 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
|
||||
// c'est précisément le moment où l'on se demande si ça avance.
|
||||
function paintTypingClock(){
|
||||
const el = T.typingEl;
|
||||
if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
|
||||
if(!T.startTs) return;
|
||||
const ms = nowServer() - T.startTs;
|
||||
if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
|
||||
let c = el.querySelector('.wclock');
|
||||
if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
|
||||
c.textContent = fmtDur(ms);
|
||||
}
|
||||
// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
|
||||
// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
|
||||
// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
|
||||
// y ferait deux conventions à trois mots d'écart.
|
||||
function fmtDur(ms){
|
||||
if(!(ms > 0)) return '';
|
||||
if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
|
||||
const t = Math.round(ms/1000);
|
||||
if(t < 60) return t + ' s';
|
||||
const m = Math.floor(t/60), sec = t % 60;
|
||||
if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
|
||||
return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
|
||||
}
|
||||
// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
|
||||
// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
|
||||
function workLabel(){
|
||||
if(!T.startTs) return '';
|
||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||
if(!(ms > 300)) return '';
|
||||
return 'travail ' + fmtDur(ms);
|
||||
}
|
||||
// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
|
||||
// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
|
||||
// cas du tic de seconde, qui ne fait avancer que la durée).
|
||||
function paintStats(el, speed){
|
||||
if(speed !== undefined) T.speedText = speed;
|
||||
setStats(el, workLabel(), T.speedText);
|
||||
}
|
||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
||||
@@ -111,9 +170,23 @@ function renderStats(el, s){
|
||||
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
|
||||
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
|
||||
// bouton de repli, on y écrit comme avant.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · '));
|
||||
else setStats(el, parts.join(' · '));
|
||||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
|
||||
else paintStats(el, parts.join(' · '));
|
||||
}
|
||||
// --- Compteurs de bulle -----------------------------------------------------
|
||||
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
|
||||
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
|
||||
// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le
|
||||
// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier
|
||||
// token — exécution des outils, pages web et prefill compris.
|
||||
//
|
||||
// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait
|
||||
// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur
|
||||
// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite
|
||||
// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert,
|
||||
// aujourd'hui comme demain.
|
||||
function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; }
|
||||
function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; }
|
||||
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
|
||||
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
|
||||
// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
|
||||
@@ -122,18 +195,25 @@ function labelTokens(el, role, n, firstTs, lastTs){
|
||||
if(!el) return;
|
||||
const secs=(lastTs-firstTs)/1000;
|
||||
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
|
||||
// Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
|
||||
// pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
|
||||
const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
|
||||
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
|
||||
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
|
||||
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
|
||||
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
|
||||
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
|
||||
else setStats(el, m);
|
||||
if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · '));
|
||||
else paintStats(el, m);
|
||||
}
|
||||
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
|
||||
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
|
||||
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
|
||||
// La liste des discussions montre laquelle travaille : elle doit suivre l'état
|
||||
// MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
|
||||
// l'anneau tourner sans attendre le premier événement en direct).
|
||||
if(typeof convSyncBusy==='function') convSyncBusy(); }
|
||||
function syncSendBtn(){
|
||||
const sb=document.getElementById('send');
|
||||
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
|
||||
@@ -160,11 +240,19 @@ function syncSendBtn(){
|
||||
// piloté par le serveur et rejouable à l'identique.
|
||||
function handleDelta(d){
|
||||
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
|
||||
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
|
||||
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
|
||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
||||
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||
if(d.caught_up){
|
||||
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
|
||||
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||
setChatLoading(null);
|
||||
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
|
||||
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
|
||||
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
|
||||
if(busy && T.startTs && !T.doneTs) startWorkTimer();
|
||||
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
|
||||
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
||||
syncChatEmpty();
|
||||
@@ -173,7 +261,7 @@ function handleDelta(d){
|
||||
// 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
|
||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
|
||||
if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
|
||||
if(d.user!==undefined){
|
||||
newTurn();
|
||||
let el=PENDING;
|
||||
@@ -182,11 +270,20 @@ function handleDelta(d){
|
||||
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
|
||||
// bulle neuve — sinon elles apparaîtraient en double.
|
||||
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
||||
setBusy(true); T.typingEl=addTyping(); return; }
|
||||
// Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
|
||||
// locale — c'est ce qui rend la durée juste au rejeu comme en direct.
|
||||
T.startTs = d.ts || 0; T.doneTs = 0;
|
||||
setBusy(true); startWorkTimer(); T.typingEl=addTyping(); return; }
|
||||
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
||||
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
||||
// le temps réellement passé et non continuer d'avancer.
|
||||
T.doneTs = d.ts || T.doneTs || 0;
|
||||
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
|
||||
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
@@ -214,11 +311,11 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.reasoning_content){
|
||||
killTyping('reasoning');
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||
// au lieu de concaténer, sinon le texte apparaît en double.
|
||||
if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; }
|
||||
if(d.replace){ T.fullReason=''; resetReasonStats(); }
|
||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
|
||||
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
|
||||
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
|
||||
@@ -227,8 +324,8 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.content){
|
||||
removeTyping();
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; }
|
||||
if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; }
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); }
|
||||
if(d.replace){ T.fullContent=''; resetContentStats(); }
|
||||
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
|
||||
if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
|
||||
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// ─── Intensité du raisonnement (barre de saisie) ─────────────────────────────
|
||||
// Le même réglage existe dans l'éditeur de preset, mais il y est figé avec le
|
||||
// preset : le changer demande d'ouvrir les réglages, éditer, enregistrer. Ici il
|
||||
// s'applique au message suivant, sans redémarrer le moteur — `reasoning_effort`
|
||||
// voyage dans le corps de la requête, pas dans la ligne de commande.
|
||||
//
|
||||
// La valeur part dans la config vivante. Appliquer un preset la réécrit : le
|
||||
// réglage reste donc attaché au modèle, cette liste ne fait que le dévier à la
|
||||
// volée.
|
||||
|
||||
const THINK_EFFORT_LABELS = {
|
||||
'': 'auto',
|
||||
none: 'aucune',
|
||||
low: 'basse',
|
||||
medium: 'moyenne',
|
||||
high: 'haute',
|
||||
};
|
||||
|
||||
async function loadThinkEffort(){
|
||||
try{ applyThinkEffort(await jget('/api/reasoning-effort')); }
|
||||
catch(e){ /* endpoint indisponible : la liste garde sa valeur par défaut */ }
|
||||
}
|
||||
|
||||
function applyThinkEffort(r){
|
||||
const sel = document.getElementById('think-effort');
|
||||
if(!sel || !r || !r.ok) return;
|
||||
sel.value = r.value || '';
|
||||
// Raisonnement coupé au niveau du preset : le niveau n'a plus de prise. On
|
||||
// grise plutôt que de masquer, pour que le réglage reste trouvable.
|
||||
sel.disabled = !r.on;
|
||||
sel.title = r.on
|
||||
? 'Intensité du raisonnement — envoyée au gabarit du modèle'
|
||||
: 'Raisonnement désactivé pour ce modèle (réglages → Raisonnement)';
|
||||
}
|
||||
|
||||
async function setThinkEffort(value){
|
||||
const r = await jpost('/api/reasoning-effort', {value});
|
||||
if(!r || !r.ok){ toast((r && r.error) || 'réglage refusé'); return; }
|
||||
applyThinkEffort(r);
|
||||
toast('raisonnement : '+(THINK_EFFORT_LABELS[r.value||''] || r.value));
|
||||
}
|
||||
@@ -26,12 +26,28 @@ function fmtSize(n){
|
||||
if(n >= 1024) return Math.round(n/1024)+' Ko';
|
||||
return n+' o';
|
||||
}
|
||||
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
|
||||
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
|
||||
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
|
||||
// une vignette cassée qui retombe sur son texte de repli.
|
||||
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
|
||||
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
|
||||
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
|
||||
// objet visuel des deux côtés, seul le contexte (CSS) change.
|
||||
//
|
||||
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
|
||||
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
|
||||
// vers le serveur à ce stade).
|
||||
function fileChip(name, size, opts){
|
||||
opts=opts||{};
|
||||
const chip=document.createElement('div');
|
||||
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
|
||||
if(opts.thumb){
|
||||
const t=document.createElement('img');
|
||||
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
|
||||
chip.appendChild(t);
|
||||
chip.classList.add('has-thumb');
|
||||
}
|
||||
const n=document.createElement('span');
|
||||
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
|
||||
const s=document.createElement('span');
|
||||
@@ -49,12 +65,31 @@ function fileChip(name, size, opts){
|
||||
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
|
||||
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
|
||||
// un bloc bâtard, moitié texte moitié pastilles.
|
||||
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
|
||||
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
|
||||
// balise <img> ne peut pas porter la clé de pilotage.
|
||||
function msgImgTile(f){
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-img';
|
||||
const img=document.createElement('img');
|
||||
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
|
||||
img.alt=f.name||''; img.title=f.name||'';
|
||||
box.appendChild(img);
|
||||
return box;
|
||||
}
|
||||
function addMsgFiles(el, files){
|
||||
if(!el || !files || !files.length) return;
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-files';
|
||||
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
for(const f of files){
|
||||
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
|
||||
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
|
||||
// apprendre — le nom vit dans l'infobulle et dans la loupe.
|
||||
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
|
||||
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
}
|
||||
el.parentNode.insertBefore(box, el);
|
||||
hydrateImages(box);
|
||||
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
|
||||
markEmptyMsg(el);
|
||||
return box;
|
||||
@@ -202,11 +237,18 @@ function renderAttach(){
|
||||
sizeText: a.state==='err' ? 'échec'
|
||||
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
|
||||
: fmtSize(a.size),
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
|
||||
thumb: a.thumb,
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
|
||||
}));
|
||||
}
|
||||
}
|
||||
function clearAttach(){ ATTACH=[]; renderAttach(); }
|
||||
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
|
||||
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
|
||||
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
|
||||
function dropThumb(a){
|
||||
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
|
||||
}
|
||||
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
|
||||
// Ce qui part avec le message, pour l'afficher dans sa bulle.
|
||||
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
|
||||
|
||||
@@ -277,7 +319,11 @@ function addFiles(files){
|
||||
for(const f of files||[]){
|
||||
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
|
||||
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
|
||||
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
|
||||
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
|
||||
// source d'image disponible à ce stade.
|
||||
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
|
||||
}
|
||||
renderAttach();
|
||||
}
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
|
||||
// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
|
||||
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
|
||||
// route, ou ouverte depuis un autre appareil).
|
||||
let CONV_BUSY = false;
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||
CONVS = r.conversations || [];
|
||||
CONV_ACTIVE = r.active || '';
|
||||
CONV_BUSY = !!r.busy;
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
@@ -37,11 +43,16 @@ function renderConversations(){
|
||||
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
|
||||
cont.appendChild(e);
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
|
||||
// qui travaille sans redessiner la liste (un redessin relancerait
|
||||
// l'animation de l'anneau à chaque événement du flux).
|
||||
row.dataset.conv = c.id;
|
||||
row.onclick = () => convSwitch(c.id);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'preset-info';
|
||||
@@ -74,6 +85,57 @@ function renderConversations(){
|
||||
cont.appendChild(row);
|
||||
}
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
}
|
||||
|
||||
// --- Discussion au travail : anneau qui tourne ------------------------------
|
||||
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
|
||||
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
|
||||
// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
|
||||
// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
|
||||
// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
|
||||
// n'est rechargée qu'en fin de tour.
|
||||
function convBusyNow(){
|
||||
const live = (typeof busy !== 'undefined') && busy;
|
||||
if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
|
||||
return CONV_BUSY || !!live;
|
||||
}
|
||||
|
||||
// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
|
||||
// contenu : réécrire l'hôte remettrait l'animation à zéro.
|
||||
function convSpinToggle(host, on, before){
|
||||
if(!host) return;
|
||||
let sp = host.querySelector(':scope > .conv-spin');
|
||||
if(on && !sp){
|
||||
sp = document.createElement('span');
|
||||
sp.className = 'conv-spin';
|
||||
sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
|
||||
sp.setAttribute('role', 'img');
|
||||
sp.setAttribute('aria-label', 'génération en cours');
|
||||
sp.appendChild(icon('spinner', 14));
|
||||
if(before) host.insertBefore(sp, before); else host.appendChild(sp);
|
||||
} else if(!on && sp){
|
||||
sp.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
|
||||
// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
|
||||
// ça, rien ne dirait que ça travaille).
|
||||
function convSyncBusy(){
|
||||
const on = convBusyNow();
|
||||
const cont = document.getElementById('conv-list');
|
||||
if(cont){
|
||||
for(const row of cont.querySelectorAll('.preset')){
|
||||
const mine = on && row.dataset.conv === CONV_ACTIVE;
|
||||
row.classList.toggle('working', mine);
|
||||
convSpinToggle(row, mine, row.firstChild);
|
||||
}
|
||||
}
|
||||
// Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
|
||||
// à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
|
||||
const tb = document.getElementById('topbar-title');
|
||||
convSpinToggle(tb && tb.parentNode, on, tb);
|
||||
}
|
||||
|
||||
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ===== Loupe d'image =======================================================
|
||||
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
|
||||
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
|
||||
// la conversation illisible. Le plafond ne se paie donc pas en information : un
|
||||
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
|
||||
//
|
||||
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
|
||||
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
|
||||
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
|
||||
// ne sait pas porter, d'où le blob: en premier lieu.
|
||||
function lightboxEl(){
|
||||
let el=document.getElementById('lightbox');
|
||||
if(el) return el;
|
||||
el=document.createElement('div');
|
||||
el.id='lightbox';
|
||||
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
|
||||
+'<img alt=""><div class="lb-name"></div>';
|
||||
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
|
||||
// sinon on referme dès qu'on veut la regarder de près.
|
||||
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
|
||||
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
|
||||
function _lbKey(e){
|
||||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
|
||||
}
|
||||
function openLightbox(src, name){
|
||||
if(!src) return;
|
||||
const el=lightboxEl(), img=el.querySelector('img');
|
||||
img.src=src;
|
||||
img.alt=name||'';
|
||||
el.querySelector('.lb-name').textContent=name||'';
|
||||
showModal(el);
|
||||
document.addEventListener('keydown', _lbKey, true);
|
||||
}
|
||||
function closeLightbox(){
|
||||
const el=document.getElementById('lightbox'); if(!el) return;
|
||||
document.removeEventListener('keydown', _lbKey, true);
|
||||
hideModal(el);
|
||||
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
|
||||
// laissait un cadre vide clignoter pendant la fermeture.
|
||||
setTimeout(()=>{
|
||||
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
|
||||
}, 200);
|
||||
}
|
||||
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
|
||||
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
|
||||
//
|
||||
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
|
||||
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
|
||||
// une image que le navigateur n'a pas le droit de charger seul.
|
||||
function bindZoom(img, name){
|
||||
if(!img || img.dataset.zoom) return;
|
||||
img.dataset.zoom='1';
|
||||
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
|
||||
}
|
||||
+176
-100
@@ -143,11 +143,11 @@ select{color-scheme:inherit}
|
||||
option,optgroup{background:var(--panel);color:var(--text)}
|
||||
.side{width:320px;border-right:1px solid var(--border);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));overflow-y:auto;flex-shrink:0}
|
||||
.main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||||
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer}
|
||||
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:3px;font-size:18px;cursor:pointer}
|
||||
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
|
||||
/* Pastille « installation en cours » : suit exactement les règles d'affichage
|
||||
du bouton ☰ (elle ne sert que quand le panneau est en tiroir, donc caché). */
|
||||
#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:6px;font-size:12px;cursor:pointer}
|
||||
#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:3px;font-size:12px;cursor:pointer}
|
||||
#lc-chip.failed{border-color:var(--err);color:var(--err)}
|
||||
@media (max-width:720px){
|
||||
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left))}
|
||||
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
||||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||||
@media (min-width:721px){
|
||||
.side{width:380px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:9px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px}
|
||||
}
|
||||
h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
@@ -193,7 +193,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
sans souris. */
|
||||
.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||||
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
|
||||
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
|
||||
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:3px;color:var(--dim);cursor:pointer}
|
||||
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
/* Séparateur de groupe dans la barre latérale : sépare les réglages d'IA des
|
||||
réglages d'application, sans imbriquer un second niveau de repli (illisible
|
||||
@@ -203,7 +203,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
/* Choix d'avatar : une grille de pastilles emoji, celle retenue encadrée. */
|
||||
.avpick{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
|
||||
.avpick button{font-size:15px;line-height:1;padding:5px 6px;background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:8px;cursor:pointer}
|
||||
border:1px solid var(--border);border-radius:4px;cursor:pointer}
|
||||
.avpick button:hover{border-color:var(--accent)}
|
||||
.avpick button.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,transparent)}
|
||||
/* Avatar + nom en tête de bulle : l'emoji garde sa taille propre, le nom hérite
|
||||
@@ -212,7 +212,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
||||
/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et
|
||||
cliquables pour ouvrir en grand dans un onglet. */
|
||||
.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0}
|
||||
.msg .body img{max-width:100%;height:auto;border-radius:4px;border:1px solid var(--border);display:block;margin:8px 0}
|
||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||
@@ -235,15 +235,15 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
.pages-bar{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:7px 0;margin-top:4px}
|
||||
.pages-bar .pages-title{font-size:11.5px;color:var(--dim);font-weight:600}
|
||||
.pages-bar .addbtn{margin-left:auto}
|
||||
.mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font:inherit;font-size:12px;margin:0 0 6px}
|
||||
.mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:5px 9px;font:inherit;font-size:12px;margin:0 0 6px}
|
||||
.mem-search:focus{outline:none;border-color:var(--accent)}
|
||||
.mem-list{max-height:240px;overflow-y:auto;overscroll-behavior:contain}
|
||||
.mem-more{font-size:11px;color:var(--dim);text-align:center;padding:6px 0 2px}
|
||||
.ask-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:60;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
|
||||
.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
|
||||
.ask-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:8px}
|
||||
.ask-msg{font-size:13px;color:var(--dim);line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.ask-input{width:100%;box-sizing:border-box;margin-top:12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:9px 11px;font:inherit;font-size:13px}
|
||||
.ask-input{width:100%;box-sizing:border-box;margin-top:12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 11px;font:inherit;font-size:13px}
|
||||
.ask-input:focus{outline:none;border-color:var(--accent)}
|
||||
.ask-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
|
||||
.ask-actions button{margin:0;padding:7px 16px}
|
||||
@@ -275,17 +275,17 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
.switch input:focus-visible + .slider{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
|
||||
h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
|
||||
details>summary::-webkit-details-marker{display:none}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
|
||||
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
|
||||
details[open]>summary>.preset-add{display:inline-block}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag.ok{color:var(--ok);border-color:var(--ok)}
|
||||
.tag.err{color:var(--err);border-color:var(--err)}
|
||||
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
|
||||
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
|
||||
button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.row{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)}
|
||||
.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:3px;margin:4px 0;cursor:pointer;background:var(--panel)}
|
||||
.preset:hover{border-color:var(--accent)}
|
||||
.preset.active{border-color:var(--accent);color:var(--accent);border-width:1.5px;padding:5.5px 9.5px}
|
||||
.preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@@ -301,9 +301,9 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
/* La carte (.slist) laisse volontairement déborder son contenu (menus), elle ne
|
||||
rogne donc pas ses coins : c'est la LIGNE de bord qui porte l'arrondi, sinon
|
||||
le voile dépasse l'angle de la carte. */
|
||||
.rowlist:first-child>.preset:first-child{border-radius:8px 8px 0 0}
|
||||
.rowlist:last-child>.preset:last-child{border-radius:0 0 8px 8px}
|
||||
.rowlist:first-child:last-child>.preset:only-child{border-radius:8px}
|
||||
.rowlist:first-child>.preset:first-child{border-radius:4px 4px 0 0}
|
||||
.rowlist:last-child>.preset:last-child{border-radius:0 0 4px 4px}
|
||||
.rowlist:first-child:last-child>.preset:only-child{border-radius:4px}
|
||||
/* Liseré de gauche : un trait DROIT (pas un arc) qui court sur TOUTE la hauteur
|
||||
de la ligne. Donc pas de box-shadow interne (l'arrondi le courberait) mais un
|
||||
élément à part, aux bords francs, que l'arrondi de la ligne de bord se
|
||||
@@ -355,15 +355,15 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
margin:0 -2px 0 0;padding:4px;line-height:0;background:transparent;border:none;opacity:.55}
|
||||
.preset-edit:hover{opacity:1;color:var(--accent);background:transparent}
|
||||
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
|
||||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em;text-transform:uppercase}
|
||||
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em}
|
||||
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em}
|
||||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
|
||||
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
/* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */
|
||||
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle}
|
||||
.help:hover{color:var(--accent);border-color:var(--accent)}
|
||||
/* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? »,
|
||||
pour rester alignée au bord gauche du panneau et ne pas déborder. */
|
||||
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
|
||||
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:3px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
|
||||
.help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block}
|
||||
.kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center}
|
||||
.kv span:first-child{color:var(--text);flex-shrink:0}
|
||||
@@ -378,7 +378,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
#chat{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:20px;display:flex;flex-direction:column;gap:14px}
|
||||
:root{--col:max(20px,calc(50% - 450px))}
|
||||
@media (max-width:720px){#chat{padding:60px 8px 12px}}
|
||||
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
|
||||
.msg{max-width:80%;padding:10px 14px;border-radius:4px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
|
||||
.msg.assistant,.msg.reasoning{white-space:normal}
|
||||
.msg .body{overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.user{align-self:flex-end;background:var(--bubble);color:var(--text)}
|
||||
@@ -394,8 +394,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.msg .body li{margin:.15em 0}
|
||||
.msg .body li>p{margin:0}
|
||||
.msg.assistant .body code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
|
||||
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s}
|
||||
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
|
||||
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;transition:opacity .15s,color .15s}
|
||||
.msg.assistant .body pre:hover .copybtn,.msg.reasoning .body pre:hover .copybtn{opacity:1}
|
||||
.copybtn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1}
|
||||
@@ -429,7 +429,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.diff-count .a{color:var(--add)}
|
||||
.diff-count .d{color:var(--del)}
|
||||
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
|
||||
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
|
||||
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
|
||||
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
|
||||
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
|
||||
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
|
||||
@@ -444,7 +444,12 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux
|
||||
éléments pour un seul état d'attente. Ici les points passent en accent et un
|
||||
libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */
|
||||
.msg.typing.compacting span:not(.tlabel){background:var(--accent)}
|
||||
.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)}
|
||||
/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de
|
||||
pastille que `.msg.typing span` applique à TOUT span de la bulle. */
|
||||
.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none;
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim);
|
||||
margin-left:7px;white-space:nowrap}
|
||||
.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap}
|
||||
@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}}
|
||||
/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur
|
||||
@@ -567,7 +572,7 @@ details>summary+.subhead{margin-top:4px}
|
||||
rapport à la ligne, un débordement caché les TRONQUERAIT — elles doivent
|
||||
pouvoir déborder de la carte. Les lignes n'ont pas de fond propre, l'arrondi
|
||||
n'a donc rien à rogner. */
|
||||
.slist{background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||||
.slist{background:var(--panel);border:1px solid var(--border);border-radius:4px;
|
||||
overflow:visible;margin:0 0 8px}
|
||||
.slist:last-child{margin-bottom:0}
|
||||
/* Contenu libre (champ + boutons) : le rembourrage passe sur la carte. */
|
||||
@@ -616,10 +621,10 @@ details>summary+.subhead{margin-top:4px}
|
||||
|
||||
/* --- Champs et boutons --- */
|
||||
.sctl,.io-input{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);
|
||||
border-radius:7px;padding:7px 9px;font:inherit;font-size:12.5px}
|
||||
border-radius:3px;padding:7px 9px;font:inherit;font-size:12.5px}
|
||||
.sctl:focus,.io-input:focus{outline:none;border-color:var(--dim)}
|
||||
textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn):not(.iconbtn){font-size:12px;padding:6px 10px;border-radius:7px}
|
||||
.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn):not(.iconbtn){font-size:12px;padding:6px 10px;border-radius:3px}
|
||||
.side .row{display:flex;gap:6px;align-items:center;margin:6px 0}
|
||||
.side .row .sctl,.side .row .io-input{flex:1;min-width:0}
|
||||
/* Bouton d'action à côté d'un champ (copier, ouvrir) : un CARRÉ à la hauteur du
|
||||
@@ -627,7 +632,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
posait de travers et changeait de largeur d'une police à l'autre. */
|
||||
.iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;
|
||||
width:34px;height:34px;padding:0;margin:0;color:var(--dim);background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:9px;cursor:pointer}
|
||||
border:1px solid var(--border);border-radius:4px;cursor:pointer}
|
||||
.iconbtn:hover{color:var(--text);border-color:var(--dim);background:var(--accent-bg)}
|
||||
.iconbtn svg{width:15px;height:15px;display:block}
|
||||
.side .btn-danger-o{border-color:var(--err);color:var(--err)}
|
||||
@@ -670,10 +675,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
|
||||
/* Moteur pas encore prêt : l'aide clavier cède la place à l'état du chargement. */
|
||||
#sendhint.waiting{opacity:1;color:var(--dim)}
|
||||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
|
||||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
|
||||
#input:focus{outline:none;border-color:var(--accent)}
|
||||
.muted{color:var(--dim);font-size:13px}
|
||||
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:4px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
#toast.show{opacity:1}
|
||||
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
|
||||
/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les
|
||||
@@ -697,7 +702,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
rétrécit le contenu. Padding interne normal → largeur utile pleine. */
|
||||
.side{width:300px;max-width:300px;left:env(safe-area-inset-left);padding:calc(14px + env(safe-area-inset-top)) 14px calc(14px + env(safe-area-inset-bottom)) 14px}
|
||||
#chat{padding:48px 10px 6px;gap:7px}
|
||||
.msg{padding:6px 10px;border-radius:8px;max-width:78%}
|
||||
.msg{padding:6px 10px;border-radius:4px;max-width:78%}
|
||||
#chat .msg.assistant,#chat .msg.reasoning{max-width:88%}
|
||||
#inputbar{padding:5px 8px;gap:6px}
|
||||
/* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On
|
||||
@@ -724,7 +729,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px}
|
||||
/* « conseillé » : pilule PLEINE (texte inversé). En contour, elle héritait du
|
||||
gris atténué du thème monochrome et devenait illisible en mode sombre. */
|
||||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:8px;padding:1px 6px}
|
||||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
|
||||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||||
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
|
||||
les trois cartes grandissaient d'un cran une fois l'état connu. */
|
||||
@@ -753,7 +758,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
affichée avec une seule carte). */
|
||||
.pe-group[hidden]{display:none}
|
||||
.pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px}
|
||||
.pe-list{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||||
.pe-list{background:var(--panel);border:1px solid var(--border);border-radius:3px;overflow:hidden}
|
||||
/* Ligne : libellé à gauche, contrôle à droite, séparateur fin entre lignes. */
|
||||
.pe-row{display:flex;align-items:center;gap:14px;min-height:48px;padding:9px 15px}
|
||||
/* Séparateur porté par la ligne SUIVANTE (border-top), pas par la précédente
|
||||
@@ -806,9 +811,9 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.gpu-legend i.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)}
|
||||
.gpu-shares{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.gpu-share{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--dim)}
|
||||
.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:5px 7px;font:inherit;text-align:right}
|
||||
.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:5px 7px;font:inherit;text-align:right}
|
||||
.gpu-share input:focus{outline:none;border-color:var(--accent)}
|
||||
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
|
||||
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
|
||||
.pe-inp:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
.pe-row.stack.plain .pe-inp{border:none;background:transparent;padding:6px 0;font-size:15px}
|
||||
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
|
||||
@@ -819,13 +824,13 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
/* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
|
||||
sans ça, un thème qui arrondit tous les boutons (ChatGPT) le détachait du
|
||||
champ, avec un coin rond au milieu de la paire. */
|
||||
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
|
||||
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 4px 4px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
|
||||
.pe-inpbtn button:hover:not(:disabled){color:var(--bg);background:var(--accent);border-color:var(--accent)}
|
||||
.pe-inpbtn:focus-within .pe-inp,.pe-inpbtn:focus-within button{border-color:var(--accent)}
|
||||
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
|
||||
/* Contrôle segmenté (moteur) façon iOS. */
|
||||
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%}
|
||||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:7px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
|
||||
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:4px;padding:3px;width:100%}
|
||||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:3px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
|
||||
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
|
||||
.pe-seg input{display:none}
|
||||
.pe-seg .be-note{display:none}
|
||||
@@ -851,7 +856,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.pe-switch input:checked+.pe-sl::before{transform:translateX(16px);background:var(--bg)}
|
||||
.pe-switch input:focus-visible+.pe-sl{box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
/* Repli « avancé » / config brute : une ligne cliquable dans un groupe. */
|
||||
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||||
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:3px;overflow:hidden}
|
||||
.pe-adv>summary{cursor:pointer;font-size:14px;color:var(--text);padding:13px 15px;list-style:none;display:flex;align-items:center;user-select:none}
|
||||
.pe-adv>summary::-webkit-details-marker{display:none}
|
||||
.pe-adv>summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:translateY(-2px) rotate(45deg);transition:transform .15s}
|
||||
@@ -874,7 +879,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
align-items:center;gap:8px;flex-wrap:nowrap}
|
||||
.pe-foot .foot-l{display:flex;align-items:center;gap:8px;min-width:0}
|
||||
.pe-foot .foot-r{display:flex;gap:8px;flex:none}
|
||||
.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:8px;font-weight:600;cursor:pointer;
|
||||
.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:4px;font-weight:600;cursor:pointer;
|
||||
border:1px solid var(--border);background:var(--panel);color:var(--text);white-space:nowrap}
|
||||
.pe-btn:hover{border-color:var(--dim)}
|
||||
.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:var(--bg)}
|
||||
@@ -883,7 +888,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
autant que « enregistrer » alors que c'est l'action rare — et à trois boutons
|
||||
larges, le pied passait à deux lignes sur téléphone. */
|
||||
.pe-del{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
|
||||
margin:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--err);cursor:pointer}
|
||||
margin:0;padding:0;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--err);cursor:pointer}
|
||||
.pe-del svg{width:15px;height:15px;display:block}
|
||||
.pe-del:hover{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 40%,transparent)}
|
||||
/* Option d'une confirmation : une PASTILLE à cocher, pas une case système suivie
|
||||
@@ -914,7 +919,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.modal-ov,.ask-overlay,.modal-card,.ask-box{transition:none}
|
||||
}
|
||||
.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)}
|
||||
.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:3px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)}
|
||||
.modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
.modal-head strong{font-size:14px}
|
||||
/* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique
|
||||
@@ -935,7 +940,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
preset au nouveau SOUS LES YEUX, une fois le chargement terminé. */
|
||||
.modal-ov.loading .pe-body *,.modal-ov.no-anim .pe-body *{transition:none!important}
|
||||
.modal-load{display:none;position:absolute;inset:var(--mh,52px) 0 0 0;z-index:2;
|
||||
align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 12px 12px}
|
||||
align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 5px 5px}
|
||||
.modal-ov.loading .modal-load{display:flex}
|
||||
/* visibility (et non display) : le formulaire garde sa place, donc la carte a
|
||||
déjà sa taille finale — la révéler ne provoque plus d'à-coup de hauteur. */
|
||||
@@ -956,10 +961,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.fld-l{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)}
|
||||
.fld-l .opt{text-transform:none;letter-spacing:0;font-weight:400;opacity:.65}
|
||||
.fld-hint{font-size:11px;color:var(--dim);margin-top:2px}
|
||||
.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:9px 11px;font:inherit}
|
||||
.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 11px;font:inherit}
|
||||
.ctl:focus{outline:none;border-color:var(--accent)}
|
||||
.ta{min-height:220px;resize:vertical;line-height:1.5}
|
||||
.card{border:1px solid var(--border);border-radius:10px;padding:13px;display:flex;flex-direction:column;gap:13px}
|
||||
.card{border:1px solid var(--border);border-radius:4px;padding:13px;display:flex;flex-direction:column;gap:13px}
|
||||
.card-h{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)}
|
||||
.row{display:flex;gap:6px}
|
||||
.row .ctl{flex:1;min-width:0}
|
||||
@@ -969,7 +974,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.input-btn .btn-in{flex-shrink:0;margin:0;padding:9px 13px;border-left:0;border-top-left-radius:0;border-bottom-left-radius:0}
|
||||
.input-btn .btn-in:hover{border-left:0}
|
||||
.seg{display:flex;gap:6px}
|
||||
.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s}
|
||||
.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s}
|
||||
.seg>label:hover{border-color:var(--accent)}
|
||||
.seg>label:has(input:checked){border-color:var(--accent);background:var(--accent-bg);color:var(--accent)}
|
||||
.seg input{display:none}
|
||||
@@ -981,12 +986,12 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--dim);font-weight:600;padding:6px 0;margin-top:2px}
|
||||
.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block}
|
||||
.lc-logbar.open #lc-log-caret{transform:rotate(90deg)}
|
||||
#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||||
#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:3px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||||
.lc-spin{display:inline-block;animation:spin 1s linear infinite}
|
||||
/* Serveurs MCP */
|
||||
.mcp-list{display:flex;flex-direction:column;gap:4px}
|
||||
|
||||
.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel);cursor:pointer}
|
||||
.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:3px;background:var(--panel);cursor:pointer}
|
||||
.mcp-row:hover{border-color:var(--accent)}
|
||||
.mcp-row.off{opacity:.6}
|
||||
.mcp-dot{flex:none;width:8px;height:8px;border-radius:50%}
|
||||
@@ -996,7 +1001,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-info{flex:1;min-width:0}
|
||||
.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:4px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro
|
||||
de build et un hash de commit se recopient et se comparent à l'œil, et
|
||||
« AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */
|
||||
@@ -1007,26 +1012,34 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
|
||||
.mcp-edit:hover{opacity:1;color:var(--accent)}
|
||||
/* Liste des outils MCP dans la modale */
|
||||
/* Sélecteur d'intensité du raisonnement, dans la barre de saisie. Discret par
|
||||
défaut (même poids visuel que les icônes voisines), il ne s'affirme qu'au
|
||||
survol ou quand une intensité est imposée. */
|
||||
#think-effort{flex:none;align-self:center;height:22px;padding:0 4px;font-size:11px;
|
||||
color:var(--dim);background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}
|
||||
#think-effort:hover:not(:disabled){color:var(--text);border-color:var(--border)}
|
||||
#think-effort:disabled{opacity:.4;cursor:not-allowed}
|
||||
|
||||
.mcp-actions{display:flex;gap:6px;margin-top:6px}
|
||||
.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:4px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat:hover{border-color:var(--accent);color:var(--text)}
|
||||
.mcp-cat.on{border-color:var(--accent);color:var(--accent)}
|
||||
.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:var(--panel)}
|
||||
.mcp-cat-row:hover{border-color:var(--accent)}
|
||||
.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35}
|
||||
.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px}
|
||||
.mcp-cat-use{flex:none}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:3px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:3px;cursor:pointer}
|
||||
.mcp-tool-row:hover{background:var(--panel)}
|
||||
.mcp-tool-switch{flex:none;transform:scale(.85)}
|
||||
.mcp-tool-name{font-size:12px;color:var(--text);word-break:break-all}
|
||||
|
||||
/* Accès OpenAI / Accès distant : champs lecture seule (URL, clé) + libellés
|
||||
compacts, pour une présentation homogène et sans surcharge visuelle. */
|
||||
.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:8px 10px;font:inherit;font-size:13px}
|
||||
.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px 10px;font:inherit;font-size:13px}
|
||||
|
||||
/* Modale MCP (ajout/édition) : les champs des groupes stdio/http n'avaient aucun
|
||||
espacement vertical (le conteneur n'est pas un flex à gap) → ils se touchaient.
|
||||
@@ -1053,7 +1066,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
|
||||
/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */
|
||||
#chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
|
||||
/* 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:3px;padding:11px 18px;max-width:75%;line-height:1.6}
|
||||
#chat .msg.user .label{color:var(--dim)}
|
||||
/* Libellés : l'amont les masquait tous ici, les mots « user » / « assistant »
|
||||
n'ajoutant rien à l'alignement. Ils portent désormais un avatar et un prénom
|
||||
@@ -1071,7 +1084,12 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
|
||||
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
|
||||
color:var(--dim);opacity:.75}
|
||||
#chat .msg .statline:empty{display:none}
|
||||
html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
|
||||
travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
|
||||
réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
|
||||
entier — sans .has-work il ne resterait qu'un blanc. */
|
||||
html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none}
|
||||
html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
|
||||
/* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75)
|
||||
est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne
|
||||
clignote en repassant à sa valeur de repos. */
|
||||
@@ -1079,10 +1097,10 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
@keyframes statin{from{opacity:0;transform:translateY(3px)}to{opacity:.75;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){#chat .msg .statline-in{animation:none}}
|
||||
/* Raisonnement / outils : encart gris discret plutôt qu'un cadre pointillé. */
|
||||
#chat .msg.reasoning,#chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px}
|
||||
#chat .msg.reasoning,#chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:3px;color:var(--dim);padding:12px 16px}
|
||||
#chat .msg.reasoning{color:var(--dim)}
|
||||
#chat .msg.tool .tool-head{color:var(--text)}
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{border-radius:12px;padding:12px 14px}
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{border-radius:3px;padding:12px 14px}
|
||||
#chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
|
||||
/* Composer = UNE seule carte posée PAR-DESSUS le fil : la conversation
|
||||
continue de défiler DERRIÈRE elle et s'efface en fondu (dégradé vers le fond)
|
||||
@@ -1110,7 +1128,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
trou en PWA iOS. */
|
||||
#chat{padding-bottom:calc(var(--composer-h,170px) + 12px)}
|
||||
#scrollbtn{bottom:calc(var(--composer-h,170px) + 10px);border-radius:999px}
|
||||
#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
|
||||
#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:4px 4px 0 0}
|
||||
#input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
|
||||
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
|
||||
bordure, seule la saisie change (curseur). */
|
||||
@@ -1120,7 +1138,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
|
||||
découper la jauge dans sa propre forme — la barre est bien épaisse ET rognée
|
||||
exactement sur la courbe, symétrique du haut. */
|
||||
#ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden}
|
||||
#ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;overflow:hidden}
|
||||
/* ⚠️ NE PAS piloter la position du composer en JS quand le clavier s'ouvre
|
||||
(tenté puis retiré le 2026-07-28, deux fois) : ni asservir la hauteur de la
|
||||
coque à visualViewport.height, ni décaler le composer de la hauteur du
|
||||
@@ -1193,9 +1211,48 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
|
||||
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
|
||||
une seule bulle difforme qui déborde vers le haut. */
|
||||
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
|
||||
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
|
||||
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
|
||||
hauteur propre. */
|
||||
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
|
||||
gap:6px;max-width:75%}
|
||||
align-items:flex-start;gap:6px;max-width:75%}
|
||||
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
|
||||
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
|
||||
capture qu'on vient de coller, assez petite pour que la rangée reste une
|
||||
rangée de pastilles et pas une planche-contact. */
|
||||
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
|
||||
background:var(--panel)}
|
||||
.chip-file.has-thumb{padding-left:4px}
|
||||
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
|
||||
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
|
||||
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
|
||||
border:1px solid var(--border);background:var(--panel)}
|
||||
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
|
||||
aussi ici, or le cadre est déjà porté par la tuile. */
|
||||
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
|
||||
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
|
||||
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
|
||||
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
|
||||
la colonne entière et poussait le texte hors de vue. */
|
||||
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
|
||||
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
|
||||
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
|
||||
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
|
||||
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
|
||||
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
|
||||
opacity:0;transition:opacity .16s ease}
|
||||
#lightbox.show{opacity:1}
|
||||
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
|
||||
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
|
||||
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
|
||||
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:22px;line-height:1;border:none;border-radius:50%;
|
||||
background:rgba(0,0,0,.35);color:#fff}
|
||||
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
|
||||
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
|
||||
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
|
||||
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
|
||||
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
|
||||
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
|
||||
#chat .msg .filelink{cursor:pointer}
|
||||
@@ -1206,16 +1263,16 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center;
|
||||
justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none}
|
||||
#dropveil.show{display:flex}
|
||||
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:20px;
|
||||
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:3px;
|
||||
background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
|
||||
button,.mem-search{border-radius:10px}
|
||||
button,.mem-search{border-radius:4px}
|
||||
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
|
||||
.pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
|
||||
.pe-inpbtn button{border-radius:0 10px 10px 0}
|
||||
.input-btn .ctl{border-radius:10px 0 0 10px}
|
||||
.input-btn .btn-in{border-radius:0 10px 10px 0}
|
||||
.preset{border-radius:12px}
|
||||
.pe-inpbtn .pe-inp{border-radius:4px 0 0 4px}
|
||||
.pe-inpbtn button{border-radius:0 4px 4px 0}
|
||||
.input-btn .ctl{border-radius:4px 0 0 4px}
|
||||
.input-btn .btn-in{border-radius:0 4px 4px 0}
|
||||
.preset{border-radius:3px}
|
||||
/* Les presets restent des cartes (fond + bordure) : à plat, sans fond ni
|
||||
cadre, la liste ne se lisait plus comme des lignes distinctes. L'actif se
|
||||
signale par sa bordure — un simple voile d'accent (7 % de blanc sur fond
|
||||
@@ -1236,7 +1293,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
|
||||
/* ===== Fenêtres et sélecteurs =====================================
|
||||
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
|
||||
.modal-card,.ask-box,.modal-box,.pe-list,.pe-adv{border-radius:12px}
|
||||
.modal-card,.ask-box,.modal-box,.pe-list,.pe-adv{border-radius:3px}
|
||||
.modal-card,.ask-box{box-shadow:0 8px 30px rgba(0,0,0,.12)}
|
||||
/* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */
|
||||
.pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)}
|
||||
@@ -1279,7 +1336,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
/* Barre latérale : pile de cartes, sans filet ni bordure de colonne. */
|
||||
.side{width:312px;border-right:0;padding:0;background:transparent;
|
||||
display:flex;flex-direction:column;gap:12px;overflow-y:auto;overscroll-behavior:contain}
|
||||
.sidecard,.side>details{background:var(--panel);border:0;border-top:0;border-radius:20px;
|
||||
.sidecard,.side>details{background:var(--panel);border:0;border-top:0;border-radius:3px;
|
||||
box-shadow:var(--shadow);margin:0;padding:2px 16px}
|
||||
.sidecard{padding:15px 18px 14px;display:flex;flex-direction:column;gap:2px}
|
||||
.side>details[open]{padding-bottom:14px}
|
||||
@@ -1289,10 +1346,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.side-sep{border-top:0;margin:10px 0 0;padding:0 6px}
|
||||
/* Blocs encastrés DANS une carte : un ton plus chaud, sans bordure — deux
|
||||
cadres imbriqués alourdiraient inutilement. */
|
||||
.side .slist{background:var(--row-bg);border-color:transparent;border-radius:14px}
|
||||
.side .slist{background:var(--row-bg);border-color:transparent;border-radius:3px}
|
||||
|
||||
/* Colonne principale : une seule grande carte qui contient fil et saisie. */
|
||||
.main{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);overflow:hidden}
|
||||
.main{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);overflow:hidden}
|
||||
#chat{padding-top:26px;padding-inline:max(20px,calc(50% - 360px))}
|
||||
/* Le dégradé qui fait disparaître le fil sous la carte de saisie doit fondre
|
||||
vers le PANNEAU, pas vers le fond de la coque. */
|
||||
@@ -1314,13 +1371,13 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
3 px sur le seul bord gauche se lisait mal et déséquilibrait la ligne. La
|
||||
bordure transparente est portée par tous les états : la sélection ne décale
|
||||
donc pas le contenu d'un pixel. */
|
||||
.side .preset{border:1px solid transparent;border-radius:14px;background:transparent}
|
||||
.side .preset{border:1px solid transparent;border-radius:3px;background:transparent}
|
||||
.side .preset.active{background:var(--row-bg-on);border-color:var(--accent)}
|
||||
.side .preset:hover{background:var(--row-bg)}
|
||||
|
||||
/* Bulle de question : coin bas-droit rentré, comme une bulle qui pointe vers
|
||||
son auteur. */
|
||||
#chat .msg.user{border-radius:20px 20px 6px 20px}
|
||||
#chat .msg.user{border-radius:4px}
|
||||
}
|
||||
/* Action principale en sauge : c'est la couleur d'action de la charte, et elle
|
||||
se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre
|
||||
@@ -1359,7 +1416,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
l'application de l'autre, tiennent maintenant chacune dans UNE carte, avec un
|
||||
filet discret entre les lignes. */
|
||||
@media (min-width:721px){
|
||||
.sidegroup{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
.sidegroup{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
|
||||
padding:2px 16px;display:flex;flex-direction:column}
|
||||
/* Dans un groupe, les sections ne sont plus des cartes : elles redeviennent
|
||||
des lignes, séparées par un filet — sauf la première. */
|
||||
@@ -1376,7 +1433,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
|
||||
l'écran. */
|
||||
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
|
||||
border:1px solid var(--border);border-radius:12px;margin-top:6px}
|
||||
border:1px solid var(--border);border-radius:3px;margin-top:6px}
|
||||
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
|
||||
border-top:1px solid var(--border);font-size:12px}
|
||||
.hf-row:first-child{border-top:0}
|
||||
@@ -1412,12 +1469,12 @@ html[data-files="1"] #files{display:flex}
|
||||
.files-head{display:flex;align-items:center;gap:8px}
|
||||
.files-head .stitle{flex:1}
|
||||
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
|
||||
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:4px}
|
||||
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
|
||||
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
|
||||
discussion dans le chemin dépasse largement 320 px. */
|
||||
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
|
||||
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
|
||||
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:3px;
|
||||
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
|
||||
.files-crumb-b.on{color:var(--text);cursor:default}
|
||||
@@ -1427,7 +1484,7 @@ html[data-files="1"] #files{display:flex}
|
||||
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
|
||||
survol : au repos on veut lire des noms de fichiers, pas une grille de
|
||||
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
|
||||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
|
||||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;font-size:12px}
|
||||
.files-row:hover{background:var(--row-bg-on)}
|
||||
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
|
||||
.files-row.isdir .files-ic{color:var(--accent)}
|
||||
@@ -1438,7 +1495,7 @@ html[data-files="1"] #files{display:flex}
|
||||
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
|
||||
@media (hover:none){.files-acts{opacity:1}}
|
||||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||||
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
border:1px solid transparent;border-radius:3px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border);
|
||||
display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
@@ -1446,7 +1503,7 @@ html[data-files="1"] #files{display:flex}
|
||||
secours pour les fichiers d'avant le rangement par discussion, pas une vue
|
||||
qu'on propose au même rang que la discussion ouverte. */
|
||||
.files-scope{margin:0;padding:2px 6px;min-height:0;font-family:var(--mono);font-size:10px;
|
||||
background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--dim)}
|
||||
background:transparent;border:1px solid var(--border);border-radius:3px;color:var(--dim)}
|
||||
.files-scope:hover{color:var(--text);background:var(--row-bg-on)}
|
||||
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
|
||||
.files-row.busy{opacity:.55;pointer-events:none}
|
||||
@@ -1469,7 +1526,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
}
|
||||
@media (min-width:721px){
|
||||
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:3px;
|
||||
box-shadow:var(--shadow);margin:0}
|
||||
}
|
||||
|
||||
@@ -1525,7 +1582,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#topbar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
font-size:13.5px;font-weight:500;color:var(--text)}
|
||||
#topbar .iconbtn{width:30px;height:30px;flex:none;display:inline-flex;align-items:center;
|
||||
justify-content:center;border:0;border-radius:9px;background:transparent;color:var(--dim);
|
||||
justify-content:center;border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||
cursor:pointer;margin:0;padding:0;transition:background .15s,color .15s,transform .1s}
|
||||
#topbar .iconbtn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#topbar .iconbtn:active{transform:scale(.92)}
|
||||
@@ -1549,7 +1606,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
|
||||
Le reste (réglages) descend sous un unique repli. */
|
||||
#conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text);
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:4px;
|
||||
padding:7px 11px;margin:0 12px 8px}
|
||||
#conv-search::placeholder{color:var(--dim)}
|
||||
#conv-search:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
@@ -1567,6 +1624,25 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
color:var(--dim);letter-spacing:-.01em;overflow:visible}
|
||||
.side .preset:hover .conv-when{opacity:0}
|
||||
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
|
||||
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
|
||||
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
|
||||
agent est en train de travailler, y compris depuis un autre appareil. */
|
||||
/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout
|
||||
span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et
|
||||
écrasait le titre, exactement comme l'heure avant lui. */
|
||||
.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center;
|
||||
justify-content:center;line-height:0;color:var(--accent)}
|
||||
.conv-spin svg{animation:conv-spin 900ms linear infinite}
|
||||
@keyframes conv-spin{to{transform:rotate(360deg)}}
|
||||
/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste,
|
||||
le mouvement de rotation disparaît. */
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.conv-spin svg{animation:conv-breathe 1.6s ease-in-out infinite}
|
||||
@keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}}
|
||||
}
|
||||
/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons
|
||||
voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */
|
||||
#topbar>.conv-spin{width:18px}
|
||||
/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
|
||||
seule ligne, pour que la barre latérale de la maquette (discussions +
|
||||
machine) reste la vue par défaut. */
|
||||
@@ -1603,7 +1679,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
⚠️ Ne concerne pas les boutons-icônes de la saisie et de l'en-tête (#send,
|
||||
#stop, #attach, #menubtn…) : ils posent `border:0` PLUS BAS dans la feuille
|
||||
et gardent leur allure nue, qui est la bonne pour un pictogramme seul. */
|
||||
button{background:var(--panel);border-color:var(--border);border-radius:9px;
|
||||
button{background:var(--panel);border-color:var(--border);border-radius:4px;
|
||||
transition:background .15s ease,color .15s ease,border-color .15s ease,transform .08s ease}
|
||||
button:hover{background:var(--row-bg);border-color:var(--dim);color:var(--text)}
|
||||
button:active:not(:disabled){transform:translateY(1px)}
|
||||
@@ -1614,11 +1690,11 @@ button:active:not(:disabled){transform:translateY(1px)}
|
||||
nu flotte sans ancrage, et la frontière entre deux réponses se perd. */
|
||||
#chat{padding-top:22px}
|
||||
#chat .msg.assistant{background:var(--bubble);border:0;
|
||||
border-radius:10px;padding:15px 17px;max-width:100%;box-shadow:var(--shadow-card)}
|
||||
border-radius:4px;padding:15px 17px;max-width:100%;box-shadow:var(--shadow-card)}
|
||||
#chat .msg.assistant.errmsg{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
|
||||
/* Question : aplat de sauge, coin bas-droit rentré. --on-accent garantit le
|
||||
contraste dans les DEUX variantes. */
|
||||
#chat .msg.user{background:var(--user-bg);color:var(--on-user);border-radius:10px;
|
||||
#chat .msg.user{background:var(--user-bg);color:var(--on-user);border-radius:4px;
|
||||
padding:13px 16px;max-width:62%;box-shadow:var(--shadow-card)}
|
||||
#chat .msg.user .label{color:var(--on-user);opacity:.75}
|
||||
#chat .msg.user .body a{color:var(--on-user);text-decoration:underline}
|
||||
@@ -1644,7 +1720,7 @@ button:active:not(:disabled){transform:translateY(1px)}
|
||||
#inputbar #attach,#inputbar #files-btn,#send,#stop{
|
||||
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;
|
||||
border:0;border-radius:10px;background:transparent;color:var(--dim);
|
||||
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||
transition:background .15s ease,color .15s ease,transform .08s ease}
|
||||
/* Envoyer / arrêter : un seul des deux à la fois, décidé par data-busy (posé par
|
||||
syncSendBtn). C'est la feuille qui garde la main sur `display` — un style
|
||||
@@ -1665,8 +1741,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient
|
||||
ce qu'il doit être — une jauge de contexte, rien d'autre. */
|
||||
#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card);
|
||||
border-radius:14px 14px 0 0}
|
||||
#ctxmeta{border-radius:0 0 14px 14px}
|
||||
border-radius:3px 3px 0 0}
|
||||
#ctxmeta{border-radius:0 0 5px 5px}
|
||||
#ctxmeta{padding-bottom:12px}
|
||||
#ctxtrack{border-radius:999px}
|
||||
|
||||
@@ -1687,8 +1763,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#side-scroll>details,#side-scroll>.sidegroup{background:transparent;box-shadow:none;
|
||||
border-radius:0;padding:0;margin:0}
|
||||
#side-scroll>details>summary{height:34px}
|
||||
.side .slist{background:transparent;border-color:transparent;border-radius:12px}
|
||||
.side #side-settings .slist{background:var(--row-bg);border-radius:12px}
|
||||
.side .slist{background:transparent;border-color:transparent;border-radius:3px}
|
||||
.side #side-settings .slist{background:var(--row-bg);border-radius:3px}
|
||||
/* Le fil n'est plus une carte : il EST la page. */
|
||||
.main{background:transparent;border-radius:0;box-shadow:none;overflow:visible}
|
||||
#composer{background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
|
||||
@@ -1701,7 +1777,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
au tiers de la place disponible. */
|
||||
.side .preset{position:relative}
|
||||
.side .preset .conv-acts{position:absolute;right:6px;top:50%;transform:translateY(-50%);
|
||||
margin:0;background:var(--row-bg-on);border-radius:9px;padding:1px}
|
||||
margin:0;background:var(--row-bg-on);border-radius:4px;padding:1px}
|
||||
.side .preset.active .conv-acts{background:var(--row-bg)}
|
||||
.side .preset .preset-info{overflow:hidden}
|
||||
/* La sélection est un aplat, pas un cadre : un liseré autour d'une ligne de
|
||||
@@ -1723,10 +1799,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* Le ☰ suit la charte des boutons : pas de contour, un fond qui apparaît au
|
||||
survol. Il flotte au-dessus de l'en-tête, il doit donc en reprendre le fond
|
||||
pour ne pas laisser voir le fil défiler dessous. */
|
||||
#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:10px;
|
||||
#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:4px;
|
||||
width:34px;height:34px;font-size:16px;top:calc(11px + env(safe-area-inset-top));left:9px}
|
||||
#menubtn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#lc-chip{border-radius:10px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
|
||||
#lc-chip{border-radius:4px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
|
||||
/* Jauges : la piste doit se VOIR, sinon une barre presque vide ressemble à un
|
||||
trait souligné sous l'intitulé. Valeurs relevées sur la maquette. */
|
||||
.perf-track{background:var(--track)}
|
||||
@@ -1743,7 +1819,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{
|
||||
background:var(--code-bg);border:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
|
||||
#chat .msg.assistant .body code{background:var(--code-bg);
|
||||
border:1px solid color-mix(in srgb,var(--text) 7%,transparent);border-radius:5px}
|
||||
border:1px solid color-mix(in srgb,var(--text) 7%,transparent);border-radius:3px}
|
||||
#chat .msg.assistant .body pre code{border:0;background:transparent}
|
||||
/* --- Titres de la barre latérale ------------------------------------
|
||||
La maquette n'a qu'un style de titre : casse normale, demi-gras, taille du
|
||||
@@ -1829,7 +1905,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#chat-actions-clear,.row>button>svg{vertical-align:-2px}
|
||||
.row>button:has(svg){display:inline-flex;align-items:center;gap:7px}
|
||||
/* Le « + » des sections : même carré que les autres commandes de la barre. */
|
||||
.preset-add{width:26px;height:26px;border-radius:8px}
|
||||
.preset-add{width:26px;height:26px;border-radius:4px}
|
||||
/* Étiquettes de groupe : même traitement que les titres de section — la
|
||||
maquette n'a qu'un style de titre, et le monospace en capitales de la charte
|
||||
précédente criait au milieu des formulaires. */
|
||||
@@ -1861,7 +1937,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* La liste EST la carte, au MÊME gabarit que .sidegroup (les sections de
|
||||
réglages) : fond de panneau, rayon de 20, ombre discrète. Les lignes sont
|
||||
rognées par overflow au lieu d'arrondir la première et la dernière à la main. */
|
||||
#conv-list{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
#conv-list{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
|
||||
overflow:hidden;margin:0}
|
||||
.side .preset{border-radius:0;margin:0;padding:10px 12px}
|
||||
.side .preset:not(:last-child){border-bottom:1px solid var(--border)}
|
||||
@@ -1877,7 +1953,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
couple que « Paramètres » + sa liste dans les réglages. */
|
||||
#side-perf{padding:12px 12px 12px;padding-bottom:calc(12px + env(safe-area-inset-bottom))}
|
||||
.perf-head{margin:0 2px 7px;font-size:12.5px;font-weight:600;color:var(--dim)}
|
||||
.perf-body{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
.perf-body{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
|
||||
padding:14px 15px 3px}
|
||||
/* ⚠️ Pas de remise à zéro sur :last-child — les jauges vivent dans DEUX
|
||||
conteneurs (les cartes graphiques, puis la mémoire vive). La dernière jauge
|
||||
|
||||
@@ -703,6 +703,49 @@ func handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
||||
}
|
||||
|
||||
// handleReasoningEffort lit (GET) ou règle (POST) l'intensité du raisonnement.
|
||||
//
|
||||
// Même rôle que le sélecteur de l'éditeur de preset, mais applicable depuis la
|
||||
// barre de saisie sans rouvrir les réglages : la valeur part dans la config
|
||||
// vivante, elle est relue à chaque requête au moteur (llm_client.go). Aucun
|
||||
// redémarrage du moteur — `reasoning_effort` voyage dans le corps de la requête.
|
||||
//
|
||||
// Attention : appliquer un preset réécrit TOUTE la config, donc la valeur du
|
||||
// preset reprend la main à ce moment-là. C'est voulu — le réglage reste attaché
|
||||
// au modèle, la barre de saisie ne fait que le changer à la volée.
|
||||
func handleReasoningEffort(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
v := strings.ToLower(strings.TrimSpace(req.Value))
|
||||
// Liste blanche : cette clé finit dans une requête au moteur, on n'y
|
||||
// laisse pas passer une chaîne arbitraire venue du navigateur.
|
||||
ok := v == ""
|
||||
for _, allowed := range ReasoningEfforts {
|
||||
if v == allowed {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "valeur inconnue : " + v})
|
||||
return
|
||||
}
|
||||
if err := SetConfigKey("REASONING_EFFORT", v); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"value": reasoningEffortValue(cfg["REASONING_EFFORT"]),
|
||||
"on": reasoningActive(cfg["REASONING"]),
|
||||
"choices": ReasoningEfforts,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMCPCatalog renvoie le catalogue embarqué de serveurs MCP connus.
|
||||
// Lecture seule : rien n'est installé ici, l'UI ne fait que préremplir la modale
|
||||
// d'ajout avec la commande de l'entrée choisie.
|
||||
|
||||
@@ -130,7 +130,11 @@ func handleConvList(w http.ResponseWriter, r *http.Request) {
|
||||
if list == nil {
|
||||
list = []convMeta{}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"conversations": list, "active": active})
|
||||
// `busy` = un tour est en cours SUR LA DISCUSSION ACTIVE (il n'y a qu'une
|
||||
// génération à la fois, sur le fil ouvert). L'UI s'en sert pour animer la
|
||||
// ligne concernée dès le chargement, avant même que le flux SSE ait rejoué
|
||||
// le journal — sinon la page reste muette pendant qu'un agent travaille.
|
||||
sendJSON(w, 200, map[string]any{"conversations": list, "active": active, "busy": conv.isGenerating()})
|
||||
}
|
||||
|
||||
func handleConvNew(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La liste des discussions doit dire LAQUELLE travaille : c'est ce qui fait
|
||||
// tourner l'anneau dans la barre latérale dès le chargement de la page, sans
|
||||
// attendre que le flux SSE ait rejoué son journal.
|
||||
func TestConvListRapporteLaGeneration(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
busy := func() bool {
|
||||
rr := httptest.NewRecorder()
|
||||
handleConvList(rr, httptest.NewRequest("GET", "/api/conversations", nil))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("code %d : %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Active string `json:"active"`
|
||||
Busy bool `json:"busy"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Active == "" {
|
||||
t.Fatal("aucune discussion active dans la liste")
|
||||
}
|
||||
return out.Busy
|
||||
}
|
||||
|
||||
if busy() {
|
||||
t.Fatal("busy=true alors qu'aucune génération ne tourne")
|
||||
}
|
||||
conv.mu.Lock()
|
||||
conv.Generating = true
|
||||
conv.mu.Unlock()
|
||||
defer func() {
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
}()
|
||||
if !busy() {
|
||||
t.Fatal("busy=false alors qu'une génération tourne")
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,8 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/mcp/tool", handleMCPTool)
|
||||
api("/api/mcp/test", handleMCPTest)
|
||||
api("/api/memory", handleMemoryMode)
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
api("/api/sysprompt", handleSysPrompt)
|
||||
// Alias rétro-compat : l'ancien portail ajean.link (dépôt loki-relay) pilote
|
||||
|
||||
Reference in new issue
Block a user