Thomas G. Lopes commited on
Commit
00d40c6
·
1 Parent(s): 18f22de

reasoning parsing

Browse files
src/lib/components/inference-playground/message.svelte CHANGED
@@ -19,6 +19,9 @@
19
  import LocalToasts from "../local-toasts.svelte";
20
  import { previewImage } from "./img-preview.svelte";
21
  import { marked } from "marked";
 
 
 
22
 
23
  type Props = {
24
  conversation: ConversationClass;
@@ -32,11 +35,13 @@
32
  const isLast = $derived(index === (conversation.data.messages?.length || 0) - 1);
33
 
34
  const autosized = new TextareaAutosize();
 
35
  const shouldStick = $derived(autosized.textareaHeight > 92);
36
 
37
  const canUploadImgs = $derived(message.role === "user" && conversation.supportsImgUpload);
38
 
39
  let isEditing = $state(false);
 
40
 
41
  const fileQueue = new AsyncQueue();
42
  const fileUpload = new FileUpload({
@@ -65,11 +70,23 @@
65
  return isLast ? "Generate from here" : "Regenerate from here";
66
  });
67
 
 
 
 
 
 
68
  const parsedContent = $derived.by(() => {
69
- if (!conversation.data.parseMarkdown || !message?.content) {
70
- return message?.content ?? "";
 
 
 
 
 
 
 
71
  }
72
- return marked(message.content);
73
  });
74
  </script>
75
 
@@ -100,85 +117,145 @@
100
  {message?.role}
101
  </div>
102
 
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>
129
 
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>
 
 
 
 
 
 
 
 
 
 
155
  {/if}
156
  </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>
180
  {/if}
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  <!-- Sticky wrapper for action buttons -->
183
  <div
184
  class={[
 
19
  import LocalToasts from "../local-toasts.svelte";
20
  import { previewImage } from "./img-preview.svelte";
21
  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";
25
 
26
  type Props = {
27
  conversation: ConversationClass;
 
35
  const isLast = $derived(index === (conversation.data.messages?.length || 0) - 1);
36
 
37
  const autosized = new TextareaAutosize();
38
+ const reasoningAutosized = new TextareaAutosize();
39
  const shouldStick = $derived(autosized.textareaHeight > 92);
40
 
41
  const canUploadImgs = $derived(message.role === "user" && conversation.supportsImgUpload);
42
 
43
  let isEditing = $state(false);
44
+ let isReasoningExpanded = $state(false);
45
 
46
  const fileQueue = new AsyncQueue();
47
  const fileUpload = new FileUpload({
 
70
  return isLast ? "Generate from here" : "Regenerate from here";
71
  });
72
 
73
+ const parsedMessage = $derived.by(() => {
74
+ const content = message?.content ?? "";
75
+ return parseThinkingTokens(content);
76
+ });
77
+
78
  const parsedContent = $derived.by(() => {
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;
88
  }
89
+ return marked(parsedMessage.thinking);
90
  });
91
  </script>
92
 
 
117
  {message?.role}
118
  </div>
119
 
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>
 
 
 
 
 
 
 
 
 
 
 
135
 
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}
171
  {/if}
172
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  {/if}
174
 
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>
259
  <!-- Sticky wrapper for action buttons -->
260
  <div
261
  class={[
src/lib/utils/thinking.test.ts ADDED
@@ -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 ADDED
@@ -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
+ }