Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions tools/server/webui/src/lib/constants/thinking-formats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const THINKING_FORMATS = [
{
name: 'html',
startTag: '<think>',
endTag: '</think>',
regex: /<think>([\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▷/
}
];
126 changes: 50 additions & 76 deletions tools/server/webui/src/lib/utils/thinking.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,48 @@
import { THINKING_FORMATS } from '$lib/constants/thinking-formats';

/**
* Parses thinking content from a message that may contain <think> 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: <think>...</think> and [THINK]...[/THINK]
* Supports formats: <think>...</think>, [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
*/
export function parseThinkingContent(content: string): {
thinking: string | null;
cleanContent: string;
} {
const incompleteThinkMatch = content.includes('<think>') && !content.includes('</think>');
const incompleteThinkBracketMatch = content.includes('[THINK]') && !content.includes('[/THINK]');

if (incompleteThinkMatch) {
const cleanContent = content.split('</think>')?.[1]?.trim();
const thinkingContent = content.split('<think>')?.[1]?.trim();

return {
cleanContent,
thinking: thinkingContent
};
}
// Check for incomplete blocks (streaming case)
for (const format of THINKING_FORMATS) {
const hasStart = content.includes(format.startTag);
const hasEnd = content.includes(format.endTag);

if (incompleteThinkBracketMatch) {
const cleanContent = content.split('[/THINK]')?.[1]?.trim();
const thinkingContent = content.split('[THINK]')?.[1]?.trim();
if (hasStart && !hasEnd) {
const cleanContent = content.split(format.endTag)?.[1]?.trim();
const thinkingContent = content.split(format.startTag)?.[1]?.trim();

return {
cleanContent,
thinking: thinkingContent
};
return {
cleanContent,
thinking: thinkingContent
};
}
}

const completeThinkMatch = content.match(/<think>([\s\S]*?)<\/think>/);
const completeThinkBracketMatch = content.match(/\[THINK\]([\s\S]*?)\[\/THINK\]/);

if (completeThinkMatch) {
const thinkingContent = completeThinkMatch[1]?.trim() ?? '';
const cleanContent = `${content.slice(0, completeThinkMatch.index ?? 0)}${content.slice(
(completeThinkMatch.index ?? 0) + completeThinkMatch[0].length
)}`.trim();

return {
thinking: thinkingContent,
cleanContent
};
}
// Check for complete blocks
for (const format of THINKING_FORMATS) {
const match = content.match(format.regex);

if (completeThinkBracketMatch) {
const thinkingContent = completeThinkBracketMatch[1]?.trim() ?? '';
const cleanContent = `${content.slice(0, completeThinkBracketMatch.index ?? 0)}${content.slice(
(completeThinkBracketMatch.index ?? 0) + completeThinkBracketMatch[0].length
)}`.trim();
if (match) {
const thinkingContent = match[1]?.trim() ?? '';
const cleanContent = `${content.slice(0, match.index ?? 0)}${content.slice(
(match.index ?? 0) + match[0].length
)}`.trim();

return {
thinking: thinkingContent,
cleanContent
};
return {
thinking: thinkingContent,
cleanContent
};
}
}

return {
Expand All @@ -68,31 +53,27 @@ export function parseThinkingContent(content: string): {

/**
* Checks if content contains an opening thinking tag (for streaming)
* Supports both <think> 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('<think>') ||
content.includes('[THINK]') ||
THINKING_FORMATS.some((format) => content.includes(format.startTag)) ||
content.includes('<|channel|>analysis')
);
}

/**
* Checks if content contains a closing thinking tag (for streaming)
* Supports both </think> 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('</think>') || content.includes('[/THINK]');
return THINKING_FORMATS.some((format) => content.includes(format.endTag));
}

/**
* Extracts partial thinking content during streaming
* Supports both <think> 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
Expand All @@ -101,39 +82,32 @@ export function extractPartialThinking(content: string): {
thinking: string | null;
remainingContent: string;
} {
const thinkStartIndex = content.indexOf('<think>');
const thinkEndIndex = content.indexOf('</think>');

const bracketStartIndex = content.indexOf('[THINK]');
const bracketEndIndex = content.indexOf('[/THINK]');
// 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 useThinkFormat =
thinkStartIndex !== -1 && (bracketStartIndex === -1 || thinkStartIndex < bracketStartIndex);
const useBracketFormat =
bracketStartIndex !== -1 && (thinkStartIndex === -1 || bracketStartIndex < thinkStartIndex);
const firstFormat = formatPositions[0];

if (useThinkFormat) {
if (thinkEndIndex === -1) {
const thinkingStart = thinkStartIndex + '<think>'.length;
if (firstFormat && firstFormat.endIndex === -1) {
// We have an opening tag but no closing tag (streaming case)
const thinkingStart = firstFormat.startIndex + firstFormat.startTag.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 {
Expand Down
91 changes: 87 additions & 4 deletions tools/server/webui/src/stories/ChatMessage.stories.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 &#9665;think&#9655; and &#9665;/think&#9665; 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 &#9665;think&#9655; 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 <think> format
let streamingThinkMessage = $state({
id: '8',
id: '9',
convId: 'conv-1',
type: 'message',
timestamp: 0, // No timestamp = streaming
Expand All @@ -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
Expand Down Expand Up @@ -215,6 +242,14 @@
}}
/>

<Story
name="ThinkPipeFormat"
args={{
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
message: thinkPipeMessage
}}
/>

<Story
name="StreamingThinkTag"
args={{
Expand Down Expand Up @@ -277,7 +312,7 @@
play={async () => {
// Phase 1: Stream [THINK] reasoning content
const thinkingContent =
'Using the DeepSeek format now:\n\n- This demonstrates the &#91;THINK&#93; bracket format\n- Should parse identically to &lt;think&gt; 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 &#91;THINK&#93; bracket format\n- Should parse identically to &lt;think&gt; 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;
Expand All @@ -295,7 +330,7 @@

// Phase 2: Stream main response content
const responseContent =
"Here's my response after using the &#91;THINK&#93; format:\n\n**Observations:**\n- Both &lt;think&gt; and &#91;THINK&#93; 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 &#91;THINK&#93; format:\n\n**Observations:**\n- All three formats (&lt;think&gt;, &#91;THINK&#93;, &lt;|think|&gt;) 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];
Expand All @@ -310,3 +345,51 @@
<ChatMessage message={streamingBracketMessage} />
</div>
</Story>

<Story
name="StreamingThinkPipe"
args={{
message: streamingPipeMessage
}}
parameters={{
test: {
timeout: 30000
}
}}
asChild
play={async () => {
// Phase 1: Stream ◁think▷ reasoning content
const thinkingContent =
'Demonstrating the pipe format thinking:\n\n- This uses the &#9665;think&#9655; and &#9665;/think&#9655; 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 &#9665;think&#9655; format:\n\n**Key Features:**\n- All three thinking formats (&lt;think&gt;, &#91;THINK&#93;, &lt;|think|&gt;) 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();
}}
>
<div class="w-[56rem]">
<ChatMessage message={streamingPipeMessage} />
</div>
</Story>