Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
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 || !
|
70 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
}
|
72 |
-
return marked(
|
73 |
});
|
74 |
</script>
|
75 |
|
@@ -100,85 +117,145 @@
|
|
100 |
{message?.role}
|
101 |
</div>
|
102 |
|
103 |
-
<div class="flex w-full gap-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
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
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
+
}
|