diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
| commit | b333b06772c89d96aacb5490d6a219fba7c09cc6 (patch) | |
| tree | 211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatMessages | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
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 @@ +<script lang="ts"> + import { chatStore } from '$lib/stores/chat.svelte'; + import { config } from '$lib/stores/settings.svelte'; + import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils'; + import ChatMessageAssistant from './ChatMessageAssistant.svelte'; + import ChatMessageUser from './ChatMessageUser.svelte'; + import ChatMessageSystem from './ChatMessageSystem.svelte'; + + interface Props { + class?: string; + message: DatabaseMessage; + onCopy?: (message: DatabaseMessage) => void; + onContinueAssistantMessage?: (message: DatabaseMessage) => void; + onDelete?: (message: DatabaseMessage) => void; + onEditWithBranching?: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; + onEditWithReplacement?: ( + message: DatabaseMessage, + newContent: string, + shouldBranch: boolean + ) => void; + onEditUserMessagePreserveResponses?: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; + onNavigateToSibling?: (siblingId: string) => void; + onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void; + siblingInfo?: ChatMessageSiblingInfo | null; + } + + let { + class: className = '', + message, + onCopy, + onContinueAssistantMessage, + onDelete, + onEditWithBranching, + onEditWithReplacement, + onEditUserMessagePreserveResponses, + onNavigateToSibling, + onRegenerateWithBranching, + siblingInfo = null + }: Props = $props(); + + let deletionInfo = $state<{ + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + } | null>(null); + let editedContent = $state(message.content); + let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []); + let editedUploadedFiles = $state<ChatUploadedFile[]>([]); + let isEditing = $state(false); + let showDeleteDialog = $state(false); + let shouldBranchAfterEdit = $state(false); + let textareaElement: HTMLTextAreaElement | undefined = $state(); + + let thinkingContent = $derived.by(() => { + if (message.role === 'assistant') { + const trimmedThinking = message.thinking?.trim(); + + return trimmedThinking ? trimmedThinking : null; + } + return null; + }); + + let toolCallContent = $derived.by((): ApiChatCompletionToolCall[] | string | null => { + if (message.role === 'assistant') { + const trimmedToolCalls = message.toolCalls?.trim(); + + if (!trimmedToolCalls) { + return null; + } + + try { + const parsed = JSON.parse(trimmedToolCalls); + + if (Array.isArray(parsed)) { + return parsed as ApiChatCompletionToolCall[]; + } + } catch { + // Harmony-only path: fall back to the raw string so issues surface visibly. + } + + return trimmedToolCalls; + } + return null; + }); + + function handleCancelEdit() { + isEditing = false; + editedContent = message.content; + editedExtras = message.extra ? [...message.extra] : []; + editedUploadedFiles = []; + } + + function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) { + editedExtras = extras; + } + + function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) { + editedUploadedFiles = files; + } + + async function handleCopy() { + const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText); + const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText); + await copyToClipboard(clipboardContent, 'Message copied to clipboard'); + onCopy?.(message); + } + + function handleConfirmDelete() { + onDelete?.(message); + showDeleteDialog = false; + } + + async function handleDelete() { + deletionInfo = await chatStore.getDeletionInfo(message.id); + showDeleteDialog = true; + } + + function handleEdit() { + isEditing = true; + editedContent = message.content; + editedExtras = message.extra ? [...message.extra] : []; + editedUploadedFiles = []; + + setTimeout(() => { + if (textareaElement) { + textareaElement.focus(); + textareaElement.setSelectionRange( + textareaElement.value.length, + textareaElement.value.length + ); + } + }, 0); + } + + function handleEditedContentChange(content: string) { + editedContent = content; + } + + function handleEditKeydown(event: KeyboardEvent) { + // Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari) + // This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input) + if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) { + event.preventDefault(); + handleSaveEdit(); + } else if (event.key === 'Escape') { + event.preventDefault(); + handleCancelEdit(); + } + } + + function handleRegenerate(modelOverride?: string) { + onRegenerateWithBranching?.(message, modelOverride); + } + + function handleContinue() { + onContinueAssistantMessage?.(message); + } + + async function handleSaveEdit() { + if (message.role === 'user' || message.role === 'system') { + const finalExtras = await getMergedExtras(); + onEditWithBranching?.(message, editedContent.trim(), finalExtras); + } else { + // For assistant messages, preserve exact content including trailing whitespace + // This is important for the Continue feature to work properly + onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit); + } + + isEditing = false; + shouldBranchAfterEdit = false; + editedUploadedFiles = []; + } + + async function handleSaveEditOnly() { + if (message.role === 'user') { + // For user messages, trim to avoid accidental whitespace + const finalExtras = await getMergedExtras(); + onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras); + } + + isEditing = false; + editedUploadedFiles = []; + } + + async function getMergedExtras(): Promise<DatabaseMessageExtra[]> { + if (editedUploadedFiles.length === 0) { + return editedExtras; + } + + const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only'); + const result = await parseFilesToMessageExtras(editedUploadedFiles); + const newExtras = result?.extras || []; + + return [...editedExtras, ...newExtras]; + } + + function handleShowDeleteDialogChange(show: boolean) { + showDeleteDialog = show; + } +</script> + +{#if message.role === 'system'} + <ChatMessageSystem + bind:textareaElement + class={className} + {deletionInfo} + {editedContent} + {isEditing} + {message} + onCancelEdit={handleCancelEdit} + onConfirmDelete={handleConfirmDelete} + onCopy={handleCopy} + onDelete={handleDelete} + onEdit={handleEdit} + onEditKeydown={handleEditKeydown} + onEditedContentChange={handleEditedContentChange} + {onNavigateToSibling} + onSaveEdit={handleSaveEdit} + onShowDeleteDialogChange={handleShowDeleteDialogChange} + {showDeleteDialog} + {siblingInfo} + /> +{:else if message.role === 'user'} + <ChatMessageUser + bind:textareaElement + class={className} + {deletionInfo} + {editedContent} + {editedExtras} + {editedUploadedFiles} + {isEditing} + {message} + onCancelEdit={handleCancelEdit} + onConfirmDelete={handleConfirmDelete} + onCopy={handleCopy} + onDelete={handleDelete} + onEdit={handleEdit} + onEditKeydown={handleEditKeydown} + onEditedContentChange={handleEditedContentChange} + onEditedExtrasChange={handleEditedExtrasChange} + onEditedUploadedFilesChange={handleEditedUploadedFilesChange} + {onNavigateToSibling} + onSaveEdit={handleSaveEdit} + onSaveEditOnly={handleSaveEditOnly} + onShowDeleteDialogChange={handleShowDeleteDialogChange} + {showDeleteDialog} + {siblingInfo} + /> +{:else} + <ChatMessageAssistant + bind:textareaElement + class={className} + {deletionInfo} + {editedContent} + {isEditing} + {message} + messageContent={message.content} + onCancelEdit={handleCancelEdit} + onConfirmDelete={handleConfirmDelete} + onContinue={handleContinue} + onCopy={handleCopy} + onDelete={handleDelete} + onEdit={handleEdit} + onEditKeydown={handleEditKeydown} + onEditedContentChange={handleEditedContentChange} + {onNavigateToSibling} + onRegenerate={handleRegenerate} + onSaveEdit={handleSaveEdit} + onShowDeleteDialogChange={handleShowDeleteDialogChange} + {shouldBranchAfterEdit} + onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)} + {showDeleteDialog} + {siblingInfo} + {thinkingContent} + {toolCallContent} + /> +{/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 @@ +<script lang="ts"> + import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte'; + import { + ActionButton, + ChatMessageBranchingControls, + DialogConfirmation + } from '$lib/components/app'; + + interface Props { + role: 'user' | 'assistant'; + justify: 'start' | 'end'; + actionsPosition: 'left' | 'right'; + siblingInfo?: ChatMessageSiblingInfo | null; + showDeleteDialog: boolean; + deletionInfo: { + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + } | null; + onCopy: () => void; + onEdit?: () => void; + onRegenerate?: () => void; + onContinue?: () => void; + onDelete: () => void; + onConfirmDelete: () => void; + onNavigateToSibling?: (siblingId: string) => void; + onShowDeleteDialogChange: (show: boolean) => void; + } + + let { + actionsPosition, + deletionInfo, + justify, + onCopy, + onEdit, + onConfirmDelete, + onContinue, + onDelete, + onNavigateToSibling, + onShowDeleteDialogChange, + onRegenerate, + role, + siblingInfo = null, + showDeleteDialog + }: Props = $props(); + + function handleConfirmDelete() { + onConfirmDelete(); + onShowDeleteDialogChange(false); + } +</script> + +<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{justify}"> + <div + class="absolute top-0 {actionsPosition === 'left' + ? 'left-0' + : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity" + > + {#if siblingInfo && siblingInfo.totalSiblings > 1} + <ChatMessageBranchingControls {siblingInfo} {onNavigateToSibling} /> + {/if} + + <div + class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150" + > + <ActionButton icon={Copy} tooltip="Copy" onclick={onCopy} /> + + {#if onEdit} + <ActionButton icon={Edit} tooltip="Edit" onclick={onEdit} /> + {/if} + + {#if role === 'assistant' && onRegenerate} + <ActionButton icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} /> + {/if} + + {#if role === 'assistant' && onContinue} + <ActionButton icon={ArrowRight} tooltip="Continue" onclick={onContinue} /> + {/if} + + <ActionButton icon={Trash2} tooltip="Delete" onclick={onDelete} /> + </div> + </div> +</div> + +<DialogConfirmation + bind:open={showDeleteDialog} + title="Delete Message" + description={deletionInfo && deletionInfo.totalCount > 1 + ? `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.` + : 'Are you sure you want to delete this message? This action cannot be undone.'} + confirmText={deletionInfo && deletionInfo.totalCount > 1 + ? `Delete ${deletionInfo.totalCount} Messages` + : 'Delete'} + cancelText="Cancel" + variant="destructive" + icon={Trash2} + onConfirm={handleConfirmDelete} + onCancel={() => onShowDeleteDialogChange(false)} +/> 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 @@ +<script lang="ts"> + import { + ModelBadge, + ChatMessageActions, + ChatMessageStatistics, + ChatMessageThinkingBlock, + CopyToClipboardIcon, + MarkdownContent, + ModelsSelector + } from '$lib/components/app'; + import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; + import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte'; + import { isLoading } from '$lib/stores/chat.svelte'; + import { autoResizeTextarea, copyToClipboard } from '$lib/utils'; + import { fade } from 'svelte/transition'; + import { Check, X, Wrench } from '@lucide/svelte'; + import { Button } from '$lib/components/ui/button'; + import { Checkbox } from '$lib/components/ui/checkbox'; + import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import Label from '$lib/components/ui/label/label.svelte'; + import { config } from '$lib/stores/settings.svelte'; + import { conversationsStore } from '$lib/stores/conversations.svelte'; + import { isRouterMode } from '$lib/stores/server.svelte'; + + interface Props { + class?: string; + deletionInfo: { + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + } | null; + editedContent?: string; + isEditing?: boolean; + message: DatabaseMessage; + messageContent: string | undefined; + onCancelEdit?: () => void; + onCopy: () => void; + onConfirmDelete: () => void; + onContinue?: () => void; + onDelete: () => void; + onEdit?: () => void; + onEditKeydown?: (event: KeyboardEvent) => void; + onEditedContentChange?: (content: string) => void; + onNavigateToSibling?: (siblingId: string) => void; + onRegenerate: (modelOverride?: string) => void; + onSaveEdit?: () => void; + onShowDeleteDialogChange: (show: boolean) => void; + onShouldBranchAfterEditChange?: (value: boolean) => void; + showDeleteDialog: boolean; + shouldBranchAfterEdit?: boolean; + siblingInfo?: ChatMessageSiblingInfo | null; + textareaElement?: HTMLTextAreaElement; + thinkingContent: string | null; + toolCallContent: ApiChatCompletionToolCall[] | string | null; + } + + let { + class: className = '', + deletionInfo, + editedContent = '', + isEditing = false, + message, + messageContent, + onCancelEdit, + onConfirmDelete, + onContinue, + onCopy, + onDelete, + onEdit, + onEditKeydown, + onEditedContentChange, + onNavigateToSibling, + onRegenerate, + onSaveEdit, + onShowDeleteDialogChange, + onShouldBranchAfterEditChange, + showDeleteDialog, + shouldBranchAfterEdit = false, + siblingInfo = null, + textareaElement = $bindable(), + thinkingContent, + toolCallContent = null + }: Props = $props(); + + const toolCalls = $derived( + Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null + ); + const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null); + + const processingState = useProcessingState(); + + let currentConfig = $derived(config()); + let isRouter = $derived(isRouterMode()); + let displayedModel = $derived((): string | null => { + if (message.model) { + return message.model; + } + + return null; + }); + + const { handleModelChange } = useModelChangeValidation({ + getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id), + onSuccess: (modelName) => onRegenerate(modelName) + }); + + function handleCopyModel() { + const model = displayedModel(); + + void copyToClipboard(model ?? ''); + } + + $effect(() => { + if (isEditing && textareaElement) { + autoResizeTextarea(textareaElement); + } + }); + + $effect(() => { + if (isLoading() && !message?.content?.trim()) { + processingState.startMonitoring(); + } + }); + + function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) { + const callNumber = index + 1; + const functionName = toolCall.function?.name?.trim(); + const label = functionName || `Call #${callNumber}`; + + const payload: Record<string, unknown> = {}; + + const id = toolCall.id?.trim(); + if (id) { + payload.id = id; + } + + const type = toolCall.type?.trim(); + if (type) { + payload.type = type; + } + + if (toolCall.function) { + const fnPayload: Record<string, unknown> = {}; + + const name = toolCall.function.name?.trim(); + if (name) { + fnPayload.name = name; + } + + const rawArguments = toolCall.function.arguments?.trim(); + if (rawArguments) { + try { + fnPayload.arguments = JSON.parse(rawArguments); + } catch { + fnPayload.arguments = rawArguments; + } + } + + if (Object.keys(fnPayload).length > 0) { + payload.function = fnPayload; + } + } + + const formattedPayload = JSON.stringify(payload, null, 2); + + return { + label, + tooltip: formattedPayload, + copyValue: formattedPayload + }; + } + + function handleCopyToolCall(payload: string) { + void copyToClipboard(payload, 'Tool call copied to clipboard'); + } +</script> + +<div + class="text-md group w-full leading-7.5 {className}" + role="group" + aria-label="Assistant message with actions" +> + {#if thinkingContent} + <ChatMessageThinkingBlock + reasoningContent={thinkingContent} + isStreaming={!message.timestamp} + hasRegularContent={!!messageContent?.trim()} + /> + {/if} + + {#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()} + <div class="mt-6 w-full max-w-[48rem]" in:fade> + <div class="processing-container"> + <span class="processing-text"> + {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()} + </span> + </div> + </div> + {/if} + + {#if isEditing} + <div class="w-full"> + <textarea + bind:this={textareaElement} + bind:value={editedContent} + class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}" + onkeydown={onEditKeydown} + oninput={(e) => { + autoResizeTextarea(e.currentTarget); + onEditedContentChange?.(e.currentTarget.value); + }} + placeholder="Edit assistant message..." + ></textarea> + + <div class="mt-2 flex items-center justify-between"> + <div class="flex items-center space-x-2"> + <Checkbox + id="branch-after-edit" + bind:checked={shouldBranchAfterEdit} + onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)} + /> + <Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground"> + Branch conversation after edit + </Label> + </div> + <div class="flex gap-2"> + <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline"> + <X class="mr-1 h-3 w-3" /> + Cancel + </Button> + + <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm"> + <Check class="mr-1 h-3 w-3" /> + Save + </Button> + </div> + </div> + </div> + {:else if message.role === 'assistant'} + {#if config().disableReasoningFormat} + <pre class="raw-output">{messageContent || ''}</pre> + {:else} + <MarkdownContent content={messageContent || ''} /> + {/if} + {:else} + <div class="text-sm whitespace-pre-wrap"> + {messageContent} + </div> + {/if} + + <div class="info my-6 grid gap-4 tabular-nums"> + {#if displayedModel()} + <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"> + {#if isRouter} + <ModelsSelector + currentModel={displayedModel()} + onModelChange={handleModelChange} + disabled={isLoading()} + upToMessageId={message.id} + /> + {:else} + <ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} /> + {/if} + + {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} + <ChatMessageStatistics + promptTokens={message.timings.prompt_n} + promptMs={message.timings.prompt_ms} + predictedTokens={message.timings.predicted_n} + predictedMs={message.timings.predicted_ms} + /> + {:else if isLoading() && currentConfig.showMessageStats} + {@const liveStats = processingState.getLiveProcessingStats()} + {@const genStats = processingState.getLiveGenerationStats()} + {@const promptProgress = processingState.processingState?.promptProgress} + {@const isStillProcessingPrompt = + promptProgress && promptProgress.processed < promptProgress.total} + + {#if liveStats || genStats} + <ChatMessageStatistics + isLive={true} + isProcessingPrompt={!!isStillProcessingPrompt} + promptTokens={liveStats?.tokensProcessed} + promptMs={liveStats?.timeMs} + predictedTokens={genStats?.tokensGenerated} + predictedMs={genStats?.timeMs} + /> + {/if} + {/if} + </div> + {/if} + + {#if config().showToolCalls} + {#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls} + <span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> + <span class="inline-flex items-center gap-1"> + <Wrench class="h-3.5 w-3.5" /> + + <span>Tool calls:</span> + </span> + + {#if toolCalls && toolCalls.length > 0} + {#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)} + {@const badge = formatToolCallBadge(toolCall, index)} + <button + type="button" + class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75" + title={badge.tooltip} + aria-label={`Copy tool call ${badge.label}`} + onclick={() => handleCopyToolCall(badge.copyValue)} + > + {badge.label} + <CopyToClipboardIcon + text={badge.copyValue} + ariaLabel={`Copy tool call ${badge.label}`} + /> + </button> + {/each} + {:else if fallbackToolCalls} + <button + type="button" + 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" + title={fallbackToolCalls} + aria-label="Copy tool call payload" + onclick={() => handleCopyToolCall(fallbackToolCalls)} + > + {fallbackToolCalls} + <CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" /> + </button> + {/if} + </span> + {/if} + {/if} + </div> + + {#if message.timestamp && !isEditing} + <ChatMessageActions + role="assistant" + justify="start" + actionsPosition="left" + {siblingInfo} + {showDeleteDialog} + {deletionInfo} + {onCopy} + {onEdit} + {onRegenerate} + onContinue={currentConfig.enableContinueGeneration && !thinkingContent + ? onContinue + : undefined} + {onDelete} + {onConfirmDelete} + {onNavigateToSibling} + {onShowDeleteDialogChange} + /> + {/if} +</div> + +<style> + .processing-container { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .processing-text { + background: linear-gradient( + 90deg, + var(--muted-foreground), + var(--foreground), + var(--muted-foreground) + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shine 1s linear infinite; + font-weight: 500; + font-size: 0.875rem; + } + + @keyframes shine { + to { + background-position: -200% 0; + } + } + + .raw-output { + width: 100%; + max-width: 48rem; + margin-top: 1.5rem; + padding: 1rem 1.25rem; + border-radius: 1rem; + background: hsl(var(--muted) / 0.3); + color: var(--foreground); + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, + 'Liberation Mono', Menlo, monospace; + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + } + + .tool-call-badge { + max-width: 12rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .tool-call-badge--fallback { + max-width: 20rem; + white-space: normal; + word-break: break-word; + } +</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 @@ +<script lang="ts"> + import { ChevronLeft, ChevronRight } from '@lucide/svelte'; + import { Button } from '$lib/components/ui/button'; + import * as Tooltip from '$lib/components/ui/tooltip'; + + interface Props { + class?: string; + siblingInfo: ChatMessageSiblingInfo | null; + onNavigateToSibling?: (siblingId: string) => void; + } + + let { class: className = '', siblingInfo, onNavigateToSibling }: Props = $props(); + + let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0); + let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1); + let nextSiblingId = $derived( + hasNext ? siblingInfo!.siblingIds[siblingInfo!.currentIndex + 1] : null + ); + let previousSiblingId = $derived( + hasPrevious ? siblingInfo!.siblingIds[siblingInfo!.currentIndex - 1] : null + ); + + function handleNext() { + if (nextSiblingId) { + onNavigateToSibling?.(nextSiblingId); + } + } + + function handlePrevious() { + if (previousSiblingId) { + onNavigateToSibling?.(previousSiblingId); + } + } +</script> + +{#if siblingInfo && siblingInfo.totalSiblings > 1} + <div + aria-label="Message version {siblingInfo.currentIndex + 1} of {siblingInfo.totalSiblings}" + class="flex items-center gap-1 text-xs text-muted-foreground {className}" + role="navigation" + > + <Tooltip.Root> + <Tooltip.Trigger> + <Button + aria-label="Previous message version" + class="h-5 w-5 p-0 {!hasPrevious ? 'cursor-not-allowed opacity-30' : ''}" + disabled={!hasPrevious} + onclick={handlePrevious} + size="sm" + variant="ghost" + > + <ChevronLeft class="h-3 w-3" /> + </Button> + </Tooltip.Trigger> + + <Tooltip.Content> + <p>Previous version</p> + </Tooltip.Content> + </Tooltip.Root> + + <span class="px-1 font-mono text-xs"> + {siblingInfo.currentIndex + 1}/{siblingInfo.totalSiblings} + </span> + + <Tooltip.Root> + <Tooltip.Trigger> + <Button + aria-label="Next message version" + class="h-5 w-5 p-0 {!hasNext ? 'cursor-not-allowed opacity-30' : ''}" + disabled={!hasNext} + onclick={handleNext} + size="sm" + variant="ghost" + > + <ChevronRight class="h-3 w-3" /> + </Button> + </Tooltip.Trigger> + + <Tooltip.Content> + <p>Next version</p> + </Tooltip.Content> + </Tooltip.Root> + </div> +{/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 @@ +<script lang="ts"> + import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte'; + import { Button } from '$lib/components/ui/button'; + import { Switch } from '$lib/components/ui/switch'; + import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app'; + import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; + import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums'; + import { config } from '$lib/stores/settings.svelte'; + import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte'; + import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte'; + import { conversationsStore } from '$lib/stores/conversations.svelte'; + import { modelsStore } from '$lib/stores/models.svelte'; + import { isRouterMode } from '$lib/stores/server.svelte'; + import { + autoResizeTextarea, + getFileTypeCategory, + getFileTypeCategoryByExtension, + parseClipboardContent + } from '$lib/utils'; + + interface Props { + messageId: string; + editedContent: string; + editedExtras?: DatabaseMessageExtra[]; + editedUploadedFiles?: ChatUploadedFile[]; + originalContent: string; + originalExtras?: DatabaseMessageExtra[]; + showSaveOnlyOption?: boolean; + onCancelEdit: () => void; + onSaveEdit: () => void; + onSaveEditOnly?: () => void; + onEditKeydown: (event: KeyboardEvent) => void; + onEditedContentChange: (content: string) => void; + onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void; + onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void; + textareaElement?: HTMLTextAreaElement; + } + + let { + messageId, + editedContent, + editedExtras = [], + editedUploadedFiles = [], + originalContent, + originalExtras = [], + showSaveOnlyOption = false, + onCancelEdit, + onSaveEdit, + onSaveEditOnly, + onEditKeydown, + onEditedContentChange, + onEditedExtrasChange, + onEditedUploadedFilesChange, + textareaElement = $bindable() + }: Props = $props(); + + let fileInputElement: HTMLInputElement | undefined = $state(); + let saveWithoutRegenerate = $state(false); + let showDiscardDialog = $state(false); + let isRouter = $derived(isRouterMode()); + let currentConfig = $derived(config()); + + let pasteLongTextToFileLength = $derived.by(() => { + const n = Number(currentConfig.pasteLongTextToFileLen); + + return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n; + }); + + let hasUnsavedChanges = $derived.by(() => { + if (editedContent !== originalContent) return true; + if (editedUploadedFiles.length > 0) return true; + + const extrasChanged = + editedExtras.length !== originalExtras.length || + editedExtras.some((extra, i) => extra !== originalExtras[i]); + + if (extrasChanged) return true; + + return false; + }); + + let hasAttachments = $derived( + (editedExtras && editedExtras.length > 0) || + (editedUploadedFiles && editedUploadedFiles.length > 0) + ); + + let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments); + + function getEditedAttachmentsModalities(): ModelModalities { + const modalities: ModelModalities = { vision: false, audio: false }; + + for (const extra of editedExtras) { + if (extra.type === AttachmentType.IMAGE) { + modalities.vision = true; + } + + if ( + extra.type === AttachmentType.PDF && + 'processedAsImages' in extra && + extra.processedAsImages + ) { + modalities.vision = true; + } + + if (extra.type === AttachmentType.AUDIO) { + modalities.audio = true; + } + } + + for (const file of editedUploadedFiles) { + const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name); + if (category === FileTypeCategory.IMAGE) { + modalities.vision = true; + } + if (category === FileTypeCategory.AUDIO) { + modalities.audio = true; + } + } + + return modalities; + } + + function getRequiredModalities(): ModelModalities { + const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId); + const editedModalities = getEditedAttachmentsModalities(); + + return { + vision: beforeModalities.vision || editedModalities.vision, + audio: beforeModalities.audio || editedModalities.audio + }; + } + + const { handleModelChange } = useModelChangeValidation({ + getRequiredModalities, + onValidationFailure: async (previousModelId) => { + if (previousModelId) { + await modelsStore.selectModelById(previousModelId); + } + } + }); + + function handleFileInputChange(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files || input.files.length === 0) return; + + const files = Array.from(input.files); + + processNewFiles(files); + input.value = ''; + } + + function handleGlobalKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + attemptCancel(); + } + } + + function attemptCancel() { + if (hasUnsavedChanges) { + showDiscardDialog = true; + } else { + onCancelEdit(); + } + } + + function handleRemoveExistingAttachment(index: number) { + if (!onEditedExtrasChange) return; + + const newExtras = [...editedExtras]; + + newExtras.splice(index, 1); + onEditedExtrasChange(newExtras); + } + + function handleRemoveUploadedFile(fileId: string) { + if (!onEditedUploadedFilesChange) return; + + const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId); + + onEditedUploadedFilesChange(newFiles); + } + + function handleSubmit() { + if (!canSubmit) return; + + if (saveWithoutRegenerate && onSaveEditOnly) { + onSaveEditOnly(); + } else { + onSaveEdit(); + } + + saveWithoutRegenerate = false; + } + + async function processNewFiles(files: File[]) { + if (!onEditedUploadedFilesChange) return; + + const { processFilesToChatUploaded } = await import('$lib/utils/browser-only'); + const processed = await processFilesToChatUploaded(files); + + onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]); + } + + function handlePaste(event: ClipboardEvent) { + if (!event.clipboardData) return; + + const files = Array.from(event.clipboardData.items) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + + if (files.length > 0) { + event.preventDefault(); + processNewFiles(files); + + return; + } + + const text = event.clipboardData.getData(MimeTypeText.PLAIN); + + if (text.startsWith('"')) { + const parsed = parseClipboardContent(text); + + if (parsed.textAttachments.length > 0) { + event.preventDefault(); + onEditedContentChange(parsed.message); + + const attachmentFiles = parsed.textAttachments.map( + (att) => + new File([att.content], att.name, { + type: MimeTypeText.PLAIN + }) + ); + + processNewFiles(attachmentFiles); + + setTimeout(() => { + textareaElement?.focus(); + }, 10); + + return; + } + } + + if ( + text.length > 0 && + pasteLongTextToFileLength > 0 && + text.length > pasteLongTextToFileLength + ) { + event.preventDefault(); + + const textFile = new File([text], 'Pasted', { + type: MimeTypeText.PLAIN + }); + + processNewFiles([textFile]); + } + } + + $effect(() => { + if (textareaElement) { + autoResizeTextarea(textareaElement); + } + }); + + $effect(() => { + setEditModeActive(processNewFiles); + + return () => { + clearEditMode(); + }; + }); +</script> + +<svelte:window onkeydown={handleGlobalKeydown} /> + +<input + bind:this={fileInputElement} + type="file" + multiple + class="hidden" + onchange={handleFileInputChange} +/> + +<div + class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md" + data-slot="edit-form" +> + <ChatAttachmentsList + attachments={editedExtras} + uploadedFiles={editedUploadedFiles} + readonly={false} + onFileRemove={(fileId) => { + if (fileId.startsWith('attachment-')) { + const index = parseInt(fileId.replace('attachment-', ''), 10); + if (!isNaN(index) && index >= 0 && index < editedExtras.length) { + handleRemoveExistingAttachment(index); + } + } else { + handleRemoveUploadedFile(fileId); + } + }} + limitToSingleRow + class="py-5" + style="scroll-padding: 1rem;" + /> + + <div class="relative min-h-[48px] px-5 py-3"> + <textarea + bind:this={textareaElement} + bind:value={editedContent} + class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none" + onkeydown={onEditKeydown} + oninput={(e) => { + autoResizeTextarea(e.currentTarget); + onEditedContentChange(e.currentTarget.value); + }} + onpaste={handlePaste} + placeholder="Edit your message..." + ></textarea> + + <div class="flex w-full items-center gap-3" style="container-type: inline-size"> + <Button + class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + onclick={() => fileInputElement?.click()} + type="button" + title="Add attachment" + > + <span class="sr-only">Attach files</span> + + <Paperclip class="h-4 w-4" /> + </Button> + + <div class="flex-1"></div> + + {#if isRouter} + <ModelsSelector + forceForegroundText={true} + useGlobalSelection={true} + onModelChange={handleModelChange} + /> + {/if} + + <Button + class="h-8 w-8 shrink-0 rounded-full p-0" + onclick={handleSubmit} + disabled={!canSubmit} + type="button" + title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'} + > + <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span> + + <ArrowUp class="h-5 w-5" /> + </Button> + </div> + </div> +</div> + +<div class="mt-2 flex w-full max-w-[80%] items-center justify-between"> + {#if showSaveOnlyOption && onSaveEditOnly} + <div class="flex items-center gap-2"> + <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" /> + + <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground"> + Update without re-sending + </label> + </div> + {:else} + <div></div> + {/if} + + <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost"> + <X class="mr-1 h-3 w-3" /> + + Cancel + </Button> +</div> + +<DialogConfirmation + bind:open={showDiscardDialog} + title="Discard changes?" + description="You have unsaved changes. Are you sure you want to discard them?" + confirmText="Discard" + cancelText="Keep editing" + variant="destructive" + icon={AlertTriangle} + onConfirm={onCancelEdit} + onCancel={() => (showDiscardDialog = false)} +/> 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 @@ +<script lang="ts"> + import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte'; + import { BadgeChatStatistic } from '$lib/components/app'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { ChatMessageStatsView } from '$lib/enums'; + + interface Props { + predictedTokens?: number; + predictedMs?: number; + promptTokens?: number; + promptMs?: number; + // Live mode: when true, shows stats during streaming + isLive?: boolean; + // Whether prompt processing is still in progress + isProcessingPrompt?: boolean; + // Initial view to show (defaults to READING in live mode) + initialView?: ChatMessageStatsView; + } + + let { + predictedTokens, + predictedMs, + promptTokens, + promptMs, + isLive = false, + isProcessingPrompt = false, + initialView = ChatMessageStatsView.GENERATION + }: Props = $props(); + + let activeView: ChatMessageStatsView = $state(initialView); + let hasAutoSwitchedToGeneration = $state(false); + + // In live mode: auto-switch to GENERATION tab when prompt processing completes + $effect(() => { + if (isLive) { + // Auto-switch to generation tab only when prompt processing is done (once) + if ( + !hasAutoSwitchedToGeneration && + !isProcessingPrompt && + predictedTokens && + predictedTokens > 0 + ) { + activeView = ChatMessageStatsView.GENERATION; + hasAutoSwitchedToGeneration = true; + } else if (!hasAutoSwitchedToGeneration) { + // Stay on READING while prompt is still being processed + activeView = ChatMessageStatsView.READING; + } + } + }); + + let hasGenerationStats = $derived( + predictedTokens !== undefined && + predictedTokens > 0 && + predictedMs !== undefined && + predictedMs > 0 + ); + + let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0); + let timeInSeconds = $derived( + predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00' + ); + + let promptTokensPerSecond = $derived( + promptTokens !== undefined && promptMs !== undefined && promptMs > 0 + ? (promptTokens / promptMs) * 1000 + : undefined + ); + + let promptTimeInSeconds = $derived( + promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined + ); + + let hasPromptStats = $derived( + promptTokens !== undefined && + promptMs !== undefined && + promptTokensPerSecond !== undefined && + promptTimeInSeconds !== undefined + ); + + // In live mode, generation tab is disabled until we have generation stats + let isGenerationDisabled = $derived(isLive && !hasGenerationStats); +</script> + +<div class="inline-flex items-center text-xs text-muted-foreground"> + <div class="inline-flex items-center rounded-sm bg-muted-foreground/15 p-0.5"> + {#if hasPromptStats || isLive} + <Tooltip.Root> + <Tooltip.Trigger> + <button + type="button" + class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === + ChatMessageStatsView.READING + ? 'bg-background text-foreground shadow-sm' + : 'hover:text-foreground'}" + onclick={() => (activeView = ChatMessageStatsView.READING)} + > + <BookOpenText class="h-3 w-3" /> + <span class="sr-only">Reading</span> + </button> + </Tooltip.Trigger> + <Tooltip.Content> + <p>Reading (prompt processing)</p> + </Tooltip.Content> + </Tooltip.Root> + {/if} + <Tooltip.Root> + <Tooltip.Trigger> + <button + type="button" + class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView === + ChatMessageStatsView.GENERATION + ? 'bg-background text-foreground shadow-sm' + : isGenerationDisabled + ? 'cursor-not-allowed opacity-40' + : 'hover:text-foreground'}" + onclick={() => !isGenerationDisabled && (activeView = ChatMessageStatsView.GENERATION)} + disabled={isGenerationDisabled} + > + <Sparkles class="h-3 w-3" /> + <span class="sr-only">Generation</span> + </button> + </Tooltip.Trigger> + <Tooltip.Content> + <p> + {isGenerationDisabled + ? 'Generation (waiting for tokens...)' + : 'Generation (token output)'} + </p> + </Tooltip.Content> + </Tooltip.Root> + </div> + + <div class="flex items-center gap-1 px-2"> + {#if activeView === ChatMessageStatsView.GENERATION && hasGenerationStats} + <BadgeChatStatistic + class="bg-transparent" + icon={WholeWord} + value="{predictedTokens?.toLocaleString()} tokens" + tooltipLabel="Generated tokens" + /> + <BadgeChatStatistic + class="bg-transparent" + icon={Clock} + value="{timeInSeconds}s" + tooltipLabel="Generation time" + /> + <BadgeChatStatistic + class="bg-transparent" + icon={Gauge} + value="{tokensPerSecond.toFixed(2)} tokens/s" + tooltipLabel="Generation speed" + /> + {:else if hasPromptStats} + <BadgeChatStatistic + class="bg-transparent" + icon={WholeWord} + value="{promptTokens} tokens" + tooltipLabel="Prompt tokens" + /> + <BadgeChatStatistic + class="bg-transparent" + icon={Clock} + value="{promptTimeInSeconds}s" + tooltipLabel="Prompt processing time" + /> + <BadgeChatStatistic + class="bg-transparent" + icon={Gauge} + value="{promptTokensPerSecond!.toFixed(2)} tokens/s" + tooltipLabel="Prompt processing speed" + /> + {/if} + </div> +</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 @@ +<script lang="ts"> + import { Check, X } from '@lucide/svelte'; + import { Card } from '$lib/components/ui/card'; + import { Button } from '$lib/components/ui/button'; + import { MarkdownContent } from '$lib/components/app'; + import { INPUT_CLASSES } from '$lib/constants/input-classes'; + import { config } from '$lib/stores/settings.svelte'; + import ChatMessageActions from './ChatMessageActions.svelte'; + + interface Props { + class?: string; + message: DatabaseMessage; + isEditing: boolean; + editedContent: string; + siblingInfo?: ChatMessageSiblingInfo | null; + showDeleteDialog: boolean; + deletionInfo: { + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + } | null; + onCancelEdit: () => void; + onSaveEdit: () => void; + onEditKeydown: (event: KeyboardEvent) => void; + onEditedContentChange: (content: string) => void; + onCopy: () => void; + onEdit: () => void; + onDelete: () => void; + onConfirmDelete: () => void; + onNavigateToSibling?: (siblingId: string) => void; + onShowDeleteDialogChange: (show: boolean) => void; + textareaElement?: HTMLTextAreaElement; + } + + let { + class: className = '', + message, + isEditing, + editedContent, + siblingInfo = null, + showDeleteDialog, + deletionInfo, + onCancelEdit, + onSaveEdit, + onEditKeydown, + onEditedContentChange, + onCopy, + onEdit, + onDelete, + onConfirmDelete, + onNavigateToSibling, + onShowDeleteDialogChange, + textareaElement = $bindable() + }: Props = $props(); + + let isMultiline = $state(false); + let messageElement: HTMLElement | undefined = $state(); + let isExpanded = $state(false); + let contentHeight = $state(0); + const MAX_HEIGHT = 200; // pixels + const currentConfig = config(); + + let showExpandButton = $derived(contentHeight > MAX_HEIGHT); + + $effect(() => { + if (!messageElement || !message.content.trim()) return; + + if (message.content.includes('\n')) { + isMultiline = true; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const element = entry.target as HTMLElement; + const estimatedSingleLineHeight = 24; + + isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5; + contentHeight = element.scrollHeight; + } + }); + + resizeObserver.observe(messageElement); + + return () => { + resizeObserver.disconnect(); + }; + }); + + function toggleExpand() { + isExpanded = !isExpanded; + } +</script> + +<div + aria-label="System message with actions" + class="group flex flex-col items-end gap-3 md:gap-2 {className}" + role="group" +> + {#if isEditing} + <div class="w-full max-w-[80%]"> + <textarea + bind:this={textareaElement} + bind:value={editedContent} + class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}" + onkeydown={onEditKeydown} + oninput={(e) => onEditedContentChange(e.currentTarget.value)} + placeholder="Edit system message..." + ></textarea> + + <div class="mt-2 flex justify-end gap-2"> + <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline"> + <X class="mr-1 h-3 w-3" /> + Cancel + </Button> + + <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm"> + <Check class="mr-1 h-3 w-3" /> + Send + </Button> + </div> + </div> + {:else} + {#if message.content.trim()} + <div class="relative max-w-[80%]"> + <button + class="group/expand w-full text-left {!isExpanded && showExpandButton + ? 'cursor-pointer' + : 'cursor-auto'}" + onclick={showExpandButton && !isExpanded ? toggleExpand : undefined} + type="button" + > + <Card + class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5" + data-multiline={isMultiline ? '' : undefined} + style="border: 2px dashed hsl(var(--border));" + > + <div + class="relative overflow-hidden transition-all duration-300 {isExpanded + ? 'cursor-text select-text' + : 'select-none'}" + style={!isExpanded && showExpandButton + ? `max-height: ${MAX_HEIGHT}px;` + : 'max-height: none;'} + > + {#if currentConfig.renderUserContentAsMarkdown} + <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}"> + <MarkdownContent class="markdown-system-content" content={message.content} /> + </div> + {:else} + <span + bind:this={messageElement} + class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}" + > + {message.content} + </span> + {/if} + + {#if !isExpanded && showExpandButton} + <div + class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent" + ></div> + <div + class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100" + > + <Button + class="rounded-full px-4 py-1.5 text-xs shadow-md" + size="sm" + variant="outline" + > + Show full system message + </Button> + </div> + {/if} + </div> + + {#if isExpanded && showExpandButton} + <div class="mb-2 flex justify-center"> + <Button + class="rounded-full px-4 py-1.5 text-xs" + onclick={(e) => { + e.stopPropagation(); + toggleExpand(); + }} + size="sm" + variant="outline" + > + Collapse System Message + </Button> + </div> + {/if} + </Card> + </button> + </div> + {/if} + + {#if message.timestamp} + <div class="max-w-[80%]"> + <ChatMessageActions + actionsPosition="right" + {deletionInfo} + justify="end" + {onConfirmDelete} + {onCopy} + {onDelete} + {onEdit} + {onNavigateToSibling} + {onShowDeleteDialogChange} + {siblingInfo} + {showDeleteDialog} + role="user" + /> + </div> + {/if} + {/if} +</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 @@ +<script lang="ts"> + import { Brain } from '@lucide/svelte'; + import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; + import * as Collapsible from '$lib/components/ui/collapsible/index.js'; + import { buttonVariants } from '$lib/components/ui/button/index.js'; + import { Card } from '$lib/components/ui/card'; + import { config } from '$lib/stores/settings.svelte'; + + interface Props { + class?: string; + hasRegularContent?: boolean; + isStreaming?: boolean; + reasoningContent: string | null; + } + + let { + class: className = '', + hasRegularContent = false, + isStreaming = false, + reasoningContent + }: Props = $props(); + + const currentConfig = config(); + + let isExpanded = $state(currentConfig.showThoughtInProgress); + + $effect(() => { + if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) { + isExpanded = false; + } + }); +</script> + +<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}"> + <Card class="gap-0 border-muted bg-muted/30 py-0"> + <Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3"> + <div class="flex items-center gap-2 text-muted-foreground"> + <Brain class="h-4 w-4" /> + + <span class="text-sm font-medium"> + {isStreaming ? 'Reasoning...' : 'Reasoning'} + </span> + </div> + + <div + class={buttonVariants({ + variant: 'ghost', + size: 'sm', + class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground' + })} + > + <ChevronsUpDownIcon class="h-4 w-4" /> + + <span class="sr-only">Toggle reasoning content</span> + </div> + </Collapsible.Trigger> + + <Collapsible.Content> + <div class="border-t border-muted px-3 pb-3"> + <div class="pt-3"> + <div class="text-xs leading-relaxed break-words whitespace-pre-wrap"> + {reasoningContent ?? ''} + </div> + </div> + </div> + </Collapsible.Content> + </Card> +</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 @@ +<script lang="ts"> + import { Card } from '$lib/components/ui/card'; + import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app'; + import { config } from '$lib/stores/settings.svelte'; + import ChatMessageActions from './ChatMessageActions.svelte'; + import ChatMessageEditForm from './ChatMessageEditForm.svelte'; + + interface Props { + class?: string; + message: DatabaseMessage; + isEditing: boolean; + editedContent: string; + editedExtras?: DatabaseMessageExtra[]; + editedUploadedFiles?: ChatUploadedFile[]; + siblingInfo?: ChatMessageSiblingInfo | null; + showDeleteDialog: boolean; + deletionInfo: { + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; + } | null; + onCancelEdit: () => void; + onSaveEdit: () => void; + onSaveEditOnly?: () => void; + onEditKeydown: (event: KeyboardEvent) => void; + onEditedContentChange: (content: string) => void; + onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void; + onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void; + onCopy: () => void; + onEdit: () => void; + onDelete: () => void; + onConfirmDelete: () => void; + onNavigateToSibling?: (siblingId: string) => void; + onShowDeleteDialogChange: (show: boolean) => void; + textareaElement?: HTMLTextAreaElement; + } + + let { + class: className = '', + message, + isEditing, + editedContent, + editedExtras = [], + editedUploadedFiles = [], + siblingInfo = null, + showDeleteDialog, + deletionInfo, + onCancelEdit, + onSaveEdit, + onSaveEditOnly, + onEditKeydown, + onEditedContentChange, + onEditedExtrasChange, + onEditedUploadedFilesChange, + onCopy, + onEdit, + onDelete, + onConfirmDelete, + onNavigateToSibling, + onShowDeleteDialogChange, + textareaElement = $bindable() + }: Props = $props(); + + let isMultiline = $state(false); + let messageElement: HTMLElement | undefined = $state(); + const currentConfig = config(); + + $effect(() => { + if (!messageElement || !message.content.trim()) return; + + if (message.content.includes('\n')) { + isMultiline = true; + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const element = entry.target as HTMLElement; + const estimatedSingleLineHeight = 24; // Typical line height for text-md + + isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5; + } + }); + + resizeObserver.observe(messageElement); + + return () => { + resizeObserver.disconnect(); + }; + }); +</script> + +<div + aria-label="User message with actions" + class="group flex flex-col items-end gap-3 md:gap-2 {className}" + role="group" +> + {#if isEditing} + <ChatMessageEditForm + bind:textareaElement + messageId={message.id} + {editedContent} + {editedExtras} + {editedUploadedFiles} + originalContent={message.content} + originalExtras={message.extra} + showSaveOnlyOption={!!onSaveEditOnly} + {onCancelEdit} + {onSaveEdit} + {onSaveEditOnly} + {onEditKeydown} + {onEditedContentChange} + {onEditedExtrasChange} + {onEditedUploadedFilesChange} + /> + {:else} + {#if message.extra && message.extra.length > 0} + <div class="mb-2 max-w-[80%]"> + <ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" /> + </div> + {/if} + + {#if message.content.trim()} + <Card + 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" + data-multiline={isMultiline ? '' : undefined} + > + {#if currentConfig.renderUserContentAsMarkdown} + <div bind:this={messageElement} class="text-md"> + <MarkdownContent + class="markdown-user-content text-primary-foreground" + content={message.content} + /> + </div> + {:else} + <span bind:this={messageElement} class="text-md whitespace-pre-wrap"> + {message.content} + </span> + {/if} + </Card> + {/if} + + {#if message.timestamp} + <div class="max-w-[80%]"> + <ChatMessageActions + actionsPosition="right" + {deletionInfo} + justify="end" + {onConfirmDelete} + {onCopy} + {onDelete} + {onEdit} + {onNavigateToSibling} + {onShowDeleteDialogChange} + {siblingInfo} + {showDeleteDialog} + role="user" + /> + </div> + {/if} + {/if} +</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 @@ +<script lang="ts"> + import { ChatMessage } from '$lib/components/app'; + import { chatStore } from '$lib/stores/chat.svelte'; + import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; + import { config } from '$lib/stores/settings.svelte'; + import { getMessageSiblings } from '$lib/utils'; + + interface Props { + class?: string; + messages?: DatabaseMessage[]; + onUserAction?: () => void; + } + + let { class: className, messages = [], onUserAction }: Props = $props(); + + let allConversationMessages = $state<DatabaseMessage[]>([]); + const currentConfig = config(); + + function refreshAllMessages() { + const conversation = activeConversation(); + + if (conversation) { + conversationsStore.getConversationMessages(conversation.id).then((messages) => { + allConversationMessages = messages; + }); + } else { + allConversationMessages = []; + } + } + + // Single effect that tracks both conversation and message changes + $effect(() => { + const conversation = activeConversation(); + + if (conversation) { + refreshAllMessages(); + } + }); + + let displayMessages = $derived.by(() => { + if (!messages.length) { + return []; + } + + // Filter out system messages if showSystemMessage is false + const filteredMessages = currentConfig.showSystemMessage + ? messages + : messages.filter((msg) => msg.type !== 'system'); + + return filteredMessages.map((message) => { + const siblingInfo = getMessageSiblings(allConversationMessages, message.id); + + return { + message, + siblingInfo: siblingInfo || { + message, + siblingIds: [message.id], + currentIndex: 0, + totalSiblings: 1 + } + }; + }); + }); + + async function handleNavigateToSibling(siblingId: string) { + await conversationsStore.navigateToSibling(siblingId); + } + + async function handleEditWithBranching( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) { + onUserAction?.(); + + await chatStore.editMessageWithBranching(message.id, newContent, newExtras); + + refreshAllMessages(); + } + + async function handleEditWithReplacement( + message: DatabaseMessage, + newContent: string, + shouldBranch: boolean + ) { + onUserAction?.(); + + await chatStore.editAssistantMessage(message.id, newContent, shouldBranch); + + refreshAllMessages(); + } + + async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) { + onUserAction?.(); + + await chatStore.regenerateMessageWithBranching(message.id, modelOverride); + + refreshAllMessages(); + } + + async function handleContinueAssistantMessage(message: DatabaseMessage) { + onUserAction?.(); + + await chatStore.continueAssistantMessage(message.id); + + refreshAllMessages(); + } + + async function handleEditUserMessagePreserveResponses( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) { + onUserAction?.(); + + await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras); + + refreshAllMessages(); + } + + async function handleDeleteMessage(message: DatabaseMessage) { + await chatStore.deleteMessage(message.id); + + refreshAllMessages(); + } +</script> + +<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; "> + {#each displayMessages as { message, siblingInfo } (message.id)} + <ChatMessage + class="mx-auto w-full max-w-[48rem]" + {message} + {siblingInfo} + onDelete={handleDeleteMessage} + onNavigateToSibling={handleNavigateToSibling} + onEditWithBranching={handleEditWithBranching} + onEditWithReplacement={handleEditWithReplacement} + onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses} + onRegenerateWithBranching={handleRegenerateWithBranching} + onContinueAssistantMessage={handleContinueAssistantMessage} + /> + {/each} +</div> |
