From 175bbd34d972370ffe053a08c9192b2885107154 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 26 Jun 2026 10:58:27 +0200 Subject: [PATCH] =?UTF-8?q?v0.2.9=20:=20UI=20web=20=E2=80=94=20reasoning/a?= =?UTF-8?q?ppels=20d'outil=20se=20replient=20au=20fil=20de=20la=20chaine?= =?UTF-8?q?=20+=20barre=20de=20contexte=20integree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bulles reasoning/outil repliables (clic), repli fluide en diagonale (height+width animes en JS) - chainage : chaque etape se replie des que la suivante demarre, la reponse finale replie la derniere - compteur de tokens sur chaque bulle (reasoning : gen tok/s ; outil : ~taille reponse) - bulles d'outil au meme design pointille transparent que le reasoning - contexte : comptage exact via usage.prompt_tokens (system prompt inclus meme en cache KV) - barre de contexte redessinee, integree au composer de saisie --- llm.go | 58 ++++++++++++++----- main.go | 2 +- resource_windows_amd64.syso | Bin 1188 -> 1188 bytes resource_windows_arm64.syso | Bin 1188 -> 1188 bytes ui/index.html | 110 +++++++++++++++++++++++++++++------- versioninfo.json | 6 +- 6 files changed, 137 insertions(+), 39 deletions(-) diff --git a/llm.go b/llm.go index 6dd19d2..25165a8 100644 --- a/llm.go +++ b/llm.go @@ -205,12 +205,15 @@ func previewArg(args, key string) string { // StatsEvent carries llama.cpp's per-completion timing (final chunk). type StatsEvent struct { - PromptTokens int `json:"prompt_tokens"` - PromptPerSecond float64 `json:"prompt_per_second"` - PromptMs float64 `json:"prompt_ms"` - GenTokens int `json:"gen_tokens"` - GenPerSecond float64 `json:"gen_per_second"` - GenMs float64 `json:"gen_ms"` + PromptTokens int `json:"prompt_tokens,omitempty"` + PromptPerSecond float64 `json:"prompt_per_second,omitempty"` + PromptMs float64 `json:"prompt_ms,omitempty"` + GenTokens int `json:"gen_tokens,omitempty"` + GenPerSecond float64 `json:"gen_per_second,omitempty"` + GenMs float64 `json:"gen_ms,omitempty"` + // Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de + // `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage. + PromptTokensTotal int `json:"prompt_tokens_total,omitempty"` } // ChatCallback receives stream events. Return false to abort the stream. @@ -237,6 +240,12 @@ type streamChunk struct { PredictedMs float64 `json:"predicted_ms"` PredictedPerSec float64 `json:"predicted_per_second"` } `json:"timings"` + // Chunk final (include_usage) : taille totale du prompt, hors choices. + Usage *struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` } // runChat drives the full inference loop including tool calling. @@ -274,6 +283,11 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps "messages": messages, "stream": true, "temperature": temperature, + // include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE + // du prompt (préfixe caché compris), contrairement à timings.prompt_n qui + // ne compte que les tokens nouvellement traités. Sert au comptage exact du + // contexte (sinon le system prompt déjà en cache n'est pas recompté). + "stream_options": map[string]any{"include_usage": true}, } if len(tools) > 0 && !disableTools { payload["tools"] = tools @@ -325,6 +339,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps toolCalls := map[int]*ToolCall{} assistantContent := strings.Builder{} finishReason := "" + // Accumulateur de stats : timings (prefill/decode) puis usage (total prompt) + // arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ + // pour que les consommateurs (terminal, web) aient toujours tout. + var stats StatsEvent lastPreview := "" // last command preview emitted (to stream the typing) // Per-completion reasoning-split state (see reasoningOn comment above). sawReasoningField := false @@ -344,7 +362,17 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps continue } var chunk streamChunk - if err := json.Unmarshal([]byte(data), &chunk); err != nil || len(chunk.Choices) == 0 { + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + // Le chunk d'usage (include_usage) arrive seul, sans choices : on l'émet + // avant le guard pour ne pas le perdre. + if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 { + stats.PromptTokensTotal = chunk.Usage.PromptTokens + s := stats + cb(StreamEvent{Stats: &s}) + } + if len(chunk.Choices) == 0 { continue } ch := chunk.Choices[0] @@ -352,14 +380,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps finishReason = ch.FinishReason } if chunk.Timings != nil { - cb(StreamEvent{Stats: &StatsEvent{ - PromptTokens: chunk.Timings.PromptN, - PromptPerSecond: chunk.Timings.PromptPerSecond, - PromptMs: chunk.Timings.PromptMs, - GenTokens: chunk.Timings.PredictedN, - GenPerSecond: chunk.Timings.PredictedPerSec, - GenMs: chunk.Timings.PredictedMs, - }}) + stats.PromptTokens = chunk.Timings.PromptN + stats.PromptPerSecond = chunk.Timings.PromptPerSecond + stats.PromptMs = chunk.Timings.PromptMs + stats.GenTokens = chunk.Timings.PredictedN + stats.GenPerSecond = chunk.Timings.PredictedPerSec + stats.GenMs = chunk.Timings.PredictedMs + s := stats + cb(StreamEvent{Stats: &s}) } if len(ch.Delta.ToolCalls) > 0 { for i, tc := range ch.Delta.ToolCalls { diff --git a/main.go b/main.go index 11c966a..c5881d8 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( "strings" ) -const Version = "0.2.8" +const Version = "0.2.9" func main() { args := os.Args[1:] diff --git a/resource_windows_amd64.syso b/resource_windows_amd64.syso index 24282bf66b8b9c1a913585e5d2180ba60b106a20..0aa9c3323ccf2a294b1d043da59b24290ab04f5a 100644 GIT binary patch delta 33 mcmZ3&xrB4VDNarXCI%4T+;~}#iP3WNQ>I`>M$5^TEZG2w3I`>M)S#*EZG2vk_Y|( diff --git a/resource_windows_arm64.syso b/resource_windows_arm64.syso index 7697194a57d4e78897167c005f6f9dfe3338b5cd..1aff982c31402867322d9ee6163c37cb2cc5473c 100644 GIT binary patch delta 33 mcmZ3&xrB4VDNarXCI%4T+;~}#iP3WNQ>I`>M$5^TEZG2w3I`>M)S#*EZG2vk_Y|( diff --git a/ui/index.html b/ui/index.html index a7ead42..bcfd43c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -97,7 +97,7 @@ button:disabled{opacity:.5;cursor:not-allowed} #scrollbtn{position:absolute;bottom:80px;right:20px;background:var(--panel);border:1px solid var(--border);color:var(--accent);width:36px;height:36px;border-radius:50%;cursor:pointer;display:none;font-size:16px;z-index:5} #scrollbtn.show{display:block} .msg.reasoning{align-self:flex-start;background:transparent;border:1px dashed var(--border);color:var(--mag);font-size:12px;max-width:90%;overflow-wrap:anywhere;word-break:break-word} -.msg.tool{align-self:flex-start;background:var(--panel);border:1px solid var(--border);max-width:100%;white-space:normal;padding:8px 12px} +.msg.tool{align-self:flex-start;background:transparent;border:1px dashed var(--border);max-width:90%;white-space:normal;padding:8px 12px;font-size:12px;overflow-wrap:anywhere;word-break:break-word} .msg.tool .tool-head{font-size:12px;color:var(--accent);font-weight:600;margin-bottom:6px} .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:#0d1117;border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px} @@ -106,7 +106,28 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite} @keyframes toolblink{0%,100%{opacity:.45}50%{opacity:1}} .msg .label{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin-bottom:4px;display:block} -#inputbar{border-top:1px solid var(--border);padding:12px;display:flex;gap:8px} +/* Bulles repliables (reasoning / tool) : une fois la réponse arrivée, on les + replie en douceur pour désencombrer le fil, tout en gardant le label (et son + compteur de tokens) cliquable pour redéplier. */ +.msg.collapsible .label{cursor:pointer;user-select:none;margin-bottom:0} +/* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui + peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli. + width revient à auto SANS délai à l'ouverture (pas de saut), et passe à 0 + APRÈS le fold (width 0s .3s) à la fermeture pour rétrécir la bulle au label. */ +/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth) + → repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */ +.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px} +.msg.collapsible .bodywrap>.body{min-width:0} +.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0} +/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine + ligne de progression pleine largeur affleurant le bord) + zone de saisie. */ +#composer{border-top:1px solid var(--border);background:var(--panel-2,rgba(255,255,255,.02))} +#ctxtrack{height:3px;width:100%;background:var(--panel);overflow:hidden} +#ctx-fill{height:100%;width:0%;background:var(--ok,#3a7);transition:width .3s,background .3s} +#ctxmeta{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:5px 12px 0} +#ctx-text{font-size:10px;letter-spacing:.03em;white-space:nowrap} +#ctx-compact{display:none;margin:0;padding:2px 10px;font-size:10px;background:#3d2f1f;border-color:var(--warn,#c93)} +#inputbar{padding:8px 12px;display:flex;gap:8px} #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:44px;max-height:200px} #input:focus{outline:none;border-color:var(--accent)} #send{padding:0 20px} @@ -164,19 +185,19 @@ button:disabled{opacity:.5;cursor:not-allowed}
-
-
-
+
+
+
+ contexte 0 / 0 +
- contexte 0 / 0 - +
+ + + +
+
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
-
- - - -
-
Entrée pour envoyer · Maj+Entrée = nouvelle ligne