diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 4f18a634ce545..94a510cedfe35 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/constants/thinking-formats.ts b/tools/server/webui/src/lib/constants/thinking-formats.ts new file mode 100644 index 0000000000000..952445248f0fc --- /dev/null +++ b/tools/server/webui/src/lib/constants/thinking-formats.ts @@ -0,0 +1,20 @@ +export const THINKING_FORMATS = [ + { + name: 'html', + startTag: '', + endTag: '', + regex: /([\s\S]*?)<\/think>/ + }, + { + name: 'bracket', + startTag: '[THINK]', + endTag: '[/THINK]', + regex: /\[THINK\]([\s\S]*?)\[\/THINK\]/ + }, + { + name: 'pipe', + startTag: '◁think▷', + endTag: '◁/think▷', + regex: /◁think▷([\s\S]*?)◁\/think▷/ + } +]; diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 369cdf4e8b935..a5962fcb269c5 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -261,6 +261,7 @@ export class ChatService { let regularContent = ''; let insideThinkTag = false; let hasReceivedData = false; + let hasSeenReasoningField = false; let lastTimings: ChatMessageTimings | undefined; try { @@ -314,13 +315,17 @@ export class ChatService { // Track the regular content before processing this chunk const regularContentBefore = regularContent; + const reasoningContentBefore = fullReasoningContent; + const shouldCaptureInlineThinking = !hasSeenReasoningField && !reasoningContent; // Process content character by character to handle think tags insideThinkTag = this.processContentForThinkTags( content, insideThinkTag, - () => { - // Think content is ignored - we don't include it in API requests + (thinkChunk) => { + if (shouldCaptureInlineThinking) { + fullReasoningContent += thinkChunk; + } }, (regularChunk) => { regularContent += regularChunk; @@ -331,9 +336,20 @@ export class ChatService { if (newRegularContent) { onChunk?.(newRegularContent); } + + if (shouldCaptureInlineThinking) { + const newReasoningContent = fullReasoningContent.slice( + reasoningContentBefore.length + ); + + if (newReasoningContent) { + onReasoningChunk?.(newReasoningContent); + } + } } if (reasoningContent) { + hasSeenReasoningField = true; hasReceivedData = true; fullReasoningContent += reasoningContent; onReasoningChunk?.(reasoningContent); diff --git a/tools/server/webui/src/lib/utils/thinking.ts b/tools/server/webui/src/lib/utils/thinking.ts index bed13fcecf159..4bab7bd0d5ed2 100644 --- a/tools/server/webui/src/lib/utils/thinking.ts +++ b/tools/server/webui/src/lib/utils/thinking.ts @@ -1,8 +1,10 @@ +import { THINKING_FORMATS } from '$lib/constants/thinking-formats'; + /** - * Parses thinking content from a message that may contain tags or [THINK] tags + * Parses thinking content from a message that may contain thinking tags * Returns an object with thinking content and cleaned message content * Handles both complete blocks and incomplete blocks (streaming) - * Supports formats: ... and [THINK]...[/THINK] + * Supports formats: ..., [THINK]...[/THINK], and ◁think▷...◁/think▷ * @param content - The message content to parse * @returns An object containing the extracted thinking content and the cleaned message content */ @@ -10,53 +12,57 @@ export function parseThinkingContent(content: string): { thinking: string | null; cleanContent: string; } { - const incompleteThinkMatch = content.includes('') && !content.includes(''); - const incompleteThinkBracketMatch = content.includes('[THINK]') && !content.includes('[/THINK]'); + const buildCleanContent = (before: string, after: string): string => { + const trimmedBefore = before.replace(/[ \t]+$/, ''); + const trimmedAfter = after.replace(/^[ \t]+/, ''); - if (incompleteThinkMatch) { - const cleanContent = content.split('')?.[1]?.trim(); - const thinkingContent = content.split('')?.[1]?.trim(); + if (trimmedBefore && trimmedAfter) { + const needsSeparator = !/\n\s*$/.test(trimmedBefore) && !/^\s*\n/.test(trimmedAfter); + const separator = needsSeparator ? '\n\n' : ''; - return { - cleanContent, - thinking: thinkingContent - }; - } + return `${trimmedBefore}${separator}${trimmedAfter}`; + } - if (incompleteThinkBracketMatch) { - const cleanContent = content.split('[/THINK]')?.[1]?.trim(); - const thinkingContent = content.split('[THINK]')?.[1]?.trim(); + return trimmedBefore || trimmedAfter; + }; - return { - cleanContent, - thinking: thinkingContent - }; + // Check for incomplete blocks (streaming case) + for (const format of THINKING_FORMATS) { + const startIndex = content.indexOf(format.startTag); + const endIndex = content.indexOf(format.endTag, startIndex + format.startTag.length); + + if (startIndex !== -1 && endIndex === -1) { + const before = content.slice(0, startIndex); + const thinkingContent = content.slice(startIndex + format.startTag.length).trim(); + + return { + cleanContent: buildCleanContent(before, ''), + thinking: thinkingContent || null + }; + } } - const completeThinkMatch = content.match(/([\s\S]*?)<\/think>/); - const completeThinkBracketMatch = content.match(/\[THINK\]([\s\S]*?)\[\/THINK\]/); + // Check for complete blocks + for (const format of THINKING_FORMATS) { + const startIndex = content.indexOf(format.startTag); - if (completeThinkMatch) { - const thinkingContent = completeThinkMatch[1]?.trim() ?? ''; - const cleanContent = `${content.slice(0, completeThinkMatch.index ?? 0)}${content.slice( - (completeThinkMatch.index ?? 0) + completeThinkMatch[0].length - )}`.trim(); + if (startIndex === -1) { + continue; + } - return { - thinking: thinkingContent, - cleanContent - }; - } + const endIndex = content.indexOf(format.endTag, startIndex + format.startTag.length); + + if (endIndex === -1) { + continue; + } - if (completeThinkBracketMatch) { - const thinkingContent = completeThinkBracketMatch[1]?.trim() ?? ''; - const cleanContent = `${content.slice(0, completeThinkBracketMatch.index ?? 0)}${content.slice( - (completeThinkBracketMatch.index ?? 0) + completeThinkBracketMatch[0].length - )}`.trim(); + const before = content.slice(0, startIndex); + const thinkingContent = content.slice(startIndex + format.startTag.length, endIndex).trim(); + const after = content.slice(endIndex + format.endTag.length); return { - thinking: thinkingContent, - cleanContent + thinking: thinkingContent || null, + cleanContent: buildCleanContent(before, after) }; } @@ -68,31 +74,24 @@ export function parseThinkingContent(content: string): { /** * Checks if content contains an opening thinking tag (for streaming) - * Supports both and [THINK] formats * @param content - The message content to check * @returns True if the content contains an opening thinking tag */ export function hasThinkingStart(content: string): boolean { - return ( - content.includes('') || - content.includes('[THINK]') || - content.includes('<|channel|>analysis') - ); + return THINKING_FORMATS.some((format) => content.includes(format.startTag)); } /** * Checks if content contains a closing thinking tag (for streaming) - * Supports both and [/THINK] formats * @param content - The message content to check * @returns True if the content contains a closing thinking tag */ export function hasThinkingEnd(content: string): boolean { - return content.includes('') || content.includes('[/THINK]'); + return THINKING_FORMATS.some((format) => content.includes(format.endTag)); } /** * Extracts partial thinking content during streaming - * Supports both and [THINK] formats * Used when we have opening tag but not yet closing tag * @param content - The message content to extract partial thinking from * @returns An object containing the extracted partial thinking content and the remaining content @@ -101,39 +100,32 @@ export function extractPartialThinking(content: string): { thinking: string | null; remainingContent: string; } { - const thinkStartIndex = content.indexOf(''); - const thinkEndIndex = content.indexOf(''); + // Find all format positions and determine which appears first + const formatPositions = THINKING_FORMATS.map((format) => ({ + ...format, + startIndex: content.indexOf(format.startTag), + endIndex: content.indexOf(format.endTag) + })) + .filter((format) => format.startIndex !== -1) + .sort((a, b) => a.startIndex - b.startIndex); - const bracketStartIndex = content.indexOf('[THINK]'); - const bracketEndIndex = content.indexOf('[/THINK]'); + const firstFormat = formatPositions[0]; - const useThinkFormat = - thinkStartIndex !== -1 && (bracketStartIndex === -1 || thinkStartIndex < bracketStartIndex); - const useBracketFormat = - bracketStartIndex !== -1 && (thinkStartIndex === -1 || bracketStartIndex < thinkStartIndex); + if (firstFormat && firstFormat.endIndex === -1) { + // We have an opening tag but no closing tag (streaming case) + const thinkingStart = firstFormat.startIndex + firstFormat.startTag.length; - if (useThinkFormat) { - if (thinkEndIndex === -1) { - const thinkingStart = thinkStartIndex + ''.length; - - return { - thinking: content.substring(thinkingStart), - remainingContent: content.substring(0, thinkStartIndex) - }; - } - } else if (useBracketFormat) { - if (bracketEndIndex === -1) { - const thinkingStart = bracketStartIndex + '[THINK]'.length; + return { + thinking: content.substring(thinkingStart), + remainingContent: content.substring(0, firstFormat.startIndex) + }; + } - return { - thinking: content.substring(thinkingStart), - remainingContent: content.substring(0, bracketStartIndex) - }; - } - } else { + if (!firstFormat) { return { thinking: null, remainingContent: content }; } + // If we have both start and end tags, use the main parsing function const parsed = parseThinkingContent(content); return { diff --git a/tools/server/webui/src/stories/ChatMessage.stories.svelte b/tools/server/webui/src/stories/ChatMessage.stories.svelte index c6377e23cb6fd..5b3876bd25d62 100644 --- a/tools/server/webui/src/stories/ChatMessage.stories.svelte +++ b/tools/server/webui/src/stories/ChatMessage.stories.svelte @@ -88,9 +88,23 @@ children: [] }; + // Message with ◁think▷ format thinking content + const thinkPipeMessage: DatabaseMessage = { + id: '8', + convId: 'conv-1', + type: 'message', + timestamp: Date.now() - 1000 * 30, + role: 'assistant', + content: + "◁think▷\nThis demonstrates the pipe-style thinking format:\n\n- Uses ◁think▷ and ◁/think◁ tags\n- Common in some model architectures\n- Should parse identically to other formats\n- Provides the same separation of reasoning and response\n\nAll three formats offer equivalent functionality.\n◁/think▷\n\nHere's my response using the ◁think▷ format. The reasoning content above should be extracted and displayed in the thinking section, while this main content appears in the regular message area.", + parent: '1', + thinking: '', + children: [] + }; + // Streaming message for format let streamingThinkMessage = $state({ - id: '8', + id: '9', convId: 'conv-1', type: 'message', timestamp: 0, // No timestamp = streaming @@ -103,7 +117,20 @@ // Streaming message for [THINK] format let streamingBracketMessage = $state({ - id: '9', + id: '10', + convId: 'conv-1', + type: 'message', + timestamp: 0, // No timestamp = streaming + role: 'assistant', + content: '', + parent: '1', + thinking: '', + children: [] + }); + + // Streaming message for ◁think▷ format + let streamingPipeMessage = $state({ + id: '11', convId: 'conv-1', type: 'message', timestamp: 0, // No timestamp = streaming @@ -215,6 +242,14 @@ }} /> + + { // Phase 1: Stream [THINK] reasoning content const thinkingContent = - 'Using the DeepSeek format now:\n\n- This demonstrates the [THINK] bracket format\n- Should parse identically to <think> tags\n- The UI should display this in the thinking section\n- Main content should be separate\n\nBoth formats provide the same functionality.'; + 'Using the DeepSeek format now:\n\n- This demonstrates the [THINK] bracket format\n- Should parse identically to <think> tags\n- The UI should display this in the thinking section\n- Main content should be separate\n\nAll three formats provide the same functionality.'; let currentContent = '[THINK]\n'; streamingBracketMessage.content = currentContent; @@ -295,7 +330,7 @@ // Phase 2: Stream main response content const responseContent = - "Here's my response after using the [THINK] format:\n\n**Observations:**\n- Both <think> and [THINK] formats work seamlessly\n- The parsing logic handles both cases\n- UI display is consistent across formats\n\nThis demonstrates the enhanced thinking content support."; + "Here's my response after using the [THINK] format:\n\n**Observations:**\n- All three formats (<think>, [THINK], <|think|>) work seamlessly\n- The parsing logic handles all cases\n- UI display is consistent across formats\n\nThis demonstrates comprehensive thinking content support."; for (let i = 0; i < responseContent.length; i++) { currentContent += responseContent[i]; @@ -310,3 +345,51 @@ + + { + // Phase 1: Stream ◁think▷ reasoning content + const thinkingContent = + 'Demonstrating the pipe format thinking:\n\n- This uses the ◁think▷ and ◁/think▷ tag structure\n- Should integrate seamlessly with existing parsing logic\n- UI display should be identical to other formats\n- All three formats provide equivalent functionality\n\nThe pipe format adds another option for reasoning content.'; + + let currentContent = '◁think▷\n'; + streamingPipeMessage.content = currentContent; + + for (let i = 0; i < thinkingContent.length; i++) { + currentContent += thinkingContent[i]; + streamingPipeMessage.content = currentContent; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + // Close the thinking block + currentContent += '\n◁/think▷\n\n'; + streamingPipeMessage.content = currentContent; + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Phase 2: Stream main response content + const responseContent = + "Here's the response after using ◁think▷ format:\n\n**Key Features:**\n- All three thinking formats (<think>, [THINK], <|think|>) work identically\n- Parsing logic handles each format seamlessly\n- UI presentation is consistent across all formats\n\nThis demonstrates comprehensive thinking content support."; + + for (let i = 0; i < responseContent.length; i++) { + currentContent += responseContent[i]; + streamingPipeMessage.content = currentContent; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + streamingPipeMessage.timestamp = Date.now(); + }} +> +
+ +
+