Skip to content

Commit 00d40c6

Browse files
committed
reasoning parsing
1 parent 18f22de commit 00d40c6

File tree

3 files changed

+247
-77
lines changed

3 files changed

+247
-77
lines changed

src/lib/components/inference-playground/message.svelte

Lines changed: 154 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import LocalToasts from "../local-toasts.svelte";
2020
import { previewImage } from "./img-preview.svelte";
2121
import { marked } from "marked";
22+
import { parseThinkingTokens } from "$lib/utils/thinking.js";
23+
import IconChevronDown from "~icons/carbon/chevron-down";
24+
import IconChevronRight from "~icons/carbon/chevron-right";
2225
2326
type Props = {
2427
conversation: ConversationClass;
@@ -32,11 +35,13 @@
3235
const isLast = $derived(index === (conversation.data.messages?.length || 0) - 1);
3336
3437
const autosized = new TextareaAutosize();
38+
const reasoningAutosized = new TextareaAutosize();
3539
const shouldStick = $derived(autosized.textareaHeight > 92);
3640
3741
const canUploadImgs = $derived(message.role === "user" && conversation.supportsImgUpload);
3842
3943
let isEditing = $state(false);
44+
let isReasoningExpanded = $state(false);
4045
4146
const fileQueue = new AsyncQueue();
4247
const fileUpload = new FileUpload({
@@ -65,11 +70,23 @@
6570
return isLast ? "Generate from here" : "Regenerate from here";
6671
});
6772
73+
const parsedMessage = $derived.by(() => {
74+
const content = message?.content ?? "";
75+
return parseThinkingTokens(content);
76+
});
77+
6878
const parsedContent = $derived.by(() => {
69-
if (!conversation.data.parseMarkdown || !message?.content) {
70-
return message?.content ?? "";
79+
if (!conversation.data.parseMarkdown || !parsedMessage.content) {
80+
return parsedMessage.content;
81+
}
82+
return marked(parsedMessage.content);
83+
});
84+
85+
const parsedReasoning = $derived.by(() => {
86+
if (!conversation.data.parseMarkdown || !parsedMessage.thinking) {
87+
return parsedMessage.thinking;
7188
}
72-
return marked(message.content);
89+
return marked(parsedMessage.thinking);
7390
});
7491
</script>
7592

@@ -100,85 +117,145 @@
100117
{message?.role}
101118
</div>
102119

103-
<div class="flex w-full gap-4">
104-
{#if conversation.data.parseMarkdown && message?.role === "assistant"}
105-
<div
106-
class="relative max-w-none grow rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900"
107-
data-message
108-
data-test-id={TEST_IDS.message}
109-
{@attach clickOutside(() => (isEditing = false))}
110-
>
111-
<Tooltip>
112-
{#snippet trigger(tooltip)}
113-
<button
114-
tabindex="0"
115-
onclick={() => {
116-
isEditing = !isEditing;
117-
}}
118-
type="button"
119-
class="absolute top-1 right-1 grid size-6 place-items-center rounded border border-gray-200 bg-white text-xs transition-opacity hover:bg-gray-100 hover:text-blue-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white {isEditing
120-
? 'opacity-100'
121-
: 'opacity-0 group-hover/message:opacity-100'}"
122-
{...tooltip.trigger}
123-
>
124-
<IconEdit />
125-
</button>
126-
{/snippet}
127-
{isEditing ? "Stop editing" : "Edit"}
128-
</Tooltip>
120+
<div class="flex w-full flex-col gap-2">
121+
<!-- Reasoning section (if present) -->
122+
{#if parsedMessage.thinking && message?.role === "assistant"}
123+
<div class="flex w-full flex-col gap-2">
124+
<button
125+
onclick={() => (isReasoningExpanded = !isReasoningExpanded)}
126+
class="flex items-center gap-2 self-start rounded-md px-2 py-1 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
127+
>
128+
{#if isReasoningExpanded}
129+
<IconChevronDown class="size-4" />
130+
{:else}
131+
<IconChevronRight class="size-4" />
132+
{/if}
133+
Reasoning
134+
</button>
129135

130-
{#if !isEditing}
131-
<div class="prose prose-sm dark:prose-invert">
132-
{@html parsedContent}
133-
</div>
134-
{:else}
135-
<textarea
136-
value={message?.content}
137-
onchange={e => {
138-
const el = e.target as HTMLTextAreaElement;
139-
const content = el?.value;
140-
if (!message || !content) return;
141-
conversation.updateMessage({ index, message: { ...message, content } });
142-
}}
143-
onkeydown={e => {
144-
if ((e.ctrlKey || e.metaKey) && e.key === "g") {
145-
e.preventDefault();
146-
e.stopPropagation();
147-
onRegen?.();
148-
}
149-
}}
150-
placeholder="Enter {message?.role} message"
151-
class="w-full resize-none overflow-hidden border-none bg-transparent outline-none"
152-
rows="1"
153-
{@attach autosized.attachment}
154-
></textarea>
136+
{#if isReasoningExpanded}
137+
{#if conversation.data.parseMarkdown && !isEditing}
138+
<div
139+
class="relative w-full max-w-none rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900"
140+
>
141+
<div class="prose prose-sm dark:prose-invert">
142+
{@html parsedReasoning}
143+
</div>
144+
</div>
145+
{:else}
146+
<textarea
147+
value={parsedMessage.thinking}
148+
onchange={e => {
149+
const el = e.target as HTMLTextAreaElement;
150+
const reasoningContent = el?.value ?? "";
151+
if (!message) return;
152+
// Reconstruct the full message with updated reasoning
153+
const newContent = reasoningContent
154+
? `<think>${reasoningContent}</think>\n\n${parsedMessage.content}`
155+
: parsedMessage.content;
156+
conversation.updateMessage({ index, message: { ...message, content: newContent } });
157+
}}
158+
onkeydown={e => {
159+
if ((e.ctrlKey || e.metaKey) && e.key === "g") {
160+
e.preventDefault();
161+
e.stopPropagation();
162+
onRegen?.();
163+
}
164+
}}
165+
placeholder="Enter reasoning content"
166+
class="w-full resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
167+
rows="1"
168+
{@attach reasoningAutosized.attachment}
169+
></textarea>
170+
{/if}
155171
{/if}
156172
</div>
157-
{:else}
158-
<textarea
159-
value={message?.content}
160-
onchange={e => {
161-
const el = e.target as HTMLTextAreaElement;
162-
const content = el?.value;
163-
if (!message || !content) return;
164-
conversation.updateMessage({ index, message: { ...message, content } });
165-
}}
166-
onkeydown={e => {
167-
if ((e.ctrlKey || e.metaKey) && e.key === "g") {
168-
e.preventDefault();
169-
e.stopPropagation();
170-
onRegen?.();
171-
}
172-
}}
173-
placeholder="Enter {message?.role} message"
174-
class="grow resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
175-
rows="1"
176-
data-message
177-
data-test-id={TEST_IDS.message}
178-
{@attach autosized.attachment}
179-
></textarea>
180173
{/if}
181174

175+
<!-- Main content section -->
176+
<div class="flex w-full gap-4">
177+
{#if conversation.data.parseMarkdown && message?.role === "assistant"}
178+
<div
179+
class="relative max-w-none grow rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900"
180+
data-message
181+
data-test-id={TEST_IDS.message}
182+
{@attach clickOutside(() => (isEditing = false))}
183+
>
184+
<Tooltip>
185+
{#snippet trigger(tooltip)}
186+
<button
187+
tabindex="0"
188+
onclick={() => {
189+
isEditing = !isEditing;
190+
}}
191+
type="button"
192+
class="absolute top-1 right-1 grid size-6 place-items-center rounded border border-gray-200 bg-white text-xs transition-opacity hover:bg-gray-100 hover:text-blue-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white {isEditing
193+
? 'opacity-100'
194+
: 'opacity-0 group-hover/message:opacity-100'}"
195+
{...tooltip.trigger}
196+
>
197+
<IconEdit />
198+
</button>
199+
{/snippet}
200+
{isEditing ? "Stop editing" : "Edit"}
201+
</Tooltip>
202+
203+
{#if !isEditing}
204+
<div class="prose prose-sm dark:prose-invert">
205+
{@html parsedContent}
206+
</div>
207+
{:else}
208+
<textarea
209+
value={message?.content}
210+
onchange={e => {
211+
const el = e.target as HTMLTextAreaElement;
212+
const content = el?.value;
213+
if (!message || !content) return;
214+
conversation.updateMessage({ index, message: { ...message, content } });
215+
}}
216+
onkeydown={e => {
217+
if ((e.ctrlKey || e.metaKey) && e.key === "g") {
218+
e.preventDefault();
219+
e.stopPropagation();
220+
onRegen?.();
221+
}
222+
}}
223+
placeholder="Enter {message?.role} message"
224+
class="w-full resize-none overflow-hidden border-none bg-transparent outline-none"
225+
rows="1"
226+
{@attach autosized.attachment}
227+
></textarea>
228+
{/if}
229+
</div>
230+
{:else}
231+
<textarea
232+
value={parsedMessage.thinking ? parsedMessage.content : (message?.content ?? "")}
233+
onchange={e => {
234+
const el = e.target as HTMLTextAreaElement;
235+
const content = el?.value;
236+
if (!message || content === undefined) return;
237+
// If there was reasoning content, we need to preserve it when editing the main content
238+
const finalContent = parsedMessage.thinking
239+
? `<think>${parsedMessage.thinking}</think>\n\n${content}`
240+
: content;
241+
conversation.updateMessage({ index, message: { ...message, content: finalContent } });
242+
}}
243+
onkeydown={e => {
244+
if ((e.ctrlKey || e.metaKey) && e.key === "g") {
245+
e.preventDefault();
246+
e.stopPropagation();
247+
onRegen?.();
248+
}
249+
}}
250+
placeholder="Enter {message?.role} message"
251+
class="grow resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
252+
rows="1"
253+
data-message
254+
data-test-id={TEST_IDS.message}
255+
{@attach autosized.attachment}
256+
></textarea>
257+
{/if}
258+
</div>
182259
<!-- Sticky wrapper for action buttons -->
183260
<div
184261
class={[

src/lib/utils/thinking.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseThinkingTokens } from "./thinking.js";
3+
4+
describe("parseThinkingTokens", () => {
5+
it("should parse thinking tokens correctly", () => {
6+
const content = `<think>Got it, the user just sent "hi". I need to respond in a friendly way. Let's make it warm and welcoming. Maybe say "Hi there! How can I help you today?" That's a common and helpful response.</think>
7+
8+
Hi there! How can I help you today?`;
9+
10+
const result = parseThinkingTokens(content);
11+
12+
expect(result.thinking).toBe(
13+
`Got it, the user just sent "hi". I need to respond in a friendly way. Let's make it warm and welcoming. Maybe say "Hi there! How can I help you today?" That's a common and helpful response.`,
14+
);
15+
expect(result.content).toBe("Hi there! How can I help you today?");
16+
});
17+
18+
it("should handle thinking tags", () => {
19+
const content = `<thinking>This is a thinking section</thinking>
20+
21+
This is the main response.`;
22+
23+
const result = parseThinkingTokens(content);
24+
25+
expect(result.thinking).toBe("This is a thinking section");
26+
expect(result.content).toBe("This is the main response.");
27+
});
28+
29+
it("should handle multiple thinking sections", () => {
30+
const content = `<think>First thought</think>
31+
32+
Some content here.
33+
34+
<think>Second thought</think>
35+
36+
More content.`;
37+
38+
const result = parseThinkingTokens(content);
39+
40+
expect(result.thinking).toBe("First thought\n\nSecond thought");
41+
expect(result.content).toBe("Some content here.\n\nMore content.");
42+
});
43+
44+
it("should handle content without thinking tokens", () => {
45+
const content = "Just regular content here.";
46+
47+
const result = parseThinkingTokens(content);
48+
49+
expect(result.thinking).toBe("");
50+
expect(result.content).toBe("Just regular content here.");
51+
});
52+
53+
it("should handle empty content", () => {
54+
const result = parseThinkingTokens("");
55+
56+
expect(result.thinking).toBe("");
57+
expect(result.content).toBe("");
58+
});
59+
});

src/lib/utils/thinking.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export interface ParsedMessage {
2+
thinking: string;
3+
content: string;
4+
}
5+
6+
/**
7+
* Parses a message to separate thinking tokens from the main content
8+
* @param content The raw message content
9+
* @returns Object with thinking and content separated
10+
*/
11+
export function parseThinkingTokens(content: string): ParsedMessage {
12+
if (!content) {
13+
return { thinking: "", content: "" };
14+
}
15+
16+
// Match thinking tokens like <think>...</think> or <thinking>...</thinking>
17+
const thinkingRegex = /<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/gi;
18+
const matches = Array.from(content.matchAll(thinkingRegex));
19+
20+
if (matches.length === 0) {
21+
return { thinking: "", content };
22+
}
23+
24+
// Extract all thinking content
25+
const thinking = matches.map(match => match[1]?.trim() ?? "").join("\n\n");
26+
27+
// Remove thinking tokens from the main content and clean up extra whitespace
28+
const cleanContent = content
29+
.replace(thinkingRegex, "")
30+
.replace(/\n\s*\n\s*\n/g, "\n\n") // Replace multiple newlines with double newlines
31+
.trim();
32+
33+
return { thinking, content: cleanContent };
34+
}

0 commit comments

Comments
 (0)