diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages')
10 files changed, 2044 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte new file mode 100644 index 0000000..220276f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte | |||
| @@ -0,0 +1,286 @@ | |||
| 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} | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte new file mode 100644 index 0000000..3cb4815 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte'; | ||
| 3 | import { | ||
| 4 | ActionButton, | ||
| 5 | ChatMessageBranchingControls, | ||
| 6 | DialogConfirmation | ||
| 7 | } from '$lib/components/app'; | ||
| 8 | |||
| 9 | interface Props { | ||
| 10 | role: 'user' | 'assistant'; | ||
| 11 | justify: 'start' | 'end'; | ||
| 12 | actionsPosition: 'left' | 'right'; | ||
| 13 | siblingInfo?: ChatMessageSiblingInfo | null; | ||
| 14 | showDeleteDialog: boolean; | ||
| 15 | deletionInfo: { | ||
| 16 | totalCount: number; | ||
| 17 | userMessages: number; | ||
| 18 | assistantMessages: number; | ||
| 19 | messageTypes: string[]; | ||
| 20 | } | null; | ||
| 21 | onCopy: () => void; | ||
| 22 | onEdit?: () => void; | ||
| 23 | onRegenerate?: () => void; | ||
| 24 | onContinue?: () => void; | ||
| 25 | onDelete: () => void; | ||
| 26 | onConfirmDelete: () => void; | ||
| 27 | onNavigateToSibling?: (siblingId: string) => void; | ||
| 28 | onShowDeleteDialogChange: (show: boolean) => void; | ||
| 29 | } | ||
| 30 | |||
| 31 | let { | ||
| 32 | actionsPosition, | ||
| 33 | deletionInfo, | ||
| 34 | justify, | ||
| 35 | onCopy, | ||
| 36 | onEdit, | ||
| 37 | onConfirmDelete, | ||
| 38 | onContinue, | ||
| 39 | onDelete, | ||
| 40 | onNavigateToSibling, | ||
| 41 | onShowDeleteDialogChange, | ||
| 42 | onRegenerate, | ||
| 43 | role, | ||
| 44 | siblingInfo = null, | ||
| 45 | showDeleteDialog | ||
| 46 | }: Props = $props(); | ||
| 47 | |||
| 48 | function handleConfirmDelete() { | ||
| 49 | onConfirmDelete(); | ||
| 50 | onShowDeleteDialogChange(false); | ||
| 51 | } | ||
| 52 | </script> | ||
| 53 | |||
| 54 | <div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}"> | ||
| 55 | <div | ||
| 56 | class="absolute top-0 {actionsPosition === 'left' | ||
| 57 | ? 'left-0' | ||
| 58 | : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity" | ||
| 59 | > | ||
| 60 | {#if siblingInfo && siblingInfo.totalSiblings > 1} | ||
| 61 | <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} /> | ||
| 62 | {/if} | ||
| 63 | |||
| 64 | <div | ||
| 65 | class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150" | ||
| 66 | > | ||
| 67 | <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} /> | ||
| 68 | |||
| 69 | {#if onEdit} | ||
| 70 | <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} /> | ||
| 71 | {/if} | ||
| 72 | |||
| 73 | {#if role === 'assistant' && onRegenerate} | ||
| 74 | <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} /> | ||
| 75 | {/if} | ||
| 76 | |||
| 77 | {#if role === 'assistant' && onContinue} | ||
| 78 | <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} /> | ||
| 79 | {/if} | ||
| 80 | |||
| 81 | <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} /> | ||
| 82 | </div> | ||
| 83 | </div> | ||
| 84 | </div> | ||
| 85 | |||
| 86 | <DialogConfirmation | ||
| 87 | bind:open={showDeleteDialog} | ||
| 88 | title="Delete Message" | ||
| 89 | description={deletionInfo && deletionInfo.totalCount > 1 | ||
| 90 | ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.` | ||
| 91 | : 'Are you sure you want to delete this message? This action cannot be undone.'} | ||
| 92 | confirmText={deletionInfo && deletionInfo.totalCount > 1 | ||
| 93 | ? `Delete ${deletionInfo.totalCount} Messages` | ||
| 94 | : 'Delete'} | ||
| 95 | cancelText="Cancel" | ||
| 96 | variant="destructive" | ||
| 97 | icon={Trash2} | ||
| 98 | onConfirm={handleConfirmDelete} | ||
| 99 | onCancel={() => onShowDeleteDialogChange(false)} | ||
| 100 | /> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte new file mode 100644 index 0000000..2b34b1c --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte | |||
| @@ -0,0 +1,418 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { | ||
| 3 | ModelBadge, | ||
| 4 | ChatMessageActions, | ||
| 5 | ChatMessageStatistics, | ||
| 6 | ChatMessageThinkingBlock, | ||
| 7 | CopyToClipboardIcon, | ||
| 8 | MarkdownContent, | ||
| 9 | ModelsSelector | ||
| 10 | } from '$lib/components/app'; | ||
| 11 | import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; | ||
| 12 | import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte'; | ||
| 13 | import { isLoading } from '$lib/stores/chat.svelte'; | ||
| 14 | import { autoResizeTextarea, copyToClipboard } from '$lib/utils'; | ||
| 15 | import { fade } from 'svelte/transition'; | ||
| 16 | import { Check, X, Wrench } from '@lucide/svelte'; | ||
| 17 | import { Button } from '$lib/components/ui/button'; | ||
| 18 | import { Checkbox } from '$lib/components/ui/checkbox'; | ||
| 19 | import { INPUT_CLASSES } from '$lib/constants/input-classes'; | ||
| 20 | import Label from '$lib/components/ui/label/label.svelte'; | ||
| 21 | import { config } from '$lib/stores/settings.svelte'; | ||
| 22 | import { conversationsStore } from '$lib/stores/conversations.svelte'; | ||
| 23 | import { isRouterMode } from '$lib/stores/server.svelte'; | ||
| 24 | |||
| 25 | interface Props { | ||
| 26 | class?: string; | ||
| 27 | deletionInfo: { | ||
| 28 | totalCount: number; | ||
| 29 | userMessages: number; | ||
| 30 | assistantMessages: number; | ||
| 31 | messageTypes: string[]; | ||
| 32 | } | null; | ||
| 33 | editedContent?: string; | ||
| 34 | isEditing?: boolean; | ||
| 35 | message: DatabaseMessage; | ||
| 36 | messageContent: string | undefined; | ||
| 37 | onCancelEdit?: () => void; | ||
| 38 | onCopy: () => void; | ||
| 39 | onConfirmDelete: () => void; | ||
| 40 | onContinue?: () => void; | ||
| 41 | onDelete: () => void; | ||
| 42 | onEdit?: () => void; | ||
| 43 | onEditKeydown?: (event: KeyboardEvent) => void; | ||
| 44 | onEditedContentChange?: (content: string) => void; | ||
| 45 | onNavigateToSibling?: (siblingId: string) => void; | ||
| 46 | onRegenerate: (modelOverride?: string) => void; | ||
| 47 | onSaveEdit?: () => void; | ||
| 48 | onShowDeleteDialogChange: (show: boolean) => void; | ||
| 49 | onShouldBranchAfterEditChange?: (value: boolean) => void; | ||
| 50 | showDeleteDialog: boolean; | ||
| 51 | shouldBranchAfterEdit?: boolean; | ||
| 52 | siblingInfo?: ChatMessageSiblingInfo | null; | ||
| 53 | textareaElement?: HTMLTextAreaElement; | ||
| 54 | thinkingContent: string | null; | ||
| 55 | toolCallContent: ApiChatCompletionToolCall[] | string | null; | ||
| 56 | } | ||
| 57 | |||
| 58 | let { | ||
| 59 | class: className = '', | ||
| 60 | deletionInfo, | ||
| 61 | editedContent = '', | ||
| 62 | isEditing = false, | ||
| 63 | message, | ||
| 64 | messageContent, | ||
| 65 | onCancelEdit, | ||
| 66 | onConfirmDelete, | ||
| 67 | onContinue, | ||
| 68 | onCopy, | ||
| 69 | onDelete, | ||
| 70 | onEdit, | ||
| 71 | onEditKeydown, | ||
| 72 | onEditedContentChange, | ||
| 73 | onNavigateToSibling, | ||
| 74 | onRegenerate, | ||
| 75 | onSaveEdit, | ||
| 76 | onShowDeleteDialogChange, | ||
| 77 | onShouldBranchAfterEditChange, | ||
| 78 | showDeleteDialog, | ||
| 79 | shouldBranchAfterEdit = false, | ||
| 80 | siblingInfo = null, | ||
| 81 | textareaElement = $bindable(), | ||
| 82 | thinkingContent, | ||
| 83 | toolCallContent = null | ||
| 84 | }: Props = $props(); | ||
| 85 | |||
| 86 | const toolCalls = $derived( | ||
| 87 | Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null | ||
| 88 | ); | ||
| 89 | const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null); | ||
| 90 | |||
| 91 | const processingState = useProcessingState(); | ||
| 92 | |||
| 93 | let currentConfig = $derived(config()); | ||
| 94 | let isRouter = $derived(isRouterMode()); | ||
| 95 | let displayedModel = $derived((): string | null => { | ||
| 96 | if (message.model) { | ||
| 97 | return message.model; | ||
| 98 | } | ||
| 99 | |||
| 100 | return null; | ||
| 101 | }); | ||
| 102 | |||
| 103 | const { handleModelChange } = useModelChangeValidation({ | ||
| 104 | getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id), | ||
| 105 | onSuccess: (modelName) => onRegenerate(modelName) | ||
| 106 | }); | ||
| 107 | |||
| 108 | function handleCopyModel() { | ||
| 109 | const model = displayedModel(); | ||
| 110 | |||
| 111 | void copyToClipboard(model ?? ''); | ||
| 112 | } | ||
| 113 | |||
| 114 | $effect(() => { | ||
| 115 | if (isEditing && textareaElement) { | ||
| 116 | autoResizeTextarea(textareaElement); | ||
| 117 | } | ||
| 118 | }); | ||
| 119 | |||
| 120 | $effect(() => { | ||
| 121 | if (isLoading() && !message?.content?.trim()) { | ||
| 122 | processingState.startMonitoring(); | ||
| 123 | } | ||
| 124 | }); | ||
| 125 | |||
| 126 | function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) { | ||
| 127 | const callNumber = index + 1; | ||
| 128 | const functionName = toolCall.function?.name?.trim(); | ||
| 129 | const label = functionName || `Call #${callNumber}`; | ||
| 130 | |||
| 131 | const payload: Record<string, unknown> = {}; | ||
| 132 | |||
| 133 | const id = toolCall.id?.trim(); | ||
| 134 | if (id) { | ||
| 135 | payload.id = id; | ||
| 136 | } | ||
| 137 | |||
| 138 | const type = toolCall.type?.trim(); | ||
| 139 | if (type) { | ||
| 140 | payload.type = type; | ||
| 141 | } | ||
| 142 | |||
| 143 | if (toolCall.function) { | ||
| 144 | const fnPayload: Record<string, unknown> = {}; | ||
| 145 | |||
| 146 | const name = toolCall.function.name?.trim(); | ||
| 147 | if (name) { | ||
| 148 | fnPayload.name = name; | ||
| 149 | } | ||
| 150 | |||
| 151 | const rawArguments = toolCall.function.arguments?.trim(); | ||
| 152 | if (rawArguments) { | ||
| 153 | try { | ||
| 154 | fnPayload.arguments = JSON.parse(rawArguments); | ||
| 155 | } catch { | ||
| 156 | fnPayload.arguments = rawArguments; | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | if (Object.keys(fnPayload).length > 0) { | ||
| 161 | payload.function = fnPayload; | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | const formattedPayload = JSON.stringify(payload, null, 2); | ||
| 166 | |||
| 167 | return { | ||
| 168 | label, | ||
| 169 | tooltip: formattedPayload, | ||
| 170 | copyValue: formattedPayload | ||
| 171 | }; | ||
| 172 | } | ||
| 173 | |||
| 174 | function handleCopyToolCall(payload: string) { | ||
| 175 | void copyToClipboard(payload, 'Tool call copied to clipboard'); | ||
| 176 | } | ||
| 177 | </script> | ||
| 178 | |||
| 179 | <div | ||
| 180 | class="text-md group w-full leading-7.5 {className}" | ||
| 181 | role="group" | ||
| 182 | aria-label="Assistant message with actions" | ||
| 183 | > | ||
| 184 | {#if thinkingContent} | ||
| 185 | <ChatMessageThinkingBlock | ||
| 186 | reasoningContent={thinkingContent} | ||
| 187 | isStreaming={!message.timestamp} | ||
| 188 | hasRegularContent={!!messageContent?.trim()} | ||
| 189 | /> | ||
| 190 | {/if} | ||
| 191 | |||
| 192 | {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()} | ||
| 193 | <div class="mt-6 w-full max-w-[48rem]" in:fade> | ||
| 194 | <div class="processing-container"> | ||
| 195 | <span class="processing-text"> | ||
| 196 | {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()} | ||
| 197 | </span> | ||
| 198 | </div> | ||
| 199 | </div> | ||
| 200 | {/if} | ||
| 201 | |||
| 202 | {#if isEditing} | ||
| 203 | <div class="w-full"> | ||
| 204 | <textarea | ||
| 205 | bind:this={textareaElement} | ||
| 206 | bind:value={editedContent} | ||
| 207 | class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}" | ||
| 208 | onkeydown={onEditKeydown} | ||
| 209 | oninput={(e) => { | ||
| 210 | autoResizeTextarea(e.currentTarget); | ||
| 211 | onEditedContentChange?.(e.currentTarget.value); | ||
| 212 | }} | ||
| 213 | placeholder="Edit assistant message..." | ||
| 214 | ></textarea> | ||
| 215 | |||
| 216 | <div class="mt-2 flex items-center justify-between"> | ||
| 217 | <div class="flex items-center space-x-2"> | ||
| 218 | <Checkbox | ||
| 219 | id="branch-after-edit" | ||
| 220 | bind:checked={shouldBranchAfterEdit} | ||
| 221 | onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)} | ||
| 222 | /> | ||
| 223 | <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground"> | ||
| 224 | Branch conversation after edit | ||
| 225 | </Label> | ||
| 226 | </div> | ||
| 227 | <div class="flex gap-2"> | ||
| 228 | <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline"> | ||
| 229 | <X class="mr-1 h-3 w-3" /> | ||
| 230 | Cancel | ||
| 231 | </Button> | ||
| 232 | |||
| 233 | <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm"> | ||
| 234 | <Check class="mr-1 h-3 w-3" /> | ||
| 235 | Save | ||
| 236 | </Button> | ||
| 237 | </div> | ||
| 238 | </div> | ||
| 239 | </div> | ||
| 240 | {:else if message.role === 'assistant'} | ||
| 241 | {#if config().disableReasoningFormat} | ||
| 242 | <pre class="raw-output">{messageContent || ''}</pre> | ||
| 243 | {:else} | ||
| 244 | <MarkdownContent content={messageContent || ''} /> | ||
| 245 | {/if} | ||
| 246 | {:else} | ||
| 247 | <div class="text-sm whitespace-pre-wrap"> | ||
| 248 | {messageContent} | ||
| 249 | </div> | ||
| 250 | {/if} | ||
| 251 | |||
| 252 | <div class="info my-6 grid gap-4 tabular-nums"> | ||
| 253 | {#if displayedModel()} | ||
| 254 | <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"> | ||
| 255 | {#if isRouter} | ||
| 256 | <ModelsSelector | ||
| 257 | currentModel={displayedModel()} | ||
| 258 | onModelChange={handleModelChange} | ||
| 259 | disabled={isLoading()} | ||
| 260 | upToMessageId={message.id} | ||
| 261 | /> | ||
| 262 | {:else} | ||
| 263 | <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} /> | ||
| 264 | {/if} | ||
| 265 | |||
| 266 | {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} | ||
| 267 | <ChatMessageStatistics | ||
| 268 | promptTokens={message.timings.prompt_n} | ||
| 269 | promptMs={message.timings.prompt_ms} | ||
| 270 | predictedTokens={message.timings.predicted_n} | ||
| 271 | predictedMs={message.timings.predicted_ms} | ||
| 272 | /> | ||
| 273 | {:else if isLoading() && currentConfig.showMessageStats} | ||
| 274 | {@const liveStats = processingState.getLiveProcessingStats()} | ||
| 275 | {@const genStats = processingState.getLiveGenerationStats()} | ||
| 276 | {@const promptProgress = processingState.processingState?.promptProgress} | ||
| 277 | {@const isStillProcessingPrompt = | ||
| 278 | promptProgress && promptProgress.processed < promptProgress.total} | ||
| 279 | |||
| 280 | {#if liveStats || genStats} | ||
| 281 | <ChatMessageStatistics | ||
| 282 | isLive={true} | ||
| 283 | isProcessingPrompt={!!isStillProcessingPrompt} | ||
| 284 | promptTokens={liveStats?.tokensProcessed} | ||
| 285 | promptMs={liveStats?.timeMs} | ||
| 286 | predictedTokens={genStats?.tokensGenerated} | ||
| 287 | predictedMs={genStats?.timeMs} | ||
| 288 | /> | ||
| 289 | {/if} | ||
| 290 | {/if} | ||
| 291 | </div> | ||
| 292 | {/if} | ||
| 293 | |||
| 294 | {#if config().showToolCalls} | ||
| 295 | {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls} | ||
| 296 | <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> | ||
| 297 | <span class="inline-flex items-center gap-1"> | ||
| 298 | <Wrench class="h-3.5 w-3.5" /> | ||
| 299 | |||
| 300 | <span>Tool calls:</span> | ||
| 301 | </span> | ||
| 302 | |||
| 303 | {#if toolCalls && toolCalls.length > 0} | ||
| 304 | {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)} | ||
| 305 | {@const badge = formatToolCallBadge(toolCall, index)} | ||
| 306 | <button | ||
| 307 | type="button" | ||
| 308 | class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75" | ||
| 309 | title={badge.tooltip} | ||
| 310 | aria-label={`Copy tool call ${badge.label}`} | ||
| 311 | onclick={() => handleCopyToolCall(badge.copyValue)} | ||
| 312 | > | ||
| 313 | {badge.label} | ||
| 314 | <CopyToClipboardIcon | ||
| 315 | text={badge.copyValue} | ||
| 316 | ariaLabel={`Copy tool call ${badge.label}`} | ||
| 317 | /> | ||
| 318 | </button> | ||
| 319 | {/each} | ||
| 320 | {:else if fallbackToolCalls} | ||
| 321 | <button | ||
| 322 | type="button" | ||
| 323 | class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75" | ||
| 324 | title={fallbackToolCalls} | ||
| 325 | aria-label="Copy tool call payload" | ||
| 326 | onclick={() => handleCopyToolCall(fallbackToolCalls)} | ||
| 327 | > | ||
| 328 | {fallbackToolCalls} | ||
| 329 | <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" /> | ||
| 330 | </button> | ||
| 331 | {/if} | ||
| 332 | </span> | ||
| 333 | {/if} | ||
| 334 | {/if} | ||
| 335 | </div> | ||
| 336 | |||
| 337 | {#if message.timestamp && !isEditing} | ||
| 338 | <ChatMessageActions | ||
| 339 | role="assistant" | ||
| 340 | justify="start" | ||
| 341 | actionsPosition="left" | ||
| 342 | {siblingInfo} | ||
| 343 | {showDeleteDialog} | ||
| 344 | {deletionInfo} | ||
| 345 | {onCopy} | ||
| 346 | {onEdit} | ||
| 347 | {onRegenerate} | ||
| 348 | onContinue={currentConfig.enableContinueGeneration && !thinkingContent | ||
| 349 | ? onContinue | ||
| 350 | : undefined} | ||
| 351 | {onDelete} | ||
| 352 | {onConfirmDelete} | ||
| 353 | {onNavigateToSibling} | ||
| 354 | {onShowDeleteDialogChange} | ||
| 355 | /> | ||
| 356 | {/if} | ||
| 357 | </div> | ||
| 358 | |||
| 359 | <style> | ||
| 360 | .processing-container { | ||
| 361 | display: flex; | ||
| 362 | flex-direction: column; | ||
| 363 | align-items: flex-start; | ||
| 364 | gap: 0.5rem; | ||
| 365 | } | ||
| 366 | |||
| 367 | .processing-text { | ||
| 368 | background: linear-gradient( | ||
| 369 | 90deg, | ||
| 370 | var(--muted-foreground), | ||
| 371 | var(--foreground), | ||
| 372 | var(--muted-foreground) | ||
| 373 | ); | ||
| 374 | background-size: 200% 100%; | ||
| 375 | background-clip: text; | ||
| 376 | -webkit-background-clip: text; | ||
| 377 | -webkit-text-fill-color: transparent; | ||
| 378 | animation: shine 1s linear infinite; | ||
| 379 | font-weight: 500; | ||
| 380 | font-size: 0.875rem; | ||
| 381 | } | ||
| 382 | |||
| 383 | @keyframes shine { | ||
| 384 | to { | ||
| 385 | background-position: -200% 0; | ||
| 386 | } | ||
| 387 | } | ||
| 388 | |||
| 389 | .raw-output { | ||
| 390 | width: 100%; | ||
| 391 | max-width: 48rem; | ||
| 392 | margin-top: 1.5rem; | ||
| 393 | padding: 1rem 1.25rem; | ||
| 394 | border-radius: 1rem; | ||
| 395 | background: hsl(var(--muted) / 0.3); | ||
| 396 | color: var(--foreground); | ||
| 397 | font-family: | ||
| 398 | ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, | ||
| 399 | 'Liberation Mono', Menlo, monospace; | ||
| 400 | font-size: 0.875rem; | ||
| 401 | line-height: 1.6; | ||
| 402 | white-space: pre-wrap; | ||
| 403 | word-break: break-word; | ||
| 404 | } | ||
| 405 | |||
| 406 | .tool-call-badge { | ||
| 407 | max-width: 12rem; | ||
| 408 | white-space: nowrap; | ||
| 409 | overflow: hidden; | ||
| 410 | text-overflow: ellipsis; | ||
| 411 | } | ||
| 412 | |||
| 413 | .tool-call-badge--fallback { | ||
| 414 | max-width: 20rem; | ||
| 415 | white-space: normal; | ||
| 416 | word-break: break-word; | ||
| 417 | } | ||
| 418 | </style> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte new file mode 100644 index 0000000..7420bb1 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageBranchingControls.svelte | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { ChevronLeft, ChevronRight } from '@lucide/svelte'; | ||
| 3 | import { Button } from '$lib/components/ui/button'; | ||
| 4 | import * as Tooltip from '$lib/components/ui/tooltip'; | ||
| 5 | |||
| 6 | interface Props { | ||
| 7 | class?: string; | ||
| 8 | siblingInfo: ChatMessageSiblingInfo | null; | ||
| 9 | onNavigateToSibling?: (siblingId: string) => void; | ||
| 10 | } | ||
| 11 | |||
| 12 | let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props(); | ||
| 13 | |||
| 14 | let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0); | ||
| 15 | let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1); | ||
| 16 | let nextSiblingId = $derived( | ||
| 17 | hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null | ||
| 18 | ); | ||
| 19 | let previousSiblingId = $derived( | ||
| 20 | hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null | ||
| 21 | ); | ||
| 22 | |||
| 23 | function handleNext() { | ||
| 24 | if (nextSiblingId) { | ||
| 25 | onNavigateToSibling?.(nextSiblingId); | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | function handlePrevious() { | ||
| 30 | if (previousSiblingId) { | ||
| 31 | onNavigateToSibling?.(previousSiblingId); | ||
| 32 | } | ||
| 33 | } | ||
| 34 | </script> | ||
| 35 | |||
| 36 | {#if siblingInfo && siblingInfo.totalSiblings > 1} | ||
| 37 | <div | ||
| 38 | aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}" | ||
| 39 | class="flex items-center gap-1 text-xs text-muted-foreground {className}" | ||
| 40 | role="navigation" | ||
| 41 | > | ||
| 42 | <Tooltip.Root> | ||
| 43 | <Tooltip.Trigger> | ||
| 44 | <Button | ||
| 45 | aria-label="Previous message version" | ||
| 46 | class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}" | ||
| 47 | disabled={!hasPrevious} | ||
| 48 | onclick={handlePrevious} | ||
| 49 | size="sm" | ||
| 50 | variant="ghost" | ||
| 51 | > | ||
| 52 | <ChevronLeft class="h-3 w-3" /> | ||
| 53 | </Button> | ||
| 54 | </Tooltip.Trigger> | ||
| 55 | |||
| 56 | <Tooltip.Content> | ||
| 57 | <p>Previous version</p> | ||
| 58 | </Tooltip.Content> | ||
| 59 | </Tooltip.Root> | ||
| 60 | |||
| 61 | <span class="px-1 font-mono text-xs"> | ||
| 62 | {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings} | ||
| 63 | </span> | ||
| 64 | |||
| 65 | <Tooltip.Root> | ||
| 66 | <Tooltip.Trigger> | ||
| 67 | <Button | ||
| 68 | aria-label="Next message version" | ||
| 69 | class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}" | ||
| 70 | disabled={!hasNext} | ||
| 71 | onclick={handleNext} | ||
| 72 | size="sm" | ||
| 73 | variant="ghost" | ||
| 74 | > | ||
| 75 | <ChevronRight class="h-3 w-3" /> | ||
| 76 | </Button> | ||
| 77 | </Tooltip.Trigger> | ||
| 78 | |||
| 79 | <Tooltip.Content> | ||
| 80 | <p>Next version</p> | ||
| 81 | </Tooltip.Content> | ||
| 82 | </Tooltip.Root> | ||
| 83 | </div> | ||
| 84 | {/if} | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte new file mode 100644 index 0000000..f812ea2 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte | |||
| @@ -0,0 +1,391 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte'; | ||
| 3 | import { Button } from '$lib/components/ui/button'; | ||
| 4 | import { Switch } from '$lib/components/ui/switch'; | ||
| 5 | import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app'; | ||
| 6 | import { INPUT_CLASSES } from '$lib/constants/input-classes'; | ||
| 7 | import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; | ||
| 8 | import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums'; | ||
| 9 | import { config } from '$lib/stores/settings.svelte'; | ||
| 10 | import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte'; | ||
| 11 | import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte'; | ||
| 12 | import { conversationsStore } from '$lib/stores/conversations.svelte'; | ||
| 13 | import { modelsStore } from '$lib/stores/models.svelte'; | ||
| 14 | import { isRouterMode } from '$lib/stores/server.svelte'; | ||
| 15 | import { | ||
| 16 | autoResizeTextarea, | ||
| 17 | getFileTypeCategory, | ||
| 18 | getFileTypeCategoryByExtension, | ||
| 19 | parseClipboardContent | ||
| 20 | } from '$lib/utils'; | ||
| 21 | |||
| 22 | interface Props { | ||
| 23 | messageId: string; | ||
| 24 | editedContent: string; | ||
| 25 | editedExtras?: DatabaseMessageExtra[]; | ||
| 26 | editedUploadedFiles?: ChatUploadedFile[]; | ||
| 27 | originalContent: string; | ||
| 28 | originalExtras?: DatabaseMessageExtra[]; | ||
| 29 | showSaveOnlyOption?: boolean; | ||
| 30 | onCancelEdit: () => void; | ||
| 31 | onSaveEdit: () => void; | ||
| 32 | onSaveEditOnly?: () => void; | ||
| 33 | onEditKeydown: (event: KeyboardEvent) => void; | ||
| 34 | onEditedContentChange: (content: string) => void; | ||
| 35 | onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void; | ||
| 36 | onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void; | ||
| 37 | textareaElement?: HTMLTextAreaElement; | ||
| 38 | } | ||
| 39 | |||
| 40 | let { | ||
| 41 | messageId, | ||
| 42 | editedContent, | ||
| 43 | editedExtras = [], | ||
| 44 | editedUploadedFiles = [], | ||
| 45 | originalContent, | ||
| 46 | originalExtras = [], | ||
| 47 | showSaveOnlyOption = false, | ||
| 48 | onCancelEdit, | ||
| 49 | onSaveEdit, | ||
| 50 | onSaveEditOnly, | ||
| 51 | onEditKeydown, | ||
| 52 | onEditedContentChange, | ||
| 53 | onEditedExtrasChange, | ||
| 54 | onEditedUploadedFilesChange, | ||
| 55 | textareaElement = $bindable() | ||
| 56 | }: Props = $props(); | ||
| 57 | |||
| 58 | let fileInputElement: HTMLInputElement | undefined = $state(); | ||
| 59 | let saveWithoutRegenerate = $state(false); | ||
| 60 | let showDiscardDialog = $state(false); | ||
| 61 | let isRouter = $derived(isRouterMode()); | ||
| 62 | let currentConfig = $derived(config()); | ||
| 63 | |||
| 64 | let pasteLongTextToFileLength = $derived.by(() => { | ||
| 65 | const n = Number(currentConfig.pasteLongTextToFileLen); | ||
| 66 | |||
| 67 | return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n; | ||
| 68 | }); | ||
| 69 | |||
| 70 | let hasUnsavedChanges = $derived.by(() => { | ||
| 71 | if (editedContent !== originalContent) return true; | ||
| 72 | if (editedUploadedFiles.length > 0) return true; | ||
| 73 | |||
| 74 | const extrasChanged = | ||
| 75 | editedExtras.length !== originalExtras.length || | ||
| 76 | editedExtras.some((extra, i) => extra !== originalExtras[i]); | ||
| 77 | |||
| 78 | if (extrasChanged) return true; | ||
| 79 | |||
| 80 | return false; | ||
| 81 | }); | ||
| 82 | |||
| 83 | let hasAttachments = $derived( | ||
| 84 | (editedExtras && editedExtras.length > 0) || | ||
| 85 | (editedUploadedFiles && editedUploadedFiles.length > 0) | ||
| 86 | ); | ||
| 87 | |||
| 88 | let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments); | ||
| 89 | |||
| 90 | function getEditedAttachmentsModalities(): ModelModalities { | ||
| 91 | const modalities: ModelModalities = { vision: false, audio: false }; | ||
| 92 | |||
| 93 | for (const extra of editedExtras) { | ||
| 94 | if (extra.type === AttachmentType.IMAGE) { | ||
| 95 | modalities.vision = true; | ||
| 96 | } | ||
| 97 | |||
| 98 | if ( | ||
| 99 | extra.type === AttachmentType.PDF && | ||
| 100 | 'processedAsImages' in extra && | ||
| 101 | extra.processedAsImages | ||
| 102 | ) { | ||
| 103 | modalities.vision = true; | ||
| 104 | } | ||
| 105 | |||
| 106 | if (extra.type === AttachmentType.AUDIO) { | ||
| 107 | modalities.audio = true; | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | for (const file of editedUploadedFiles) { | ||
| 112 | const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name); | ||
| 113 | if (category === FileTypeCategory.IMAGE) { | ||
| 114 | modalities.vision = true; | ||
| 115 | } | ||
| 116 | if (category === FileTypeCategory.AUDIO) { | ||
| 117 | modalities.audio = true; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | return modalities; | ||
| 122 | } | ||
| 123 | |||
| 124 | function getRequiredModalities(): ModelModalities { | ||
| 125 | const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId); | ||
| 126 | const editedModalities = getEditedAttachmentsModalities(); | ||
| 127 | |||
| 128 | return { | ||
| 129 | vision: beforeModalities.vision || editedModalities.vision, | ||
| 130 | audio: beforeModalities.audio || editedModalities.audio | ||
| 131 | }; | ||
| 132 | } | ||
| 133 | |||
| 134 | const { handleModelChange } = useModelChangeValidation({ | ||
| 135 | getRequiredModalities, | ||
| 136 | onValidationFailure: async (previousModelId) => { | ||
| 137 | if (previousModelId) { | ||
| 138 | await modelsStore.selectModelById(previousModelId); | ||
| 139 | } | ||
| 140 | } | ||
| 141 | }); | ||
| 142 | |||
| 143 | function handleFileInputChange(event: Event) { | ||
| 144 | const input = event.target as HTMLInputElement; | ||
| 145 | if (!input.files || input.files.length === 0) return; | ||
| 146 | |||
| 147 | const files = Array.from(input.files); | ||
| 148 | |||
| 149 | processNewFiles(files); | ||
| 150 | input.value = ''; | ||
| 151 | } | ||
| 152 | |||
| 153 | function handleGlobalKeydown(event: KeyboardEvent) { | ||
| 154 | if (event.key === 'Escape') { | ||
| 155 | event.preventDefault(); | ||
| 156 | attemptCancel(); | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | function attemptCancel() { | ||
| 161 | if (hasUnsavedChanges) { | ||
| 162 | showDiscardDialog = true; | ||
| 163 | } else { | ||
| 164 | onCancelEdit(); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | function handleRemoveExistingAttachment(index: number) { | ||
| 169 | if (!onEditedExtrasChange) return; | ||
| 170 | |||
| 171 | const newExtras = [...editedExtras]; | ||
| 172 | |||
| 173 | newExtras.splice(index, 1); | ||
| 174 | onEditedExtrasChange(newExtras); | ||
| 175 | } | ||
| 176 | |||
| 177 | function handleRemoveUploadedFile(fileId: string) { | ||
| 178 | if (!onEditedUploadedFilesChange) return; | ||
| 179 | |||
| 180 | const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId); | ||
| 181 | |||
| 182 | onEditedUploadedFilesChange(newFiles); | ||
| 183 | } | ||
| 184 | |||
| 185 | function handleSubmit() { | ||
| 186 | if (!canSubmit) return; | ||
| 187 | |||
| 188 | if (saveWithoutRegenerate && onSaveEditOnly) { | ||
| 189 | onSaveEditOnly(); | ||
| 190 | } else { | ||
| 191 | onSaveEdit(); | ||
| 192 | } | ||
| 193 | |||
| 194 | saveWithoutRegenerate = false; | ||
| 195 | } | ||
| 196 | |||
| 197 | async function processNewFiles(files: File[]) { | ||
| 198 | if (!onEditedUploadedFilesChange) return; | ||
| 199 | |||
| 200 | const { processFilesToChatUploaded } = await import('$lib/utils/browser-only'); | ||
| 201 | const processed = await processFilesToChatUploaded(files); | ||
| 202 | |||
| 203 | onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]); | ||
| 204 | } | ||
| 205 | |||
| 206 | function handlePaste(event: ClipboardEvent) { | ||
| 207 | if (!event.clipboardData) return; | ||
| 208 | |||
| 209 | const files = Array.from(event.clipboardData.items) | ||
| 210 | .filter((item) => item.kind === 'file') | ||
| 211 | .map((item) => item.getAsFile()) | ||
| 212 | .filter((file): file is File => file !== null); | ||
| 213 | |||
| 214 | if (files.length > 0) { | ||
| 215 | event.preventDefault(); | ||
| 216 | processNewFiles(files); | ||
| 217 | |||
| 218 | return; | ||
| 219 | } | ||
| 220 | |||
| 221 | const text = event.clipboardData.getData(MimeTypeText.PLAIN); | ||
| 222 | |||
| 223 | if (text.startsWith('"')) { | ||
| 224 | const parsed = parseClipboardContent(text); | ||
| 225 | |||
| 226 | if (parsed.textAttachments.length > 0) { | ||
| 227 | event.preventDefault(); | ||
| 228 | onEditedContentChange(parsed.message); | ||
| 229 | |||
| 230 | const attachmentFiles = parsed.textAttachments.map( | ||
| 231 | (att) => | ||
| 232 | new File([att.content], att.name, { | ||
| 233 | type: MimeTypeText.PLAIN | ||
| 234 | }) | ||
| 235 | ); | ||
| 236 | |||
| 237 | processNewFiles(attachmentFiles); | ||
| 238 | |||
| 239 | setTimeout(() => { | ||
| 240 | textareaElement?.focus(); | ||
| 241 | }, 10); | ||
| 242 | |||
| 243 | return; | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 247 | if ( | ||
| 248 | text.length > 0 && | ||
| 249 | pasteLongTextToFileLength > 0 && | ||
| 250 | text.length > pasteLongTextToFileLength | ||
| 251 | ) { | ||
| 252 | event.preventDefault(); | ||
| 253 | |||
| 254 | const textFile = new File([text], 'Pasted', { | ||
| 255 | type: MimeTypeText.PLAIN | ||
| 256 | }); | ||
| 257 | |||
| 258 | processNewFiles([textFile]); | ||
| 259 | } | ||
| 260 | } | ||
| 261 | |||
| 262 | $effect(() => { | ||
| 263 | if (textareaElement) { | ||
| 264 | autoResizeTextarea(textareaElement); | ||
| 265 | } | ||
| 266 | }); | ||
| 267 | |||
| 268 | $effect(() => { | ||
| 269 | setEditModeActive(processNewFiles); | ||
| 270 | |||
| 271 | return () => { | ||
| 272 | clearEditMode(); | ||
| 273 | }; | ||
| 274 | }); | ||
| 275 | </script> | ||
| 276 | |||
| 277 | <svelte:window onkeydown={handleGlobalKeydown} /> | ||
| 278 | |||
| 279 | <input | ||
| 280 | bind:this={fileInputElement} | ||
| 281 | type="file" | ||
| 282 | multiple | ||
| 283 | class="hidden" | ||
| 284 | onchange={handleFileInputChange} | ||
| 285 | /> | ||
| 286 | |||
| 287 | <div | ||
| 288 | class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md" | ||
| 289 | data-slot="edit-form" | ||
| 290 | > | ||
| 291 | <ChatAttachmentsList | ||
| 292 | attachments={editedExtras} | ||
| 293 | uploadedFiles={editedUploadedFiles} | ||
| 294 | readonly={false} | ||
| 295 | onFileRemove={(fileId) => { | ||
| 296 | if (fileId.startsWith('attachment-')) { | ||
| 297 | const index = parseInt(fileId.replace('attachment-', ''), 10); | ||
| 298 | if (!isNaN(index) && index >= 0 && index < editedExtras.length) { | ||
| 299 | handleRemoveExistingAttachment(index); | ||
| 300 | } | ||
| 301 | } else { | ||
| 302 | handleRemoveUploadedFile(fileId); | ||
| 303 | } | ||
| 304 | }} | ||
| 305 | limitToSingleRow | ||
| 306 | class="py-5" | ||
| 307 | style="scroll-padding: 1rem;" | ||
| 308 | /> | ||
| 309 | |||
| 310 | <div class="relative min-h-[48px] px-5 py-3"> | ||
| 311 | <textarea | ||
| 312 | bind:this={textareaElement} | ||
| 313 | bind:value={editedContent} | ||
| 314 | class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none" | ||
| 315 | onkeydown={onEditKeydown} | ||
| 316 | oninput={(e) => { | ||
| 317 | autoResizeTextarea(e.currentTarget); | ||
| 318 | onEditedContentChange(e.currentTarget.value); | ||
| 319 | }} | ||
| 320 | onpaste={handlePaste} | ||
| 321 | placeholder="Edit your message..." | ||
| 322 | ></textarea> | ||
| 323 | |||
| 324 | <div class="flex w-full items-center gap-3" style="container-type: inline-size"> | ||
| 325 | <Button | ||
| 326 | class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground" | ||
| 327 | onclick={() => fileInputElement?.click()} | ||
| 328 | type="button" | ||
| 329 | title="Add attachment" | ||
| 330 | > | ||
| 331 | <span class="sr-only">Attach files</span> | ||
| 332 | |||
| 333 | <Paperclip class="h-4 w-4" /> | ||
| 334 | </Button> | ||
| 335 | |||
| 336 | <div class="flex-1"></div> | ||
| 337 | |||
| 338 | {#if isRouter} | ||
| 339 | <ModelsSelector | ||
| 340 | forceForegroundText={true} | ||
| 341 | useGlobalSelection={true} | ||
| 342 | onModelChange={handleModelChange} | ||
| 343 | /> | ||
| 344 | {/if} | ||
| 345 | |||
| 346 | <Button | ||
| 347 | class="h-8 w-8 shrink-0 rounded-full p-0" | ||
| 348 | onclick={handleSubmit} | ||
| 349 | disabled={!canSubmit} | ||
| 350 | type="button" | ||
| 351 | title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'} | ||
| 352 | > | ||
| 353 | <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span> | ||
| 354 | |||
| 355 | <ArrowUp class="h-5 w-5" /> | ||
| 356 | </Button> | ||
| 357 | </div> | ||
| 358 | </div> | ||
| 359 | </div> | ||
| 360 | |||
| 361 | <div class="mt-2 flex w-full max-w-[80%] items-center justify-between"> | ||
| 362 | {#if showSaveOnlyOption && onSaveEditOnly} | ||
| 363 | <div class="flex items-center gap-2"> | ||
| 364 | <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" /> | ||
| 365 | |||
| 366 | <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground"> | ||
| 367 | Update without re-sending | ||
| 368 | </label> | ||
| 369 | </div> | ||
| 370 | {:else} | ||
| 371 | <div></div> | ||
| 372 | {/if} | ||
| 373 | |||
| 374 | <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost"> | ||
| 375 | <X class="mr-1 h-3 w-3" /> | ||
| 376 | |||
| 377 | Cancel | ||
| 378 | </Button> | ||
| 379 | </div> | ||
| 380 | |||
| 381 | <DialogConfirmation | ||
| 382 | bind:open={showDiscardDialog} | ||
| 383 | title="Discard changes?" | ||
| 384 | description="You have unsaved changes. Are you sure you want to discard them?" | ||
| 385 | confirmText="Discard" | ||
| 386 | cancelText="Keep editing" | ||
| 387 | variant="destructive" | ||
| 388 | icon={AlertTriangle} | ||
| 389 | onConfirm={onCancelEdit} | ||
| 390 | onCancel={() => (showDiscardDialog = false)} | ||
| 391 | /> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte new file mode 100644 index 0000000..24fe592 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte | |||
| @@ -0,0 +1,175 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte'; | ||
| 3 | import { BadgeChatStatistic } from '$lib/components/app'; | ||
| 4 | import * as Tooltip from '$lib/components/ui/tooltip'; | ||
| 5 | import { ChatMessageStatsView } from '$lib/enums'; | ||
| 6 | |||
| 7 | interface Props { | ||
| 8 | predictedTokens?: number; | ||
| 9 | predictedMs?: number; | ||
| 10 | promptTokens?: number; | ||
| 11 | promptMs?: number; | ||
| 12 | // Live mode: when true, shows stats during streaming | ||
| 13 | isLive?: boolean; | ||
| 14 | // Whether prompt processing is still in progress | ||
| 15 | isProcessingPrompt?: boolean; | ||
| 16 | // Initial view to show (defaults to READING in live mode) | ||
| 17 | initialView?: ChatMessageStatsView; | ||
| 18 | } | ||
| 19 | |||
| 20 | let { | ||
| 21 | predictedTokens, | ||
| 22 | predictedMs, | ||
| 23 | promptTokens, | ||
| 24 | promptMs, | ||
| 25 | isLive = false, | ||
| 26 | isProcessingPrompt = false, | ||
| 27 | initialView = ChatMessageStatsView.GENERATION | ||
| 28 | }: Props = $props(); | ||
| 29 | |||
| 30 | let activeView: ChatMessageStatsView = $state(initialView); | ||
| 31 | let hasAutoSwitchedToGeneration = $state(false); | ||
| 32 | |||
| 33 | // In live mode: auto-switch to GENERATION tab when prompt processing completes | ||
| 34 | $effect(() => { | ||
| 35 | if (isLive) { | ||
| 36 | // Auto-switch to generation tab only when prompt processing is done (once) | ||
| 37 | if ( | ||
| 38 | !hasAutoSwitchedToGeneration && | ||
| 39 | !isProcessingPrompt && | ||
| 40 | predictedTokens && | ||
| 41 | predictedTokens > 0 | ||
| 42 | ) { | ||
| 43 | activeView = ChatMessageStatsView.GENERATION; | ||
| 44 | hasAutoSwitchedToGeneration = true; | ||
| 45 | } else if (!hasAutoSwitchedToGeneration) { | ||
| 46 | // Stay on READING while prompt is still being processed | ||
| 47 | activeView = ChatMessageStatsView.READING; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | }); | ||
| 51 | |||
| 52 | let hasGenerationStats = $derived( | ||
| 53 | predictedTokens !== undefined && | ||
| 54 | predictedTokens > 0 && | ||
| 55 | predictedMs !== undefined && | ||
| 56 | predictedMs > 0 | ||
| 57 | ); | ||
| 58 | |||
| 59 | let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0); | ||
| 60 | let timeInSeconds = $derived( | ||
| 61 | predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00' | ||
| 62 | ); | ||
| 63 | |||
| 64 | let promptTokensPerSecond = $derived( | ||
| 65 | promptTokens !== undefined && promptMs !== undefined && promptMs > 0 | ||
| 66 | ? (promptTokens / promptMs) * 1000 | ||
| 67 | : undefined | ||
| 68 | ); | ||
| 69 | |||
| 70 | let promptTimeInSeconds = $derived( | ||
| 71 | promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined | ||
| 72 | ); | ||
| 73 | |||
| 74 | let hasPromptStats = $derived( | ||
| 75 | promptTokens !== undefined && | ||
| 76 | promptMs !== undefined && | ||
| 77 | promptTokensPerSecond !== undefined && | ||
| 78 | promptTimeInSeconds !== undefined | ||
| 79 | ); | ||
| 80 | |||
| 81 | // In live mode, generation tab is disabled until we have generation stats | ||
| 82 | let isGenerationDisabled = $derived(isLive && !hasGenerationStats); | ||
| 83 | </script> | ||
| 84 | |||
| 85 | <div class="inline-flex items-center text-xs text-muted-foreground"> | ||
| 86 | <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5"> | ||
| 87 | {#if hasPromptStats || isLive} | ||
| 88 | <Tooltip.Root> | ||
| 89 | <Tooltip.Trigger> | ||
| 90 | <button | ||
| 91 | type="button" | ||
| 92 | class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === | ||
| 93 | ChatMessageStatsView.READING | ||
| 94 | ? 'bg-background text-foreground shadow-sm' | ||
| 95 | : 'hover:text-foreground'}" | ||
| 96 | onclick={() => (activeView = ChatMessageStatsView.READING)} | ||
| 97 | > | ||
| 98 | <BookOpenText class="h-3 w-3" /> | ||
| 99 | <span class="sr-only">Reading</span> | ||
| 100 | </button> | ||
| 101 | </Tooltip.Trigger> | ||
| 102 | <Tooltip.Content> | ||
| 103 | <p>Reading (prompt processing)</p> | ||
| 104 | </Tooltip.Content> | ||
| 105 | </Tooltip.Root> | ||
| 106 | {/if} | ||
| 107 | <Tooltip.Root> | ||
| 108 | <Tooltip.Trigger> | ||
| 109 | <button | ||
| 110 | type="button" | ||
| 111 | class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === | ||
| 112 | ChatMessageStatsView.GENERATION | ||
| 113 | ? 'bg-background text-foreground shadow-sm' | ||
| 114 | : isGenerationDisabled | ||
| 115 | ? 'cursor-not-allowed opacity-40' | ||
| 116 | : 'hover:text-foreground'}" | ||
| 117 | onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)} | ||
| 118 | disabled={isGenerationDisabled} | ||
| 119 | > | ||
| 120 | <Sparkles class="h-3 w-3" /> | ||
| 121 | <span class="sr-only">Generation</span> | ||
| 122 | </button> | ||
| 123 | </Tooltip.Trigger> | ||
| 124 | <Tooltip.Content> | ||
| 125 | <p> | ||
| 126 | {isGenerationDisabled | ||
| 127 | ? 'Generation (waiting for tokens...)' | ||
| 128 | : 'Generation (token output)'} | ||
| 129 | </p> | ||
| 130 | </Tooltip.Content> | ||
| 131 | </Tooltip.Root> | ||
| 132 | </div> | ||
| 133 | |||
| 134 | <div class="flex items-center gap-1 px-2"> | ||
| 135 | {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats} | ||
| 136 | <BadgeChatStatistic | ||
| 137 | class="bg-transparent" | ||
| 138 | icon={WholeWord} | ||
| 139 | value="{predictedTokens?.toLocaleString()} tokens" | ||
| 140 | tooltipLabel="Generated tokens" | ||
| 141 | /> | ||
| 142 | <BadgeChatStatistic | ||
| 143 | class="bg-transparent" | ||
| 144 | icon={Clock} | ||
| 145 | value="{timeInSeconds}s" | ||
| 146 | tooltipLabel="Generation time" | ||
| 147 | /> | ||
| 148 | <BadgeChatStatistic | ||
| 149 | class="bg-transparent" | ||
| 150 | icon={Gauge} | ||
| 151 | value="{tokensPerSecond.toFixed(2)} tokens/s" | ||
| 152 | tooltipLabel="Generation speed" | ||
| 153 | /> | ||
| 154 | {:else if hasPromptStats} | ||
| 155 | <BadgeChatStatistic | ||
| 156 | class="bg-transparent" | ||
| 157 | icon={WholeWord} | ||
| 158 | value="{promptTokens} tokens" | ||
| 159 | tooltipLabel="Prompt tokens" | ||
| 160 | /> | ||
| 161 | <BadgeChatStatistic | ||
| 162 | class="bg-transparent" | ||
| 163 | icon={Clock} | ||
| 164 | value="{promptTimeInSeconds}s" | ||
| 165 | tooltipLabel="Prompt processing time" | ||
| 166 | /> | ||
| 167 | <BadgeChatStatistic | ||
| 168 | class="bg-transparent" | ||
| 169 | icon={Gauge} | ||
| 170 | value="{promptTokensPerSecond!.toFixed(2)} tokens/s" | ||
| 171 | tooltipLabel="Prompt processing speed" | ||
| 172 | /> | ||
| 173 | {/if} | ||
| 174 | </div> | ||
| 175 | </div> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte new file mode 100644 index 0000000..c203822 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte | |||
| @@ -0,0 +1,216 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Check, X } from '@lucide/svelte'; | ||
| 3 | import { Card } from '$lib/components/ui/card'; | ||
| 4 | import { Button } from '$lib/components/ui/button'; | ||
| 5 | import { MarkdownContent } from '$lib/components/app'; | ||
| 6 | import { INPUT_CLASSES } from '$lib/constants/input-classes'; | ||
| 7 | import { config } from '$lib/stores/settings.svelte'; | ||
| 8 | import ChatMessageActions from './ChatMessageActions.svelte'; | ||
| 9 | |||
| 10 | interface Props { | ||
| 11 | class?: string; | ||
| 12 | message: DatabaseMessage; | ||
| 13 | isEditing: boolean; | ||
| 14 | editedContent: string; | ||
| 15 | siblingInfo?: ChatMessageSiblingInfo | null; | ||
| 16 | showDeleteDialog: boolean; | ||
| 17 | deletionInfo: { | ||
| 18 | totalCount: number; | ||
| 19 | userMessages: number; | ||
| 20 | assistantMessages: number; | ||
| 21 | messageTypes: string[]; | ||
| 22 | } | null; | ||
| 23 | onCancelEdit: () => void; | ||
| 24 | onSaveEdit: () => void; | ||
| 25 | onEditKeydown: (event: KeyboardEvent) => void; | ||
| 26 | onEditedContentChange: (content: string) => void; | ||
| 27 | onCopy: () => void; | ||
| 28 | onEdit: () => void; | ||
| 29 | onDelete: () => void; | ||
| 30 | onConfirmDelete: () => void; | ||
| 31 | onNavigateToSibling?: (siblingId: string) => void; | ||
| 32 | onShowDeleteDialogChange: (show: boolean) => void; | ||
| 33 | textareaElement?: HTMLTextAreaElement; | ||
| 34 | } | ||
| 35 | |||
| 36 | let { | ||
| 37 | class: className = '', | ||
| 38 | message, | ||
| 39 | isEditing, | ||
| 40 | editedContent, | ||
| 41 | siblingInfo = null, | ||
| 42 | showDeleteDialog, | ||
| 43 | deletionInfo, | ||
| 44 | onCancelEdit, | ||
| 45 | onSaveEdit, | ||
| 46 | onEditKeydown, | ||
| 47 | onEditedContentChange, | ||
| 48 | onCopy, | ||
| 49 | onEdit, | ||
| 50 | onDelete, | ||
| 51 | onConfirmDelete, | ||
| 52 | onNavigateToSibling, | ||
| 53 | onShowDeleteDialogChange, | ||
| 54 | textareaElement = $bindable() | ||
| 55 | }: Props = $props(); | ||
| 56 | |||
| 57 | let isMultiline = $state(false); | ||
| 58 | let messageElement: HTMLElement | undefined = $state(); | ||
| 59 | let isExpanded = $state(false); | ||
| 60 | let contentHeight = $state(0); | ||
| 61 | const MAX_HEIGHT = 200; // pixels | ||
| 62 | const currentConfig = config(); | ||
| 63 | |||
| 64 | let showExpandButton = $derived(contentHeight > MAX_HEIGHT); | ||
| 65 | |||
| 66 | $effect(() => { | ||
| 67 | if (!messageElement || !message.content.trim()) return; | ||
| 68 | |||
| 69 | if (message.content.includes('\n')) { | ||
| 70 | isMultiline = true; | ||
| 71 | } | ||
| 72 | |||
| 73 | const resizeObserver = new ResizeObserver((entries) => { | ||
| 74 | for (const entry of entries) { | ||
| 75 | const element = entry.target as HTMLElement; | ||
| 76 | const estimatedSingleLineHeight = 24; | ||
| 77 | |||
| 78 | isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5; | ||
| 79 | contentHeight = element.scrollHeight; | ||
| 80 | } | ||
| 81 | }); | ||
| 82 | |||
| 83 | resizeObserver.observe(messageElement); | ||
| 84 | |||
| 85 | return () => { | ||
| 86 | resizeObserver.disconnect(); | ||
| 87 | }; | ||
| 88 | }); | ||
| 89 | |||
| 90 | function toggleExpand() { | ||
| 91 | isExpanded = !isExpanded; | ||
| 92 | } | ||
| 93 | </script> | ||
| 94 | |||
| 95 | <div | ||
| 96 | aria-label="System message with actions" | ||
| 97 | class="group flex flex-col items-end gap-3 md:gap-2 {className}" | ||
| 98 | role="group" | ||
| 99 | > | ||
| 100 | {#if isEditing} | ||
| 101 | <div class="w-full max-w-[80%]"> | ||
| 102 | <textarea | ||
| 103 | bind:this={textareaElement} | ||
| 104 | bind:value={editedContent} | ||
| 105 | class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}" | ||
| 106 | onkeydown={onEditKeydown} | ||
| 107 | oninput={(e) => onEditedContentChange(e.currentTarget.value)} | ||
| 108 | placeholder="Edit system message..." | ||
| 109 | ></textarea> | ||
| 110 | |||
| 111 | <div class="mt-2 flex justify-end gap-2"> | ||
| 112 | <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline"> | ||
| 113 | <X class="mr-1 h-3 w-3" /> | ||
| 114 | Cancel | ||
| 115 | </Button> | ||
| 116 | |||
| 117 | <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm"> | ||
| 118 | <Check class="mr-1 h-3 w-3" /> | ||
| 119 | Send | ||
| 120 | </Button> | ||
| 121 | </div> | ||
| 122 | </div> | ||
| 123 | {:else} | ||
| 124 | {#if message.content.trim()} | ||
| 125 | <div class="relative max-w-[80%]"> | ||
| 126 | <button | ||
| 127 | class="group/expand w-full text-left {!isExpanded && showExpandButton | ||
| 128 | ? 'cursor-pointer' | ||
| 129 | : 'cursor-auto'}" | ||
| 130 | onclick={showExpandButton && !isExpanded ? toggleExpand : undefined} | ||
| 131 | type="button" | ||
| 132 | > | ||
| 133 | <Card | ||
| 134 | class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5" | ||
| 135 | data-multiline={isMultiline ? '' : undefined} | ||
| 136 | style="border: 2px dashed hsl(var(--border));" | ||
| 137 | > | ||
| 138 | <div | ||
| 139 | class="relative overflow-hidden transition-all duration-300 {isExpanded | ||
| 140 | ? 'cursor-text select-text' | ||
| 141 | : 'select-none'}" | ||
| 142 | style={!isExpanded && showExpandButton | ||
| 143 | ? `max-height: ${MAX_HEIGHT}px;` | ||
| 144 | : 'max-height: none;'} | ||
| 145 | > | ||
| 146 | {#if currentConfig.renderUserContentAsMarkdown} | ||
| 147 | <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}"> | ||
| 148 | <MarkdownContent class="markdown-system-content" content={message.content} /> | ||
| 149 | </div> | ||
| 150 | {:else} | ||
| 151 | <span | ||
| 152 | bind:this={messageElement} | ||
| 153 | class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}" | ||
| 154 | > | ||
| 155 | {message.content} | ||
| 156 | </span> | ||
| 157 | {/if} | ||
| 158 | |||
| 159 | {#if !isExpanded && showExpandButton} | ||
| 160 | <div | ||
| 161 | class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent" | ||
| 162 | ></div> | ||
| 163 | <div | ||
| 164 | class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100" | ||
| 165 | > | ||
| 166 | <Button | ||
| 167 | class="rounded-full px-4 py-1.5 text-xs shadow-md" | ||
| 168 | size="sm" | ||
| 169 | variant="outline" | ||
| 170 | > | ||
| 171 | Show full system message | ||
| 172 | </Button> | ||
| 173 | </div> | ||
| 174 | {/if} | ||
| 175 | </div> | ||
| 176 | |||
| 177 | {#if isExpanded && showExpandButton} | ||
| 178 | <div class="mb-2 flex justify-center"> | ||
| 179 | <Button | ||
| 180 | class="rounded-full px-4 py-1.5 text-xs" | ||
| 181 | onclick={(e) => { | ||
| 182 | e.stopPropagation(); | ||
| 183 | toggleExpand(); | ||
| 184 | }} | ||
| 185 | size="sm" | ||
| 186 | variant="outline" | ||
| 187 | > | ||
| 188 | Collapse System Message | ||
| 189 | </Button> | ||
| 190 | </div> | ||
| 191 | {/if} | ||
| 192 | </Card> | ||
| 193 | </button> | ||
| 194 | </div> | ||
| 195 | {/if} | ||
| 196 | |||
| 197 | {#if message.timestamp} | ||
| 198 | <div class="max-w-[80%]"> | ||
| 199 | <ChatMessageActions | ||
| 200 | actionsPosition="right" | ||
| 201 | {deletionInfo} | ||
| 202 | justify="end" | ||
| 203 | {onConfirmDelete} | ||
| 204 | {onCopy} | ||
| 205 | {onDelete} | ||
| 206 | {onEdit} | ||
| 207 | {onNavigateToSibling} | ||
| 208 | {onShowDeleteDialogChange} | ||
| 209 | {siblingInfo} | ||
| 210 | {showDeleteDialog} | ||
| 211 | role="user" | ||
| 212 | /> | ||
| 213 | </div> | ||
| 214 | {/if} | ||
| 215 | {/if} | ||
| 216 | </div> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte new file mode 100644 index 0000000..9245ad5 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageThinkingBlock.svelte | |||
| @@ -0,0 +1,68 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Brain } from '@lucide/svelte'; | ||
| 3 | import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; | ||
| 4 | import * as Collapsible from '$lib/components/ui/collapsible/index.js'; | ||
| 5 | import { buttonVariants } from '$lib/components/ui/button/index.js'; | ||
| 6 | import { Card } from '$lib/components/ui/card'; | ||
| 7 | import { config } from '$lib/stores/settings.svelte'; | ||
| 8 | |||
| 9 | interface Props { | ||
| 10 | class?: string; | ||
| 11 | hasRegularContent?: boolean; | ||
| 12 | isStreaming?: boolean; | ||
| 13 | reasoningContent: string | null; | ||
| 14 | } | ||
| 15 | |||
| 16 | let { | ||
| 17 | class: className = '', | ||
| 18 | hasRegularContent = false, | ||
| 19 | isStreaming = false, | ||
| 20 | reasoningContent | ||
| 21 | }: Props = $props(); | ||
| 22 | |||
| 23 | const currentConfig = config(); | ||
| 24 | |||
| 25 | let isExpanded = $state(currentConfig.showThoughtInProgress); | ||
| 26 | |||
| 27 | $effect(() => { | ||
| 28 | if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) { | ||
| 29 | isExpanded = false; | ||
| 30 | } | ||
| 31 | }); | ||
| 32 | </script> | ||
| 33 | |||
| 34 | <Collapsible.Root bind:open={isExpanded} class="mb-6 {className}"> | ||
| 35 | <Card class="gap-0 border-muted bg-muted/30 py-0"> | ||
| 36 | <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3"> | ||
| 37 | <div class="flex items-center gap-2 text-muted-foreground"> | ||
| 38 | <Brain class="h-4 w-4" /> | ||
| 39 | |||
| 40 | <span class="text-sm font-medium"> | ||
| 41 | {isStreaming ? 'Reasoning...' : 'Reasoning'} | ||
| 42 | </span> | ||
| 43 | </div> | ||
| 44 | |||
| 45 | <div | ||
| 46 | class={buttonVariants({ | ||
| 47 | variant: 'ghost', | ||
| 48 | size: 'sm', | ||
| 49 | class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground' | ||
| 50 | })} | ||
| 51 | > | ||
| 52 | <ChevronsUpDownIcon class="h-4 w-4" /> | ||
| 53 | |||
| 54 | <span class="sr-only">Toggle reasoning content</span> | ||
| 55 | </div> | ||
| 56 | </Collapsible.Trigger> | ||
| 57 | |||
| 58 | <Collapsible.Content> | ||
| 59 | <div class="border-t border-muted px-3 pb-3"> | ||
| 60 | <div class="pt-3"> | ||
| 61 | <div class="text-xs leading-relaxed break-words whitespace-pre-wrap"> | ||
| 62 | {reasoningContent ?? ''} | ||
| 63 | </div> | ||
| 64 | </div> | ||
| 65 | </div> | ||
| 66 | </Collapsible.Content> | ||
| 67 | </Card> | ||
| 68 | </Collapsible.Root> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte new file mode 100644 index 0000000..041c6bd --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte | |||
| @@ -0,0 +1,163 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Card } from '$lib/components/ui/card'; | ||
| 3 | import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app'; | ||
| 4 | import { config } from '$lib/stores/settings.svelte'; | ||
| 5 | import ChatMessageActions from './ChatMessageActions.svelte'; | ||
| 6 | import ChatMessageEditForm from './ChatMessageEditForm.svelte'; | ||
| 7 | |||
| 8 | interface Props { | ||
| 9 | class?: string; | ||
| 10 | message: DatabaseMessage; | ||
| 11 | isEditing: boolean; | ||
| 12 | editedContent: string; | ||
| 13 | editedExtras?: DatabaseMessageExtra[]; | ||
| 14 | editedUploadedFiles?: ChatUploadedFile[]; | ||
| 15 | siblingInfo?: ChatMessageSiblingInfo | null; | ||
| 16 | showDeleteDialog: boolean; | ||
| 17 | deletionInfo: { | ||
| 18 | totalCount: number; | ||
| 19 | userMessages: number; | ||
| 20 | assistantMessages: number; | ||
| 21 | messageTypes: string[]; | ||
| 22 | } | null; | ||
| 23 | onCancelEdit: () => void; | ||
| 24 | onSaveEdit: () => void; | ||
| 25 | onSaveEditOnly?: () => void; | ||
| 26 | onEditKeydown: (event: KeyboardEvent) => void; | ||
| 27 | onEditedContentChange: (content: string) => void; | ||
| 28 | onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void; | ||
| 29 | onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void; | ||
| 30 | onCopy: () => void; | ||
| 31 | onEdit: () => void; | ||
| 32 | onDelete: () => void; | ||
| 33 | onConfirmDelete: () => void; | ||
| 34 | onNavigateToSibling?: (siblingId: string) => void; | ||
| 35 | onShowDeleteDialogChange: (show: boolean) => void; | ||
| 36 | textareaElement?: HTMLTextAreaElement; | ||
| 37 | } | ||
| 38 | |||
| 39 | let { | ||
| 40 | class: className = '', | ||
| 41 | message, | ||
| 42 | isEditing, | ||
| 43 | editedContent, | ||
| 44 | editedExtras = [], | ||
| 45 | editedUploadedFiles = [], | ||
| 46 | siblingInfo = null, | ||
| 47 | showDeleteDialog, | ||
| 48 | deletionInfo, | ||
| 49 | onCancelEdit, | ||
| 50 | onSaveEdit, | ||
| 51 | onSaveEditOnly, | ||
| 52 | onEditKeydown, | ||
| 53 | onEditedContentChange, | ||
| 54 | onEditedExtrasChange, | ||
| 55 | onEditedUploadedFilesChange, | ||
| 56 | onCopy, | ||
| 57 | onEdit, | ||
| 58 | onDelete, | ||
| 59 | onConfirmDelete, | ||
| 60 | onNavigateToSibling, | ||
| 61 | onShowDeleteDialogChange, | ||
| 62 | textareaElement = $bindable() | ||
| 63 | }: Props = $props(); | ||
| 64 | |||
| 65 | let isMultiline = $state(false); | ||
| 66 | let messageElement: HTMLElement | undefined = $state(); | ||
| 67 | const currentConfig = config(); | ||
| 68 | |||
| 69 | $effect(() => { | ||
| 70 | if (!messageElement || !message.content.trim()) return; | ||
| 71 | |||
| 72 | if (message.content.includes('\n')) { | ||
| 73 | isMultiline = true; | ||
| 74 | return; | ||
| 75 | } | ||
| 76 | |||
| 77 | const resizeObserver = new ResizeObserver((entries) => { | ||
| 78 | for (const entry of entries) { | ||
| 79 | const element = entry.target as HTMLElement; | ||
| 80 | const estimatedSingleLineHeight = 24; // Typical line height for text-md | ||
| 81 | |||
| 82 | isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5; | ||
| 83 | } | ||
| 84 | }); | ||
| 85 | |||
| 86 | resizeObserver.observe(messageElement); | ||
| 87 | |||
| 88 | return () => { | ||
| 89 | resizeObserver.disconnect(); | ||
| 90 | }; | ||
| 91 | }); | ||
| 92 | </script> | ||
| 93 | |||
| 94 | <div | ||
| 95 | aria-label="User message with actions" | ||
| 96 | class="group flex flex-col items-end gap-3 md:gap-2 {className}" | ||
| 97 | role="group" | ||
| 98 | > | ||
| 99 | {#if isEditing} | ||
| 100 | <ChatMessageEditForm | ||
| 101 | bind:textareaElement | ||
| 102 | messageId={message.id} | ||
| 103 | {editedContent} | ||
| 104 | {editedExtras} | ||
| 105 | {editedUploadedFiles} | ||
| 106 | originalContent={message.content} | ||
| 107 | originalExtras={message.extra} | ||
| 108 | showSaveOnlyOption={!!onSaveEditOnly} | ||
| 109 | {onCancelEdit} | ||
| 110 | {onSaveEdit} | ||
| 111 | {onSaveEditOnly} | ||
| 112 | {onEditKeydown} | ||
| 113 | {onEditedContentChange} | ||
| 114 | {onEditedExtrasChange} | ||
| 115 | {onEditedUploadedFilesChange} | ||
| 116 | /> | ||
| 117 | {:else} | ||
| 118 | {#if message.extra && message.extra.length > 0} | ||
| 119 | <div class="mb-2 max-w-[80%]"> | ||
| 120 | <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" /> | ||
| 121 | </div> | ||
| 122 | {/if} | ||
| 123 | |||
| 124 | {#if message.content.trim()} | ||
| 125 | <Card | ||
| 126 | class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5" | ||
| 127 | data-multiline={isMultiline ? '' : undefined} | ||
| 128 | > | ||
| 129 | {#if currentConfig.renderUserContentAsMarkdown} | ||
| 130 | <div bind:this={messageElement} class="text-md"> | ||
| 131 | <MarkdownContent | ||
| 132 | class="markdown-user-content text-primary-foreground" | ||
| 133 | content={message.content} | ||
| 134 | /> | ||
| 135 | </div> | ||
| 136 | {:else} | ||
| 137 | <span bind:this={messageElement} class="text-md whitespace-pre-wrap"> | ||
| 138 | {message.content} | ||
| 139 | </span> | ||
| 140 | {/if} | ||
| 141 | </Card> | ||
| 142 | {/if} | ||
| 143 | |||
| 144 | {#if message.timestamp} | ||
| 145 | <div class="max-w-[80%]"> | ||
| 146 | <ChatMessageActions | ||
| 147 | actionsPosition="right" | ||
| 148 | {deletionInfo} | ||
| 149 | justify="end" | ||
| 150 | {onConfirmDelete} | ||
| 151 | {onCopy} | ||
| 152 | {onDelete} | ||
| 153 | {onEdit} | ||
| 154 | {onNavigateToSibling} | ||
| 155 | {onShowDeleteDialogChange} | ||
| 156 | {siblingInfo} | ||
| 157 | {showDeleteDialog} | ||
| 158 | role="user" | ||
| 159 | /> | ||
| 160 | </div> | ||
| 161 | {/if} | ||
| 162 | {/if} | ||
| 163 | </div> | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte new file mode 100644 index 0000000..c203f10 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { ChatMessage } from '$lib/components/app'; | ||
| 3 | import { chatStore } from '$lib/stores/chat.svelte'; | ||
| 4 | import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; | ||
| 5 | import { config } from '$lib/stores/settings.svelte'; | ||
| 6 | import { getMessageSiblings } from '$lib/utils'; | ||
| 7 | |||
| 8 | interface Props { | ||
| 9 | class?: string; | ||
| 10 | messages?: DatabaseMessage[]; | ||
| 11 | onUserAction?: () => void; | ||
| 12 | } | ||
| 13 | |||
| 14 | let { class: className, messages = [], onUserAction }: Props = $props(); | ||
| 15 | |||
| 16 | let allConversationMessages = $state<DatabaseMessage[]>([]); | ||
| 17 | const currentConfig = config(); | ||
| 18 | |||
| 19 | function refreshAllMessages() { | ||
| 20 | const conversation = activeConversation(); | ||
| 21 | |||
| 22 | if (conversation) { | ||
| 23 | conversationsStore.getConversationMessages(conversation.id).then((messages) => { | ||
| 24 | allConversationMessages = messages; | ||
| 25 | }); | ||
| 26 | } else { | ||
| 27 | allConversationMessages = []; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | // Single effect that tracks both conversation and message changes | ||
| 32 | $effect(() => { | ||
| 33 | const conversation = activeConversation(); | ||
| 34 | |||
| 35 | if (conversation) { | ||
| 36 | refreshAllMessages(); | ||
| 37 | } | ||
| 38 | }); | ||
| 39 | |||
| 40 | let displayMessages = $derived.by(() => { | ||
| 41 | if (!messages.length) { | ||
| 42 | return []; | ||
| 43 | } | ||
| 44 | |||
| 45 | // Filter out system messages if showSystemMessage is false | ||
| 46 | const filteredMessages = currentConfig.showSystemMessage | ||
| 47 | ? messages | ||
| 48 | : messages.filter((msg) => msg.type !== 'system'); | ||
| 49 | |||
| 50 | return filteredMessages.map((message) => { | ||
| 51 | const siblingInfo = getMessageSiblings(allConversationMessages, message.id); | ||
| 52 | |||
| 53 | return { | ||
| 54 | message, | ||
| 55 | siblingInfo: siblingInfo || { | ||
| 56 | message, | ||
| 57 | siblingIds: [message.id], | ||
| 58 | currentIndex: 0, | ||
| 59 | totalSiblings: 1 | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | async function handleNavigateToSibling(siblingId: string) { | ||
| 66 | await conversationsStore.navigateToSibling(siblingId); | ||
| 67 | } | ||
| 68 | |||
| 69 | async function handleEditWithBranching( | ||
| 70 | message: DatabaseMessage, | ||
| 71 | newContent: string, | ||
| 72 | newExtras?: DatabaseMessageExtra[] | ||
| 73 | ) { | ||
| 74 | onUserAction?.(); | ||
| 75 | |||
| 76 | await chatStore.editMessageWithBranching(message.id, newContent, newExtras); | ||
| 77 | |||
| 78 | refreshAllMessages(); | ||
| 79 | } | ||
| 80 | |||
| 81 | async function handleEditWithReplacement( | ||
| 82 | message: DatabaseMessage, | ||
| 83 | newContent: string, | ||
| 84 | shouldBranch: boolean | ||
| 85 | ) { | ||
| 86 | onUserAction?.(); | ||
| 87 | |||
| 88 | await chatStore.editAssistantMessage(message.id, newContent, shouldBranch); | ||
| 89 | |||
| 90 | refreshAllMessages(); | ||
| 91 | } | ||
| 92 | |||
| 93 | async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) { | ||
| 94 | onUserAction?.(); | ||
| 95 | |||
| 96 | await chatStore.regenerateMessageWithBranching(message.id, modelOverride); | ||
| 97 | |||
| 98 | refreshAllMessages(); | ||
| 99 | } | ||
| 100 | |||
| 101 | async function handleContinueAssistantMessage(message: DatabaseMessage) { | ||
| 102 | onUserAction?.(); | ||
| 103 | |||
| 104 | await chatStore.continueAssistantMessage(message.id); | ||
| 105 | |||
| 106 | refreshAllMessages(); | ||
| 107 | } | ||
| 108 | |||
| 109 | async function handleEditUserMessagePreserveResponses( | ||
| 110 | message: DatabaseMessage, | ||
| 111 | newContent: string, | ||
| 112 | newExtras?: DatabaseMessageExtra[] | ||
| 113 | ) { | ||
| 114 | onUserAction?.(); | ||
| 115 | |||
| 116 | await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras); | ||
| 117 | |||
| 118 | refreshAllMessages(); | ||
| 119 | } | ||
| 120 | |||
| 121 | async function handleDeleteMessage(message: DatabaseMessage) { | ||
| 122 | await chatStore.deleteMessage(message.id); | ||
| 123 | |||
| 124 | refreshAllMessages(); | ||
| 125 | } | ||
| 126 | </script> | ||
| 127 | |||
| 128 | <div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; "> | ||
| 129 | {#each displayMessages as { message, siblingInfo } (message.id)} | ||
| 130 | <ChatMessage | ||
| 131 | class="mx-auto w-full max-w-[48rem]" | ||
| 132 | {message} | ||
| 133 | {siblingInfo} | ||
| 134 | onDelete={handleDeleteMessage} | ||
| 135 | onNavigateToSibling={handleNavigateToSibling} | ||
| 136 | onEditWithBranching={handleEditWithBranching} | ||
| 137 | onEditWithReplacement={handleEditWithReplacement} | ||
| 138 | onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses} | ||
| 139 | onRegenerateWithBranching={handleRegenerateWithBranching} | ||
| 140 | onContinueAssistantMessage={handleContinueAssistantMessage} | ||
| 141 | /> | ||
| 142 | {/each} | ||
| 143 | </div> | ||
