summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte617
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte17
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte28
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte120
4 files changed, 782 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
new file mode 100644
index 0000000..2743955
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -0,0 +1,617 @@
+<script lang="ts">
+ import { afterNavigate } from '$app/navigation';
+ import {
+ ChatForm,
+ ChatScreenHeader,
+ ChatMessages,
+ ChatScreenProcessingInfo,
+ DialogEmptyFileAlert,
+ DialogChatError,
+ ServerLoadingSplash,
+ DialogConfirmation
+ } from '$lib/components/app';
+ import * as Alert from '$lib/components/ui/alert';
+ import * as AlertDialog from '$lib/components/ui/alert-dialog';
+ import {
+ AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
+ AUTO_SCROLL_INTERVAL,
+ INITIAL_SCROLL_DELAY
+ } from '$lib/constants/auto-scroll';
+ import {
+ chatStore,
+ errorDialog,
+ isLoading,
+ isEditing,
+ getAddFilesHandler
+ } from '$lib/stores/chat.svelte';
+ import {
+ conversationsStore,
+ activeMessages,
+ activeConversation
+ } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+ import { serverLoading, serverError, serverStore, isRouterMode } from '$lib/stores/server.svelte';
+ import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+ import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
+ import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
+ import { onMount } from 'svelte';
+ import { fade, fly, slide } from 'svelte/transition';
+ import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
+ import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
+
+ let { showCenteredEmpty = false } = $props();
+
+ let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
+ let autoScrollEnabled = $state(true);
+ let chatScrollContainer: HTMLDivElement | undefined = $state();
+ let dragCounter = $state(0);
+ let isDragOver = $state(false);
+ let lastScrollTop = $state(0);
+ let scrollInterval: ReturnType<typeof setInterval> | undefined;
+ let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
+ let showFileErrorDialog = $state(false);
+ let uploadedFiles = $state<ChatUploadedFile[]>([]);
+ let userScrolledUp = $state(false);
+
+ let fileErrorData = $state<{
+ generallyUnsupported: File[];
+ modalityUnsupported: File[];
+ modalityReasons: Record<string, string>;
+ supportedTypes: string[];
+ }>({
+ generallyUnsupported: [],
+ modalityUnsupported: [],
+ modalityReasons: {},
+ supportedTypes: []
+ });
+
+ let showDeleteDialog = $state(false);
+
+ let showEmptyFileDialog = $state(false);
+
+ let emptyFileNames = $state<string[]>([]);
+
+ let isEmpty = $derived(
+ showCenteredEmpty && !activeConversation() && activeMessages().length === 0 && !isLoading()
+ );
+
+ let activeErrorDialog = $derived(errorDialog());
+ let isServerLoading = $derived(serverLoading());
+ let hasPropsError = $derived(!!serverError());
+
+ let isCurrentConversationLoading = $derived(isLoading());
+
+ let isRouter = $derived(isRouterMode());
+
+ let conversationModel = $derived(
+ chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
+ );
+
+ let activeModelId = $derived.by(() => {
+ const options = modelOptions();
+
+ if (!isRouter) {
+ return options.length > 0 ? options[0].model : null;
+ }
+
+ const selectedId = selectedModelId();
+ if (selectedId) {
+ const model = options.find((m) => m.id === selectedId);
+ if (model) return model.model;
+ }
+
+ if (conversationModel) {
+ const model = options.find((m) => m.model === conversationModel);
+ if (model) return model.model;
+ }
+
+ return null;
+ });
+
+ let modelPropsVersion = $state(0);
+
+ $effect(() => {
+ if (activeModelId) {
+ const cached = modelsStore.getModelProps(activeModelId);
+ if (!cached) {
+ modelsStore.fetchModelProps(activeModelId).then(() => {
+ modelPropsVersion++;
+ });
+ }
+ }
+ });
+
+ let hasAudioModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+ return modelsStore.modelSupportsAudio(activeModelId);
+ }
+
+ return false;
+ });
+
+ let hasVisionModality = $derived.by(() => {
+ if (activeModelId) {
+ void modelPropsVersion;
+
+ return modelsStore.modelSupportsVision(activeModelId);
+ }
+
+ return false;
+ });
+
+ async function handleDeleteConfirm() {
+ const conversation = activeConversation();
+
+ if (conversation) {
+ await conversationsStore.deleteConversation(conversation.id);
+ }
+
+ showDeleteDialog = false;
+ }
+
+ function handleDragEnter(event: DragEvent) {
+ event.preventDefault();
+
+ dragCounter++;
+
+ if (event.dataTransfer?.types.includes('Files')) {
+ isDragOver = true;
+ }
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ event.preventDefault();
+
+ dragCounter--;
+
+ if (dragCounter === 0) {
+ isDragOver = false;
+ }
+ }
+
+ function handleErrorDialogOpenChange(open: boolean) {
+ if (!open) {
+ chatStore.dismissErrorDialog();
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ }
+
+ function handleDrop(event: DragEvent) {
+ event.preventDefault();
+
+ isDragOver = false;
+ dragCounter = 0;
+
+ if (event.dataTransfer?.files) {
+ const files = Array.from(event.dataTransfer.files);
+
+ if (isEditing()) {
+ const handler = getAddFilesHandler();
+
+ if (handler) {
+ handler(files);
+ return;
+ }
+ }
+
+ processFiles(files);
+ }
+ }
+
+ function handleFileRemove(fileId: string) {
+ uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
+ }
+
+ function handleFileUpload(files: File[]) {
+ processFiles(files);
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ const isCtrlOrCmd = event.ctrlKey || event.metaKey;
+
+ if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) {
+ event.preventDefault();
+ if (activeConversation()) {
+ showDeleteDialog = true;
+ }
+ }
+ }
+
+ function handleScroll() {
+ if (disableAutoScroll || !chatScrollContainer) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
+ const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
+
+ if (scrollTop < lastScrollTop && !isAtBottom) {
+ userScrolledUp = true;
+ autoScrollEnabled = false;
+ } else if (isAtBottom && userScrolledUp) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+
+ if (scrollTimeout) {
+ clearTimeout(scrollTimeout);
+ }
+
+ scrollTimeout = setTimeout(() => {
+ if (isAtBottom) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+ }, AUTO_SCROLL_INTERVAL);
+
+ lastScrollTop = scrollTop;
+ }
+
+ async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
+ const result = files
+ ? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
+ : undefined;
+
+ if (result?.emptyFiles && result.emptyFiles.length > 0) {
+ emptyFileNames = result.emptyFiles;
+ showEmptyFileDialog = true;
+
+ if (files) {
+ const emptyFileNamesSet = new Set(result.emptyFiles);
+ uploadedFiles = uploadedFiles.filter((file) => !emptyFileNamesSet.has(file.name));
+ }
+ return false;
+ }
+
+ const extras = result?.extras;
+
+ // Enable autoscroll for user-initiated message sending
+ if (!disableAutoScroll) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ }
+ await chatStore.sendMessage(message, extras);
+ scrollChatToBottom();
+
+ return true;
+ }
+
+ async function processFiles(files: File[]) {
+ const generallySupported: File[] = [];
+ const generallyUnsupported: File[] = [];
+
+ for (const file of files) {
+ if (isFileTypeSupported(file.name, file.type)) {
+ generallySupported.push(file);
+ } else {
+ generallyUnsupported.push(file);
+ }
+ }
+
+ // Use model-specific capabilities for file validation
+ const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
+ const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
+ generallySupported,
+ capabilities
+ );
+
+ const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
+
+ if (allUnsupportedFiles.length > 0) {
+ const supportedTypes: string[] = ['text files', 'PDFs'];
+
+ if (hasVisionModality) supportedTypes.push('images');
+ if (hasAudioModality) supportedTypes.push('audio files');
+
+ fileErrorData = {
+ generallyUnsupported,
+ modalityUnsupported: unsupportedFiles,
+ modalityReasons,
+ supportedTypes
+ };
+ showFileErrorDialog = true;
+ }
+
+ if (supportedFiles.length > 0) {
+ const processed = await processFilesToChatUploaded(
+ supportedFiles,
+ activeModelId ?? undefined
+ );
+ uploadedFiles = [...uploadedFiles, ...processed];
+ }
+ }
+
+ function scrollChatToBottom(behavior: ScrollBehavior = 'smooth') {
+ if (disableAutoScroll) return;
+
+ chatScrollContainer?.scrollTo({
+ top: chatScrollContainer?.scrollHeight,
+ behavior
+ });
+ }
+
+ afterNavigate(() => {
+ if (!disableAutoScroll) {
+ setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ }
+ });
+
+ onMount(() => {
+ if (!disableAutoScroll) {
+ setTimeout(() => scrollChatToBottom('instant'), INITIAL_SCROLL_DELAY);
+ }
+ });
+
+ $effect(() => {
+ if (disableAutoScroll) {
+ autoScrollEnabled = false;
+ if (scrollInterval) {
+ clearInterval(scrollInterval);
+ scrollInterval = undefined;
+ }
+ return;
+ }
+
+ if (isCurrentConversationLoading && autoScrollEnabled) {
+ scrollInterval = setInterval(scrollChatToBottom, AUTO_SCROLL_INTERVAL);
+ } else if (scrollInterval) {
+ clearInterval(scrollInterval);
+ scrollInterval = undefined;
+ }
+ });
+</script>
+
+{#if isDragOver}
+ <ChatScreenDragOverlay />
+{/if}
+
+<svelte:window onkeydown={handleKeydown} />
+
+<ChatScreenHeader />
+
+{#if !isEmpty}
+ <div
+ bind:this={chatScrollContainer}
+ aria-label="Chat interface with file drop zone"
+ class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
+ ondragenter={handleDragEnter}
+ ondragleave={handleDragLeave}
+ ondragover={handleDragOver}
+ ondrop={handleDrop}
+ onscroll={handleScroll}
+ role="main"
+ >
+ <ChatMessages
+ class="mb-16 md:mb-24"
+ messages={activeMessages()}
+ onUserAction={() => {
+ if (!disableAutoScroll) {
+ userScrolledUp = false;
+ autoScrollEnabled = true;
+ scrollChatToBottom();
+ }
+ }}
+ />
+
+ <div
+ class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
+ in:slide={{ duration: 150, axis: 'y' }}
+ >
+ <ChatScreenProcessingInfo />
+
+ {#if hasPropsError}
+ <div
+ class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
+ in:fly={{ y: 10, duration: 250 }}
+ >
+ <Alert.Root variant="destructive">
+ <AlertTriangle class="h-4 w-4" />
+ <Alert.Title class="flex items-center justify-between">
+ <span>Server unavailable</span>
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={isServerLoading}
+ class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
+ >
+ <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+ {isServerLoading ? 'Retrying...' : 'Retry'}
+ </button>
+ </Alert.Title>
+ <Alert.Description>{serverError()}</Alert.Description>
+ </Alert.Root>
+ </div>
+ {/if}
+
+ <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
+ <ChatForm
+ disabled={hasPropsError || isEditing()}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={handleFileRemove}
+ onFileUpload={handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ showHelperText={false}
+ bind:uploadedFiles
+ />
+ </div>
+ </div>
+ </div>
+{:else if isServerLoading}
+ <!-- Server Loading State -->
+ <ServerLoadingSplash />
+{:else}
+ <div
+ aria-label="Welcome screen with file drop zone"
+ class="flex h-full items-center justify-center"
+ ondragenter={handleDragEnter}
+ ondragleave={handleDragLeave}
+ ondragover={handleDragOver}
+ ondrop={handleDrop}
+ role="main"
+ >
+ <div class="w-full max-w-[48rem] px-4">
+ <div class="mb-10 text-center" in:fade={{ duration: 300 }}>
+ <h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
+
+ <p class="text-lg text-muted-foreground">
+ {serverStore.props?.modalities?.audio
+ ? 'Record audio, type a message '
+ : 'Type a message'} or upload files to get started
+ </p>
+ </div>
+
+ {#if hasPropsError}
+ <div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
+ <Alert.Root variant="destructive">
+ <AlertTriangle class="h-4 w-4" />
+ <Alert.Title class="flex items-center justify-between">
+ <span>Server unavailable</span>
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={isServerLoading}
+ class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
+ >
+ <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+ {isServerLoading ? 'Retrying...' : 'Retry'}
+ </button>
+ </Alert.Title>
+ <Alert.Description>{serverError()}</Alert.Description>
+ </Alert.Root>
+ </div>
+ {/if}
+
+ <div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
+ <ChatForm
+ disabled={hasPropsError}
+ isLoading={isCurrentConversationLoading}
+ onFileRemove={handleFileRemove}
+ onFileUpload={handleFileUpload}
+ onSend={handleSendMessage}
+ onStop={() => chatStore.stopGeneration()}
+ showHelperText={true}
+ bind:uploadedFiles
+ />
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- File Upload Error Alert Dialog -->
+<AlertDialog.Root bind:open={showFileErrorDialog}>
+ <AlertDialog.Portal>
+ <AlertDialog.Overlay />
+
+ <AlertDialog.Content class="flex max-w-md flex-col">
+ <AlertDialog.Header>
+ <AlertDialog.Title>File Upload Error</AlertDialog.Title>
+
+ <AlertDialog.Description class="text-sm text-muted-foreground">
+ Some files cannot be uploaded with the current model.
+ </AlertDialog.Description>
+ </AlertDialog.Header>
+
+ <div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
+ {#if fileErrorData.generallyUnsupported.length > 0}
+ <div class="space-y-2">
+ <h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
+
+ <div class="space-y-1">
+ {#each fileErrorData.generallyUnsupported as file (file.name)}
+ <div class="rounded-md bg-destructive/10 px-3 py-2">
+ <p class="font-mono text-sm break-all text-destructive">
+ {file.name}
+ </p>
+
+ <p class="mt-1 text-xs text-muted-foreground">File type not supported</p>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ {#if fileErrorData.modalityUnsupported.length > 0}
+ <div class="space-y-2">
+ <div class="space-y-1">
+ {#each fileErrorData.modalityUnsupported as file (file.name)}
+ <div class="rounded-md bg-destructive/10 px-3 py-2">
+ <p class="font-mono text-sm break-all text-destructive">
+ {file.name}
+ </p>
+
+ <p class="mt-1 text-xs text-muted-foreground">
+ {fileErrorData.modalityReasons[file.name] || 'Not supported by current model'}
+ </p>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <div class="rounded-md bg-muted/50 p-3">
+ <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
+
+ <p class="text-sm text-muted-foreground">
+ {fileErrorData.supportedTypes.join(', ')}
+ </p>
+ </div>
+
+ <AlertDialog.Footer>
+ <AlertDialog.Action onclick={() => (showFileErrorDialog = false)}>
+ Got it
+ </AlertDialog.Action>
+ </AlertDialog.Footer>
+ </AlertDialog.Content>
+ </AlertDialog.Portal>
+</AlertDialog.Root>
+
+<DialogConfirmation
+ bind:open={showDeleteDialog}
+ title="Delete Conversation"
+ description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
+ confirmText="Delete"
+ cancelText="Cancel"
+ variant="destructive"
+ icon={Trash2}
+ onConfirm={handleDeleteConfirm}
+ onCancel={() => (showDeleteDialog = false)}
+/>
+
+<DialogEmptyFileAlert
+ bind:open={showEmptyFileDialog}
+ emptyFiles={emptyFileNames}
+ onOpenChange={(open) => {
+ if (!open) {
+ emptyFileNames = [];
+ }
+ }}
+/>
+
+<DialogChatError
+ message={activeErrorDialog?.message ?? ''}
+ contextInfo={activeErrorDialog?.contextInfo}
+ onOpenChange={handleErrorDialogOpenChange}
+ open={Boolean(activeErrorDialog)}
+ type={activeErrorDialog?.type ?? 'server'}
+/>
+
+<style>
+ .conversation-chat-form {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 2.375rem;
+ background-color: var(--background);
+ }
+ }
+</style>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte
new file mode 100644
index 0000000..ab4adb2
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenDragOverlay.svelte
@@ -0,0 +1,17 @@
+<script>
+ import { Upload } from '@lucide/svelte';
+</script>
+
+<div
+ class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
+>
+ <div
+ class="flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-border bg-background p-12 shadow-lg"
+ >
+ <Upload class="mb-4 h-12 w-12 text-muted-foreground" />
+
+ <p class="text-lg font-medium text-foreground">Attach a file</p>
+
+ <p class="text-sm text-muted-foreground">Drop your files here to upload</p>
+ </div>
+</div>
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
new file mode 100644
index 0000000..874140f
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
@@ -0,0 +1,28 @@
+<script lang="ts">
+ import { Settings } from '@lucide/svelte';
+ import { DialogChatSettings } from '$lib/components/app';
+ import { Button } from '$lib/components/ui/button';
+ import { useSidebar } from '$lib/components/ui/sidebar';
+
+ const sidebar = useSidebar();
+
+ let settingsOpen = $state(false);
+
+ function toggleSettings() {
+ settingsOpen = true;
+ }
+</script>
+
+<header
+ class="md:background-transparent pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end bg-background/40 p-4 backdrop-blur-xl duration-200 ease-linear {sidebar.open
+ ? 'md:left-[var(--sidebar-width)]'
+ : ''}"
+>
+ <div class="pointer-events-auto flex items-center space-x-2">
+ <Button variant="ghost" size="sm" onclick={toggleSettings}>
+ <Settings class="h-4 w-4" />
+ </Button>
+ </div>
+</header>
+
+<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
new file mode 100644
index 0000000..a60ae9e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
@@ -0,0 +1,120 @@
+<script lang="ts">
+ import { untrack } from 'svelte';
+ import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
+ import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
+ import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
+ import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
+ import { config } from '$lib/stores/settings.svelte';
+
+ const processingState = useProcessingState();
+
+ let isCurrentConversationLoading = $derived(isLoading());
+ let isStreaming = $derived(isChatStreaming());
+ let hasProcessingData = $derived(processingState.processingState !== null);
+ let processingDetails = $derived(processingState.getProcessingDetails());
+
+ let showProcessingInfo = $derived(
+ isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
+ );
+
+ $effect(() => {
+ const conversation = activeConversation();
+
+ untrack(() => chatStore.setActiveProcessingConversation(conversation?.id ?? null));
+ });
+
+ $effect(() => {
+ const keepStatsVisible = config().keepStatsVisible;
+ const shouldMonitor = keepStatsVisible || isCurrentConversationLoading || isStreaming;
+
+ if (shouldMonitor) {
+ processingState.startMonitoring();
+ }
+
+ if (!isCurrentConversationLoading && !isStreaming && !keepStatsVisible) {
+ const timeout = setTimeout(() => {
+ if (!config().keepStatsVisible && !isChatStreaming()) {
+ processingState.stopMonitoring();
+ }
+ }, PROCESSING_INFO_TIMEOUT);
+
+ return () => clearTimeout(timeout);
+ }
+ });
+
+ $effect(() => {
+ const conversation = activeConversation();
+ const messages = activeMessages() as DatabaseMessage[];
+ const keepStatsVisible = config().keepStatsVisible;
+
+ if (keepStatsVisible && conversation) {
+ if (messages.length === 0) {
+ untrack(() => chatStore.clearProcessingState(conversation.id));
+ return;
+ }
+
+ if (!isCurrentConversationLoading && !isStreaming) {
+ untrack(() => chatStore.restoreProcessingStateFromMessages(messages, conversation.id));
+ }
+ }
+ });
+</script>
+
+<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
+ <div class="chat-processing-info-content">
+ {#each processingDetails as detail (detail)}
+ <span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
+ {/each}
+ </div>
+</div>
+
+<style>
+ .chat-processing-info-container {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ padding: 1.5rem 1rem;
+ opacity: 0;
+ transform: translateY(50%);
+ transition:
+ opacity 300ms ease-out,
+ transform 300ms ease-out;
+ }
+
+ .chat-processing-info-container.visible {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .chat-processing-info-content {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ justify-content: center;
+ max-width: 48rem;
+ margin: 0 auto;
+ }
+
+ .chat-processing-info-detail {
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ background: var(--muted);
+ border-radius: 0.375rem;
+ font-family:
+ ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
+ white-space: nowrap;
+ }
+
+ @media (max-width: 768px) {
+ .chat-processing-info-content {
+ gap: 0.5rem;
+ }
+
+ .chat-processing-info-detail {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.5rem;
+ }
+ }
+</style>