ChatMessage.svelte 8.0 KB

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