fix: normalize Gemma 4 thought-channel output (#2224)
This commit is contained in:
@@ -1120,7 +1120,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
let _measureDiv = null;
|
||||
|
||||
function _replyAfterClosedThinking(text) {
|
||||
const closeRe = /<\/think(?:ing)?>/gi;
|
||||
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
|
||||
let match = null;
|
||||
let last = null;
|
||||
while ((match = closeRe.exec(text || '')) !== null) last = match;
|
||||
@@ -1147,7 +1147,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
replyTrimmed = (replyText || '').trim();
|
||||
} else {
|
||||
// Non-tag: check for garbled <think> (reasoning\n<think>reply)
|
||||
const _gm = dt.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
|
||||
const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
|
||||
if (_gm && _gm[1].trim()) {
|
||||
replyTrimmed = _gm[1].trim();
|
||||
} else {
|
||||
@@ -1188,8 +1188,11 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const prevLen = contentEl._prevTextLen || 0;
|
||||
// If thinking is still streaming (unclosed <think>), show indicator instead of raw text
|
||||
if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
|
||||
const thinkStart = dt.search(/<think(?:ing)?>/i);
|
||||
const thinkContent = dt.substring(thinkStart).replace(/<think(?:ing)?>/i, '').trim();
|
||||
const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
|
||||
const thinkContent = dt.substring(Math.max(thinkStart, 0))
|
||||
.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '')
|
||||
.replace(/<channel\|>/gi, '')
|
||||
.trim();
|
||||
const lines = thinkContent.split('\n').length;
|
||||
// Don't show beforeThink text during streaming — it'll appear in the final render
|
||||
// This prevents the "split into two" duplication
|
||||
@@ -1449,7 +1452,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
|
||||
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
|
||||
const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
|
||||
if (!hasUnclosedThink && !roundText.includes('<think')) {
|
||||
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
|
||||
const _trimmedRT = roundText.trimStart();
|
||||
const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
|
||||
if (_isReasoning) {
|
||||
@@ -1475,10 +1478,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUnclosedThink && /^<think(?:ing)?>\s*<\/think(?:ing)?>/i.test(roundText)) {
|
||||
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
|
||||
// Empty <think></think> — the model likely put thinking outside the tags
|
||||
const afterEmpty = roundText.replace(/^<think(?:ing)?>\s*<\/think(?:ing)?>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/think(?:ing)?>/gi) || []).length;
|
||||
const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
|
||||
if (closeTags === 0 && afterEmpty.length > 0) {
|
||||
hasUnclosedThink = true; // still waiting for real closing tag
|
||||
}
|
||||
@@ -1487,13 +1490,13 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// Only applies when there's a second </think> later (model leaked thinking outside tags)
|
||||
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
|
||||
if (!hasUnclosedThink && isThinking) {
|
||||
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
|
||||
const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
|
||||
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
|
||||
if (_thinkLen < 20) {
|
||||
const _afterClose = roundText.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i, '').trim();
|
||||
const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
|
||||
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/think(?:ing)?>/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
|
||||
if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
|
||||
hasUnclosedThink = true; // keep waiting for real </think>
|
||||
}
|
||||
@@ -1550,8 +1553,12 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
} else if (hasUnclosedThink && isThinking) {
|
||||
if (_liveThinkInner) {
|
||||
// Extract raw thinking text (strip all <think>/<thinking> open/close tags and prefixes)
|
||||
var thinkText = roundText.replace(/<\/?think(?:ing)?>/gi, '');
|
||||
// Extract raw thinking text (strip known thinking wrappers and prefixes)
|
||||
var thinkText = roundText
|
||||
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\|channel>thought\s*\n?/gi, '')
|
||||
.replace(/<\|channel>response\s*\n?/gi, '')
|
||||
.replace(/<channel\|>/gi, '');
|
||||
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
||||
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
||||
// Keep thinking box scrolled to bottom
|
||||
@@ -2402,8 +2409,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
_finalReply = (_extracted.content || '').trim();
|
||||
} else {
|
||||
// Non-tag thinking: extract reply from raw text
|
||||
// Handle garbled <think> tag: "Thinking: reasoning\n<think>reply"
|
||||
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
|
||||
// Handle garbled thinking tag: "Thinking: reasoning\n<think>reply"
|
||||
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
|
||||
if (_garbledMatch && _garbledMatch[1].trim()) {
|
||||
_finalReply = _garbledMatch[1].trim();
|
||||
} else {
|
||||
@@ -2452,8 +2459,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
|
||||
} else if (roundHolder !== holder) {
|
||||
// Check if there's thinking content worth showing
|
||||
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
|
||||
if (_thinkMatch && _thinkMatch[1].trim()) {
|
||||
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
|
||||
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
|
||||
// Show thinking in a collapsed section even if no visible reply text
|
||||
const _body4c = roundHolder.querySelector('.body');
|
||||
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
|
||||
@@ -4534,9 +4541,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// never closes (so it would otherwise hide the whole answer). Peel all of
|
||||
// those off so what's left is just the rewritten text.
|
||||
const _stripThink = (t) => {
|
||||
t = t.replace(/<think>[\s\S]*?<\/think>/gi, ''); // complete blocks
|
||||
if (/<\/think>/i.test(t)) t = t.replace(/^[\s\S]*?<\/think>/i, ''); // reasoning w/o opener
|
||||
return t.replace(/<\/?think>/gi, '').trim(); // any orphan tag
|
||||
t = markdownModule.normalizeThinkingMarkup(t || '');
|
||||
t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks
|
||||
if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener
|
||||
return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag
|
||||
};
|
||||
newText = _stripThink(newText);
|
||||
|
||||
|
||||
@@ -116,8 +116,13 @@ function sanitizeAllowedHtml(html) {
|
||||
* Check if text has unclosed think tag
|
||||
*/
|
||||
export function hasUnclosedThinkTag(text) {
|
||||
const openCount = (text.match(/<think(?:ing)?>/gi) || []).length;
|
||||
const closeCount = (text.match(/<\/think(?:ing)?>/gi) || []).length;
|
||||
text = text || '';
|
||||
const openCount =
|
||||
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
|
||||
+ (text.match(/<\|channel>thought/gi) || []).length;
|
||||
const closeCount =
|
||||
(text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length
|
||||
+ (text.match(/<channel\|>/gi) || []).length;
|
||||
return openCount > closeCount;
|
||||
}
|
||||
|
||||
@@ -125,8 +130,25 @@ export function startsWithReasoningPrefix(text) {
|
||||
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
|
||||
}
|
||||
|
||||
export function normalizeThinkingMarkup(text) {
|
||||
if (!text) return text;
|
||||
let normalized = text;
|
||||
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
|
||||
normalized = normalized.replace(/<\/thought>/gi, '</think>');
|
||||
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
|
||||
const thought = String(content || '').trim();
|
||||
return thought ? `<think>${thought}</think>\n` : '';
|
||||
});
|
||||
normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || '');
|
||||
normalized = normalized.replace(/<\|channel>response\s*\n?/gi, '');
|
||||
normalized = normalized.replace(/<channel\|>/gi, '');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePlainThinking(text) {
|
||||
if (!text || /<think/i.test(text)) return text;
|
||||
if (!text) return text;
|
||||
text = normalizeThinkingMarkup(text);
|
||||
if (/<think/i.test(text)) return text;
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
if (!startsWithReasoningPrefix(trimmed)) return text;
|
||||
@@ -220,11 +242,21 @@ export function extractThinkingBlocks(text) {
|
||||
// (b) Cut-off mid-generation — there's already real reply text before the
|
||||
// opener. Drop from the tag onward as before (it's truncated thinking).
|
||||
if (hasUnclosedThinkTag(normalized)) {
|
||||
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
||||
if (strayOpener) {
|
||||
cleanContent = strayOpener[1];
|
||||
const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i);
|
||||
if (gemmaThoughtStart >= 0) {
|
||||
const leakedThought = cleanContent
|
||||
.slice(gemmaThoughtStart)
|
||||
.replace(/^<\|channel>thought\s*\n?/i, '')
|
||||
.trim();
|
||||
if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought);
|
||||
cleanContent = cleanContent.slice(0, gemmaThoughtStart);
|
||||
} else {
|
||||
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
||||
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
||||
if (strayOpener) {
|
||||
cleanContent = strayOpener[1];
|
||||
} else {
|
||||
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,6 +718,7 @@ const markdownModule = {
|
||||
createCollapsible,
|
||||
hasUnclosedThinkTag,
|
||||
extractThinkingBlocks,
|
||||
normalizeThinkingMarkup,
|
||||
startsWithReasoningPrefix,
|
||||
renderMermaid
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user