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