ChatMessage.svelte 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. <script lang="ts">
  2. import { getDeletionInfo } from '$lib/stores/chat.svelte';
  3. import { copyToClipboard } from '$lib/utils/copy';
  4. import { isIMEComposing } from '$lib/utils/is-ime-composing';
  5. import type { ApiChatCompletionToolCall } from '$lib/types/api';
  6. import ChatMessageAssistant from './ChatMessageAssistant.svelte';
  7. import ChatMessageUser from './ChatMessageUser.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?: (message: DatabaseMessage, newContent: string) => void;
  15. onEditWithReplacement?: (
  16. message: DatabaseMessage,
  17. newContent: string,
  18. shouldBranch: boolean
  19. ) => void;
  20. onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
  21. onNavigateToSibling?: (siblingId: string) => void;
  22. onRegenerateWithBranching?: (message: DatabaseMessage) => void;
  23. siblingInfo?: ChatMessageSiblingInfo | null;
  24. }
  25. let {
  26. class: className = '',
  27. message,
  28. onCopy,
  29. onContinueAssistantMessage,
  30. onDelete,
  31. onEditWithBranching,
  32. onEditWithReplacement,
  33. onEditUserMessagePreserveResponses,
  34. onNavigateToSibling,
  35. onRegenerateWithBranching,
  36. siblingInfo = null
  37. }: Props = $props();
  38. let deletionInfo = $state<{
  39. totalCount: number;
  40. userMessages: number;
  41. assistantMessages: number;
  42. messageTypes: string[];
  43. } | null>(null);
  44. let editedContent = $state(message.content);
  45. let isEditing = $state(false);
  46. let showDeleteDialog = $state(false);
  47. let shouldBranchAfterEdit = $state(false);
  48. let textareaElement: HTMLTextAreaElement | undefined = $state();
  49. let thinkingContent = $derived.by(() => {
  50. if (message.role === 'assistant') {
  51. const trimmedThinking = message.thinking?.trim();
  52. return trimmedThinking ? trimmedThinking : null;
  53. }
  54. return null;
  55. });
  56. let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => {
  57. if (message.role === 'assistant') {
  58. const trimmedToolCalls = message.toolCalls?.trim();
  59. if (!trimmedToolCalls) {
  60. return null;
  61. }
  62. try {
  63. const parsed = JSON.parse(trimmedToolCalls);
  64. if (Array.isArray(parsed)) {
  65. return parsed as ApiChatCompletionToolCall[];
  66. }
  67. } catch {
  68. // Harmony-only path: fall back to the raw string so issues surface visibly.
  69. }
  70. return trimmedToolCalls;
  71. }
  72. return null;
  73. });
  74. function handleCancelEdit() {
  75. isEditing = false;
  76. editedContent = message.content;
  77. }
  78. async function handleCopy() {
  79. await copyToClipboard(message.content, 'Message copied to clipboard');
  80. onCopy?.(message);
  81. }
  82. function handleConfirmDelete() {
  83. onDelete?.(message);
  84. showDeleteDialog = false;
  85. }
  86. async function handleDelete() {
  87. deletionInfo = await getDeletionInfo(message.id);
  88. showDeleteDialog = true;
  89. }
  90. function handleEdit() {
  91. isEditing = true;
  92. editedContent = message.content;
  93. setTimeout(() => {
  94. if (textareaElement) {
  95. textareaElement.focus();
  96. textareaElement.setSelectionRange(
  97. textareaElement.value.length,
  98. textareaElement.value.length
  99. );
  100. }
  101. }, 0);
  102. }
  103. function handleEditedContentChange(content: string) {
  104. editedContent = content;
  105. }
  106. function handleEditKeydown(event: KeyboardEvent) {
  107. // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
  108. // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
  109. if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
  110. event.preventDefault();
  111. handleSaveEdit();
  112. } else if (event.key === 'Escape') {
  113. event.preventDefault();
  114. handleCancelEdit();
  115. }
  116. }
  117. function handleRegenerate() {
  118. onRegenerateWithBranching?.(message);
  119. }
  120. function handleContinue() {
  121. onContinueAssistantMessage?.(message);
  122. }
  123. function handleSaveEdit() {
  124. if (message.role === 'user') {
  125. // For user messages, trim to avoid accidental whitespace
  126. onEditWithBranching?.(message, editedContent.trim());
  127. } else {
  128. // For assistant messages, preserve exact content including trailing whitespace
  129. // This is important for the Continue feature to work properly
  130. onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit);
  131. }
  132. isEditing = false;
  133. shouldBranchAfterEdit = false;
  134. }
  135. function handleSaveEditOnly() {
  136. if (message.role === 'user') {
  137. // For user messages, trim to avoid accidental whitespace
  138. onEditUserMessagePreserveResponses?.(message, editedContent.trim());
  139. }
  140. isEditing = false;
  141. }
  142. function handleShowDeleteDialogChange(show: boolean) {
  143. showDeleteDialog = show;
  144. }
  145. </script>
  146. {#if message.role === 'user'}
  147. <ChatMessageUser
  148. bind:textareaElement
  149. class={className}
  150. {deletionInfo}
  151. {editedContent}
  152. {isEditing}
  153. {message}
  154. onCancelEdit={handleCancelEdit}
  155. onConfirmDelete={handleConfirmDelete}
  156. onCopy={handleCopy}
  157. onDelete={handleDelete}
  158. onEdit={handleEdit}
  159. onEditKeydown={handleEditKeydown}
  160. onEditedContentChange={handleEditedContentChange}
  161. {onNavigateToSibling}
  162. onSaveEdit={handleSaveEdit}
  163. onSaveEditOnly={handleSaveEditOnly}
  164. onShowDeleteDialogChange={handleShowDeleteDialogChange}
  165. {showDeleteDialog}
  166. {siblingInfo}
  167. />
  168. {:else}
  169. <ChatMessageAssistant
  170. bind:textareaElement
  171. class={className}
  172. {deletionInfo}
  173. {editedContent}
  174. {isEditing}
  175. {message}
  176. messageContent={message.content}
  177. onCancelEdit={handleCancelEdit}
  178. onConfirmDelete={handleConfirmDelete}
  179. onContinue={handleContinue}
  180. onCopy={handleCopy}
  181. onDelete={handleDelete}
  182. onEdit={handleEdit}
  183. onEditKeydown={handleEditKeydown}
  184. onEditedContentChange={handleEditedContentChange}
  185. {onNavigateToSibling}
  186. onRegenerate={handleRegenerate}
  187. onSaveEdit={handleSaveEdit}
  188. onShowDeleteDialogChange={handleShowDeleteDialogChange}
  189. {shouldBranchAfterEdit}
  190. onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
  191. {showDeleteDialog}
  192. {siblingInfo}
  193. {thinkingContent}
  194. {toolCallContent}
  195. />
  196. {/if}