|
2 | 2 | import { Card } from '$lib/components/ui/card'; |
3 | 3 | import { Button } from '$lib/components/ui/button'; |
4 | 4 | import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip'; |
5 | | - import { User, Bot, Edit, Copy, Trash2, RefreshCw } from '@lucide/svelte'; |
| 5 | + import { Textarea } from '$lib/components/ui/textarea'; |
| 6 | + import { User, Bot, Edit, Copy, Trash2, RefreshCw, Check, X } from '@lucide/svelte'; |
6 | 7 | import type { ChatRole } from '$lib/types/chat'; |
7 | | - import type { ChatMessageData } from '$lib/types/chat'; |
| 8 | + import type { DatabaseChatMessage } from '$lib/types/database'; |
8 | 9 | import ThinkingSection from './ThinkingSection.svelte'; |
9 | 10 | import MarkdownContent from './MarkdownContent.svelte'; |
10 | 11 | import { parseThinkingContent } from '$lib/utils/thinking'; |
| 12 | + import { copyToClipboard } from '$lib/utils/copy'; |
11 | 13 |
|
12 | 14 | interface Props { |
13 | 15 | class?: string; |
14 | | - message: ChatMessageData; |
15 | | - onEdit?: (message: ChatMessageData) => void; |
16 | | - onDelete?: (message: ChatMessageData) => void; |
17 | | - onCopy?: (message: ChatMessageData) => void; |
18 | | - onRegenerate?: (message: ChatMessageData) => void; |
| 16 | + message: DatabaseChatMessage; |
| 17 | + onEdit?: (message: DatabaseChatMessage) => void; |
| 18 | + onDelete?: (message: DatabaseChatMessage) => void; |
| 19 | + onCopy?: (message: DatabaseChatMessage) => void; |
| 20 | + onRegenerate?: (message: DatabaseChatMessage) => void; |
| 21 | + onUpdateMessage?: (message: DatabaseChatMessage, newContent: string) => void; |
19 | 22 | } |
20 | 23 |
|
21 | 24 | let { |
|
24 | 27 | onEdit, |
25 | 28 | onDelete, |
26 | 29 | onCopy, |
27 | | - onRegenerate |
| 30 | + onRegenerate, |
| 31 | + onUpdateMessage |
28 | 32 | }: Props = $props(); |
29 | 33 |
|
| 34 | + // Editing state |
| 35 | + let isEditing = $state(false); |
| 36 | + let editedContent = $state(message.content); |
| 37 | + // Element reference (not reactive) |
| 38 | + let textareaElement: HTMLTextAreaElement; |
| 39 | +
|
30 | 40 | // Parse thinking content for assistant messages |
31 | | - const parsedContent = $derived(() => { |
| 41 | + // Use separate derived values to prevent unnecessary re-renders |
| 42 | + let thinkingContent = $derived.by(() => { |
| 43 | + if (message.role === 'assistant') { |
| 44 | + // Prioritize message.thinking (from streaming) over parsed thinking |
| 45 | + if (message.thinking) { |
| 46 | + return message.thinking; |
| 47 | + } |
| 48 | + // Fallback to parsing content for complete messages |
| 49 | + const parsed = parseThinkingContent(message.content); |
| 50 | + return parsed.thinking; |
| 51 | + } |
| 52 | + return null; |
| 53 | + }); |
| 54 | +
|
| 55 | + let messageContent = $derived.by(() => { |
32 | 56 | if (message.role === 'assistant') { |
| 57 | + // Always parse and clean the content to remove <think>...</think> blocks |
33 | 58 | const parsed = parseThinkingContent(message.content); |
34 | | - return { |
35 | | - thinking: message.thinking || parsed.thinking, |
36 | | - content: parsed.cleanContent || message.content |
37 | | - }; |
| 59 | + return parsed.cleanContent; |
38 | 60 | } |
39 | | - return { thinking: null, content: message.content }; |
| 61 | + return message.content; |
40 | 62 | }); |
41 | 63 |
|
42 | 64 | // Handle copy to clipboard |
43 | | - function handleCopy() { |
44 | | - navigator.clipboard.writeText(message.content); |
| 65 | + async function handleCopy() { |
| 66 | + await copyToClipboard(message.content, 'Message copied to clipboard'); |
45 | 67 | onCopy?.(message); |
46 | 68 | } |
47 | 69 |
|
48 | 70 | // Handle edit action |
49 | 71 | function handleEdit() { |
| 72 | + isEditing = true; |
| 73 | + editedContent = message.content; |
| 74 | + // Focus the textarea after it's rendered |
| 75 | + setTimeout(() => { |
| 76 | + if (textareaElement) { |
| 77 | + textareaElement.focus(); |
| 78 | + textareaElement.setSelectionRange( |
| 79 | + textareaElement.value.length, |
| 80 | + textareaElement.value.length |
| 81 | + ); |
| 82 | + } |
| 83 | + }, 0); |
50 | 84 | onEdit?.(message); |
51 | 85 | } |
52 | 86 |
|
53 | | - // Handle delete action |
54 | | - function handleDelete() { |
55 | | - onDelete?.(message); |
| 87 | + // Handle save edited message |
| 88 | + function handleSaveEdit() { |
| 89 | + if (editedContent.trim() && editedContent !== message.content) { |
| 90 | + onUpdateMessage?.(message, editedContent.trim()); |
| 91 | + } |
| 92 | + isEditing = false; |
| 93 | + } |
| 94 | +
|
| 95 | + // Handle cancel edit |
| 96 | + function handleCancelEdit() { |
| 97 | + isEditing = false; |
| 98 | + editedContent = message.content; |
| 99 | + } |
| 100 | +
|
| 101 | + // Handle keyboard shortcuts in edit mode |
| 102 | + function handleEditKeydown(event: KeyboardEvent) { |
| 103 | + if (event.key === 'Enter' && !event.shiftKey) { |
| 104 | + event.preventDefault(); |
| 105 | + handleSaveEdit(); |
| 106 | + } else if (event.key === 'Escape') { |
| 107 | + event.preventDefault(); |
| 108 | + handleCancelEdit(); |
| 109 | + } |
56 | 110 | } |
57 | 111 |
|
58 | 112 | // Handle regenerate action |
|
137 | 191 | role="group" |
138 | 192 | aria-label="User message with actions" |
139 | 193 | > |
140 | | - <Card class="bg-primary text-primary-foreground max-w-[80%] rounded-2xl px-2.5 py-1.5"> |
141 | | - <div class="text-md whitespace-pre-wrap"> |
142 | | - {message.content} |
| 194 | + {#if isEditing} |
| 195 | + <!-- Editing mode --> |
| 196 | + <div class="w-full max-w-[80%]"> |
| 197 | + <textarea |
| 198 | + bind:this={textareaElement} |
| 199 | + bind:value={editedContent} |
| 200 | + onkeydown={handleEditKeydown} |
| 201 | + class="border-primary bg-background text-foreground focus:ring-ring min-h-[60px] w-full resize-none rounded-2xl border-2 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2" |
| 202 | + placeholder="Edit your message..." |
| 203 | + ></textarea> |
| 204 | + <div class="mt-2 flex justify-end gap-2"> |
| 205 | + <Button variant="outline" size="sm" class="h-8 px-3" onclick={handleCancelEdit}> |
| 206 | + <X class="mr-1 h-3 w-3" /> |
| 207 | + Cancel |
| 208 | + </Button> |
| 209 | + <Button |
| 210 | + size="sm" |
| 211 | + class="h-8 px-3" |
| 212 | + onclick={handleSaveEdit} |
| 213 | + disabled={!editedContent.trim() || editedContent === message.content} |
| 214 | + > |
| 215 | + <Check class="mr-1 h-3 w-3" /> |
| 216 | + Send |
| 217 | + </Button> |
| 218 | + </div> |
143 | 219 | </div> |
144 | | - </Card> |
| 220 | + {:else} |
| 221 | + <!-- Display mode --> |
| 222 | + <Card class="bg-primary text-primary-foreground max-w-[80%] rounded-2xl px-2.5 py-1.5"> |
| 223 | + <div class="text-md whitespace-pre-wrap"> |
| 224 | + {message.content} |
| 225 | + </div> |
| 226 | + </Card> |
145 | 227 |
|
146 | | - <div class="relative flex h-6 items-center"> |
147 | | - {@render messageActions({ role: 'user' })} |
148 | | - </div> |
| 228 | + <div class="relative flex h-6 items-center"> |
| 229 | + {@render messageActions({ role: 'user' })} |
| 230 | + </div> |
| 231 | + {/if} |
149 | 232 | </div> |
150 | 233 | {:else} |
151 | 234 | <div |
152 | 235 | class="text-md leading-7.5 group w-full {className}" |
153 | 236 | role="group" |
154 | 237 | aria-label="Assistant message with actions" |
155 | 238 | > |
156 | | - {#if parsedContent().thinking} |
157 | | - <ThinkingSection thinking={parsedContent().thinking || ''} /> |
| 239 | + {#if thinkingContent} |
| 240 | + <ThinkingSection thinking={thinkingContent} isStreaming={!message.timestamp} /> |
158 | 241 | {/if} |
159 | 242 | {#if message.role === 'assistant'} |
160 | | - <MarkdownContent content={parsedContent().content} /> |
| 243 | + <MarkdownContent content={messageContent} /> |
161 | 244 | {:else} |
162 | 245 | <div class="whitespace-pre-wrap text-sm"> |
163 | | - {parsedContent().content} |
| 246 | + {messageContent} |
164 | 247 | </div> |
165 | 248 | {/if} |
166 | 249 |
|
|
0 commit comments