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();
+ }}
+>
+
+
+
+