diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js index 2e6dac4b9229..fe4c0ac7d180 100644 --- a/core/http/react-ui/src/hooks/useChat.js +++ b/core/http/react-ui/src/hooks/useChat.js @@ -2,9 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { API_CONFIG } from '../utils/config' import { apiUrl } from '../utils/basePath' -const thinkingTagRegex = /([\s\S]*?)<\/thinking>|([\s\S]*?)<\/think>/g -const openThinkTagRegex = /|/ -const closeThinkTagRegex = /<\/thinking>|<\/think>/ +const thinkingTagRegex = /([\s\S]*?)<\/thinking>|([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)/g +const openThinkTagRegex = /||<\|channel>thought/ +const closeThinkTagRegex = /<\/thinking>|<\/think>|/ async function extractHttpError(response) { let errorMsg = `HTTP ${response.status}` @@ -23,7 +23,7 @@ function extractThinking(text) { thinkingTagRegex.lastIndex = 0 while ((match = thinkingTagRegex.exec(text)) !== null) { regularContent += text.slice(lastIdx, match.index) - thinkingContent += match[1] || match[2] || '' + thinkingContent += match[1] || match[2] || match[3] || '' lastIdx = match.index + match[0].length } regularContent += text.slice(lastIdx) @@ -578,9 +578,9 @@ export function useChat(initialModel = '') { } if (insideThinkTag) { - const lastOpen = Math.max(rawContent.lastIndexOf(''), rawContent.lastIndexOf('')) + const lastOpen = Math.max(rawContent.lastIndexOf(''), rawContent.lastIndexOf(''), rawContent.lastIndexOf('<|channel>thought')) if (lastOpen >= 0) { - const partial = rawContent.slice(lastOpen).replace(/|/, '') + const partial = rawContent.slice(lastOpen).replace(/||<\|channel>thought/, '') setStreamingReasoning(partial) const beforeThink = rawContent.slice(0, lastOpen) const { regularContent: contentBeforeThink } = extractThinking(beforeThink) diff --git a/pkg/reasoning/reasoning.go b/pkg/reasoning/reasoning.go index b61b2ea1db86..72681aaed720 100644 --- a/pkg/reasoning/reasoning.go +++ b/pkg/reasoning/reasoning.go @@ -15,6 +15,7 @@ import ( // - <|inner_prefix|> (Apertus models) // - (Seed models) // - (DeepSeek, Granite, ExaOne models) +// - <|channel>thought (Gemma 4 models) // - <|think|> (Solar Open models) // - (General thinking tag) // - [THINK] (Magistral models) @@ -28,6 +29,7 @@ func DetectThinkingStartToken(prompt string, config *Config) string { "", // Seed models "", // DeepSeek, Granite, ExaOne models "<|think|>", // Solar Open models + "<|channel>thought", // Gemma 4 models "", // General thinking tag "[THINK]", // Magistral models } @@ -146,6 +148,7 @@ func ExtractReasoning(content string, config *Config) (reasoning string, cleaned {"", ""}, // Seed models {"", ""}, // DeepSeek, Granite, ExaOne models {"<|think|>", "<|end|><|begin|>assistant<|content|>"}, // Solar Open models (complex end) + {"<|channel>thought", ""}, // Gemma 4 models {"", ""}, // General thinking tag {"[THINK]", "[/THINK]"}, // Magistral models } diff --git a/pkg/reasoning/reasoning_test.go b/pkg/reasoning/reasoning_test.go index 290576b7e474..9f3675ff6e73 100644 --- a/pkg/reasoning/reasoning_test.go +++ b/pkg/reasoning/reasoning_test.go @@ -317,6 +317,29 @@ var _ = Describe("ExtractReasoning", func() { Expect(cleaned).To(Equal("Before ")) }) }) + + Context("when content has <|channel>thought tags (Gemma 4)", func() { + It("should extract reasoning from channel thought block", func() { + content := "<|channel>thought\nThis is my reasoning\nHello! How can I help?" + reasoning, cleaned := ExtractReasoning(content, nil) + Expect(reasoning).To(Equal("This is my reasoning")) + Expect(cleaned).To(Equal("Hello! How can I help?")) + }) + + It("should handle unclosed channel thought block", func() { + content := "<|channel>thought\nIncomplete reasoning" + reasoning, cleaned := ExtractReasoning(content, nil) + Expect(reasoning).To(Equal("Incomplete reasoning")) + Expect(cleaned).To(Equal("")) + }) + + It("should handle content before and after channel thought block", func() { + content := "Before <|channel>thought\nGemma 4 reasoning\n After" + reasoning, cleaned := ExtractReasoning(content, nil) + Expect(reasoning).To(Equal("Gemma 4 reasoning")) + Expect(cleaned).To(Equal("Before After")) + }) + }) }) var _ = Describe("DetectThinkingStartToken", func() { @@ -339,6 +362,12 @@ var _ = Describe("DetectThinkingStartToken", func() { Expect(token).To(Equal("")) }) + It("should detect <|channel>thought at the end (Gemma 4)", func() { + prompt := "Prompt text <|channel>thought" + token := DetectThinkingStartToken(prompt, nil) + Expect(token).To(Equal("<|channel>thought")) + }) + It("should detect <|inner_prefix|> at the end", func() { prompt := "Prompt <|inner_prefix|>" token := DetectThinkingStartToken(prompt, nil) @@ -817,6 +846,14 @@ var _ = Describe("ExtractReasoningWithConfig", func() { Expect(cleaned).To(Equal("Text More")) }) + It("should strip reasoning from Gemma 4 channel tags when StripReasoningOnly is true", func() { + content := "<|channel>thought\nGemma 4 reasoning\nResponse text" + config := Config{StripReasoningOnly: boolPtr(true)} + reasoning, cleaned := ExtractReasoningWithConfig(content, "<|channel>thought", config) + Expect(reasoning).To(BeEmpty()) + Expect(cleaned).To(Equal("Response text")) + }) + It("should strip reasoning with multiline content when StripReasoningOnly is true", func() { content := "Start Line 1\nLine 2\nLine 3 End" config := Config{StripReasoningOnly: boolPtr(true)}