v0.4.12 : fix reasoning Laguna S 2.1 (reponse qui partait dans le thinking)

Les modeles hybrides (Laguna S 2.1) qui repondent SANS reflechir n envoyaient
que `content` (ni reasoning_content, ni </think>, retire par le backend a
--reasoning-format). jean, avec REASONING=on, supposait un bloc <think> ouvert
par le prompt et vidait toute la reponse dans la bulle thinking. On streame
desormais le contenu en direct par defaut et on ne bascule en reasoning qu au
vu d un </think> reel. La coupe de la queue anti-</think> est realignee sur une
frontiere de rune UTF-8 pour ne plus casser les accents (e -> mojibake).
This commit is contained in:
nathaninline committed 2026-07-23 11:49:40 +02:00
1 parent ef5b036d99
commit ee76638f15
5 files changed
+31 -15

No files matched your search

Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": { "FileVersion": {
"Major": 0, "Major": 0,
"Minor": 4, "Minor": 4,
"Patch": 7, "Patch": 12,
"Build": 0 "Build": 0
}, },
"ProductVersion": { "ProductVersion": {
"Major": 0, "Major": 0,
"Minor": 4, "Minor": 4,
"Patch": 7, "Patch": 12,
"Build": 0 "Build": 0
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe", "OriginalFilename": "jean.exe",
"ProductName": "Jean", "ProductName": "Jean",
"ProductVersion": "0.4.7", "ProductVersion": "0.4.12",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
}, },
"VarFileInfo": { "VarFileInfo": {
+27 -11
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"unicode/utf8"
) )
// Message is one entry in the chat history sent to llama.cpp. // Message is one entry in the chat history sent to llama.cpp.
@@ -574,12 +575,21 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
break break
} }
} else { } else {
// Inside the prompt-opened <think> block: stream to the // The prompt opened a <think> block. Stream `content` LIVE as
// reasoning bubble until the closing </think>, then switch // the answer, holding back only a short tail that could be the
// the remainder to normal content. // start of a literal "</think>". A reasoning-aware backend
// (llama.cpp with --reasoning-format, Nathan's fork) strips the
// think tags server-side, so </think> never appears in content
// and the whole answer streams straight through — including
// when the model answers WITHOUT thinking (no reasoning_content,
// no </think>), which is exactly what used to get dumped into
// the reasoning bubble. A vanilla build that leaves the thinking
// inline still gets carved at the </think> below.
thinkTail.WriteString(ch.Delta.Content) thinkTail.WriteString(ch.Delta.Content)
s := thinkTail.String() s := thinkTail.String()
if i := strings.Index(s, thinkClose); i >= 0 { if i := strings.Index(s, thinkClose); i >= 0 {
// Vanilla inline think: reasoning before </think>, answer
// after. Route the reasoning to its bubble, drop the tag.
reason := s[:i] reason := s[:i]
after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n") after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n")
thinkOpen = false thinkOpen = false
@@ -593,13 +603,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
break break
} }
} else { } else {
// Hold back a tail that could be a partial "</think>". // No </think> yet: stream as content, holding back a tail
keep := len(thinkClose) - 1 // that could be a partial "</think>". Back the cut up to a
if len(s) > keep { // UTF-8 rune boundary so a multi-byte char (é, …) is never
emit := s[:len(s)-keep] // split — otherwise the two halves decode as � (mojibake).
cut := len(s) - (len(thinkClose) - 1)
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
if cut > 0 {
emit := s[:cut]
thinkTail.Reset() thinkTail.Reset()
thinkTail.WriteString(s[len(s)-keep:]) thinkTail.WriteString(s[cut:])
if !cb(StreamEvent{Reasoning: emit}) { if !cb(StreamEvent{Content: emit}) {
aborted = true aborted = true
break break
} }
@@ -608,9 +624,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
} }
} }
} }
// Flush any buffered reasoning if the stream ended mid-think. // Flush the held-back tail (never part of a </think>): it's answer text.
if !aborted && thinkOpen && thinkTail.Len() > 0 { if !aborted && thinkOpen && thinkTail.Len() > 0 {
cb(StreamEvent{Reasoning: thinkTail.String()}) cb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")})
} }
resp.Body.Close() resp.Body.Close()
if aborted { if aborted {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
const Version = "0.4.11" const Version = "0.4.12"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() { func Main() {