1<script lang="ts">
2 import { chatStore } from '$lib/stores/chat.svelte';
3 import { config } from '$lib/stores/settings.svelte';
4 import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
5 import ChatMessageAssistant from './ChatMessageAssistant.svelte';
6 import ChatMessageUser from './ChatMessageUser.svelte';
7 import ChatMessageSystem from './ChatMessageSystem.svelte';
8
9 interface Props {
10 class?: string;
11 message: DatabaseMessage;
12 onCopy?: (message: DatabaseMessage) => void;
13 onContinueAssistantMessage?: (message: DatabaseMessage) => void;
14 onDelete?: (message: DatabaseMessage) => void;
15 onEditWithBranching?: (
16 message: DatabaseMessage,
17 newContent: string,
18 newExtras?: DatabaseMessageExtra[]
19 ) => void;
20 onEditWithReplacement?: (
21 message: DatabaseMessage,
22 newContent: string,
23 shouldBranch: boolean
24 ) => void;
25 onEditUserMessagePreserveResponses?: (
26 message: DatabaseMessage,
27 newContent: string,
28 newExtras?: DatabaseMessageExtra[]
29 ) => void;
30 onNavigateToSibling?: (siblingId: string) => void;
31 onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
32 siblingInfo?: ChatMessageSiblingInfo | null;
33 }
34
35 let {
36 class: className = '',
37 message,
38 onCopy,
39 onContinueAssistantMessage,
40 onDelete,
41 onEditWithBranching,
42 onEditWithReplacement,
43 onEditUserMessagePreserveResponses,
44 onNavigateToSibling,
45 onRegenerateWithBranching,
46 siblingInfo = null
47 }: Props = $props();
48
49 let deletionInfo = $state<{
50 totalCount: number;
51 userMessages: number;
52 assistantMessages: number;
53 messageTypes: string[];
54 } | null>(null);
55 let editedContent = $state(message.content);
56 let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
57 let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
58 let isEditing = $state(false);
59 let showDeleteDialog = $state(false);
60 let shouldBranchAfterEdit = $state(false);
61 let textareaElement: HTMLTextAreaElement | undefined = $state();
62
63 let thinkingContent = $derived.by(() => {
64 if (message.role === 'assistant') {
65 const trimmedThinking = message.thinking?.trim();
66
67 return trimmedThinking ? trimmedThinking : null;
68 }
69 return null;
70 });
71
72 let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
73 if (message.role === 'assistant') {
74 const trimmedToolCalls = message.toolCalls?.trim();
75
76 if (!trimmedToolCalls) {
77 return null;
78 }
79
80 try {
81 const parsed = JSON.parse(trimmedToolCalls);
82
83 if (Array.isArray(parsed)) {
84 return parsed as ApiChatCompletionToolCall[];
85 }
86 } catch {
87 // Harmony-only path: fall back to the raw string so issues surface visibly.
88 }
89
90 return trimmedToolCalls;
91 }
92 return null;
93 });
94
95 function handleCancelEdit() {
96 isEditing = false;
97 editedContent = message.content;
98 editedExtras = message.extra ? [...message.extra] : [];
99 editedUploadedFiles = [];
100 }
101
102 function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
103 editedExtras = extras;
104 }
105
106 function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
107 editedUploadedFiles = files;
108 }
109
110 async function handleCopy() {
111 const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
112 const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
113 await copyToClipboard(clipboardContent, 'Message copied to clipboard');
114 onCopy?.(message);
115 }
116
117 function handleConfirmDelete() {
118 onDelete?.(message);
119 showDeleteDialog = false;
120 }
121
122 async function handleDelete() {
123 deletionInfo = await chatStore.getDeletionInfo(message.id);
124 showDeleteDialog = true;
125 }
126
127 function handleEdit() {
128 isEditing = true;
129 editedContent = message.content;
130 editedExtras = message.extra ? [...message.extra] : [];
131 editedUploadedFiles = [];
132
133 setTimeout(() => {
134 if (textareaElement) {
135 textareaElement.focus();
136 textareaElement.setSelectionRange(
137 textareaElement.value.length,
138 textareaElement.value.length
139 );
140 }
141 }, 0);
142 }
143
144 function handleEditedContentChange(content: string) {
145 editedContent = content;
146 }
147
148 function handleEditKeydown(event: KeyboardEvent) {
149 // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
150 // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
151 if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
152 event.preventDefault();
153 handleSaveEdit();
154 } else if (event.key === 'Escape') {
155 event.preventDefault();
156 handleCancelEdit();
157 }
158 }
159
160 function handleRegenerate(modelOverride?: string) {
161 onRegenerateWithBranching?.(message, modelOverride);
162 }
163
164 function handleContinue() {
165 onContinueAssistantMessage?.(message);
166 }
167
168 async function handleSaveEdit() {
169 if (message.role === 'user' || message.role === 'system') {
170 const finalExtras = await getMergedExtras();
171 onEditWithBranching?.(message, editedContent.trim(), finalExtras);
172 } else {
173 // For assistant messages, preserve exact content including trailing whitespace
174 // This is important for the Continue feature to work properly
175 onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
176 }
177
178 isEditing = false;
179 shouldBranchAfterEdit = false;
180 editedUploadedFiles = [];
181 }
182
183 async function handleSaveEditOnly() {
184 if (message.role === 'user') {
185 // For user messages, trim to avoid accidental whitespace
186 const finalExtras = await getMergedExtras();
187 onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
188 }
189
190 isEditing = false;
191 editedUploadedFiles = [];
192 }
193
194 async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
195 if (editedUploadedFiles.length === 0) {
196 return editedExtras;
197 }
198
199 const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
200 const result = await parseFilesToMessageExtras(editedUploadedFiles);
201 const newExtras = result?.extras || [];
202
203 return [...editedExtras, ...newExtras];
204 }
205
206 function handleShowDeleteDialogChange(show: boolean) {
207 showDeleteDialog = show;
208 }
209</script>
210
211{#if message.role === 'system'}
212 <ChatMessageSystem
213 bind:textareaElement
214 class={className}
215 {deletionInfo}
216 {editedContent}
217 {isEditing}
218 {message}
219 onCancelEdit={handleCancelEdit}
220 onConfirmDelete={handleConfirmDelete}
221 onCopy={handleCopy}
222 onDelete={handleDelete}
223 onEdit={handleEdit}
224 onEditKeydown={handleEditKeydown}
225 onEditedContentChange={handleEditedContentChange}
226 {onNavigateToSibling}
227 onSaveEdit={handleSaveEdit}
228 onShowDeleteDialogChange={handleShowDeleteDialogChange}
229 {showDeleteDialog}
230 {siblingInfo}
231 />
232{:else if message.role === 'user'}
233 <ChatMessageUser
234 bind:textareaElement
235 class={className}
236 {deletionInfo}
237 {editedContent}
238 {editedExtras}
239 {editedUploadedFiles}
240 {isEditing}
241 {message}
242 onCancelEdit={handleCancelEdit}
243 onConfirmDelete={handleConfirmDelete}
244 onCopy={handleCopy}
245 onDelete={handleDelete}
246 onEdit={handleEdit}
247 onEditKeydown={handleEditKeydown}
248 onEditedContentChange={handleEditedContentChange}
249 onEditedExtrasChange={handleEditedExtrasChange}
250 onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
251 {onNavigateToSibling}
252 onSaveEdit={handleSaveEdit}
253 onSaveEditOnly={handleSaveEditOnly}
254 onShowDeleteDialogChange={handleShowDeleteDialogChange}
255 {showDeleteDialog}
256 {siblingInfo}
257 />
258{:else}
259 <ChatMessageAssistant
260 bind:textareaElement
261 class={className}
262 {deletionInfo}
263 {editedContent}
264 {isEditing}
265 {message}
266 messageContent={message.content}
267 onCancelEdit={handleCancelEdit}
268 onConfirmDelete={handleConfirmDelete}
269 onContinue={handleContinue}
270 onCopy={handleCopy}
271 onDelete={handleDelete}
272 onEdit={handleEdit}
273 onEditKeydown={handleEditKeydown}
274 onEditedContentChange={handleEditedContentChange}
275 {onNavigateToSibling}
276 onRegenerate={handleRegenerate}
277 onSaveEdit={handleSaveEdit}
278 onShowDeleteDialogChange={handleShowDeleteDialogChange}
279 {shouldBranchAfterEdit}
280 onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
281 {showDeleteDialog}
282 {siblingInfo}
283 {thinkingContent}
284 {toolCallContent}
285 />
286{/if}