]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: UI and routing fixes (#19586)
authorAleksander Grygier <redacted>
Fri, 13 Feb 2026 11:31:00 +0000 (12:31 +0100)
committerGitHub <redacted>
Fri, 13 Feb 2026 11:31:00 +0000 (12:31 +0100)
* chore: update webui build output

* chore: update webui build output

* fix: Scroll issues in DropdownMenuSearchable

* webui: fix redirect to root ignoring base path

* fix: Word wrapping

* fix: remove obsolete modality UI tests causing CI failures

- Remove VisionModality/AudioModality test stories
- Remove mockServerProps usage and imports
- Simplify Default test (remove dropdown interaction checks)
- Simplify FileAttachments test (remove mocks)

* feat: Improve formatting performance time

---------

Co-authored-by: Pascal <redacted>
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageStatistics.svelte
tools/server/webui/src/lib/components/app/misc/DropdownMenuSearchable.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
tools/server/webui/src/lib/utils/formatters.ts
tools/server/webui/tests/stories/ChatForm.stories.svelte

index 82ef7de7c75b8e9c702ef5e9b2c09cde16ef8e20..3470e2f711d583b417f5032df4f4224b460f1b3e 100644 (file)
@@ -1,5 +1,6 @@
 <script lang="ts">
        import { goto } from '$app/navigation';
+       import { base } from '$app/paths';
        import {
                chatStore,
                pendingEditMessageId,
                        const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
 
                        if (conversationDeleted) {
-                               goto('/');
+                               goto(`${base}/`);
                        }
 
                        return;
                                const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
                                isEditing = false;
                                if (conversationDeleted) {
-                                       goto('/');
+                                       goto(`${base}/`);
                                }
                                return;
                        }
index 24fe5926bad1b72e285757c69493baf1b8c0ead5..d457e042fcbcf068d69380ae23d16b3fafce8f8f 100644 (file)
@@ -3,6 +3,7 @@
        import { BadgeChatStatistic } from '$lib/components/app';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { ChatMessageStatsView } from '$lib/enums';
+       import { formatPerformanceTime } from '$lib/utils/formatters';
 
        interface Props {
                predictedTokens?: number;
@@ -57,8 +58,8 @@
        );
 
        let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
-       let timeInSeconds = $derived(
-               predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
+       let formattedTime = $derived(
+               predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
        );
 
        let promptTokensPerSecond = $derived(
                        : undefined
        );
 
-       let promptTimeInSeconds = $derived(
-               promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
+       let formattedPromptTime = $derived(
+               promptMs !== undefined ? formatPerformanceTime(promptMs) : undefined
        );
 
        let hasPromptStats = $derived(
                promptTokens !== undefined &&
                        promptMs !== undefined &&
                        promptTokensPerSecond !== undefined &&
-                       promptTimeInSeconds !== undefined
+                       formattedPromptTime !== undefined
        );
 
        // In live mode, generation tab is disabled until we have generation stats
                        <BadgeChatStatistic
                                class="bg-transparent"
                                icon={Clock}
-                               value="{timeInSeconds}s"
+                               value={formattedTime}
                                tooltipLabel="Generation time"
                        />
                        <BadgeChatStatistic
                        <BadgeChatStatistic
                                class="bg-transparent"
                                icon={Clock}
-                               value="{promptTimeInSeconds}s"
+                               value={formattedPromptTime ?? '0s'}
                                tooltipLabel="Prompt processing time"
                        />
                        <BadgeChatStatistic
diff --git a/tools/server/webui/src/lib/components/app/misc/DropdownMenuSearchable.svelte b/tools/server/webui/src/lib/components/app/misc/DropdownMenuSearchable.svelte
new file mode 100644 (file)
index 0000000..21ba04c
--- /dev/null
@@ -0,0 +1,88 @@
+<script lang="ts">
+       import type { Snippet } from 'svelte';
+       import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
+       import { cn } from '$lib/components/ui/utils';
+       import { SearchInput } from '$lib/components/app';
+
+       interface Props {
+               open?: boolean;
+               onOpenChange?: (open: boolean) => void;
+               placeholder?: string;
+               searchValue?: string;
+               onSearchChange?: (value: string) => void;
+               onSearchKeyDown?: (event: KeyboardEvent) => void;
+               align?: 'start' | 'center' | 'end';
+               contentClass?: string;
+               emptyMessage?: string;
+               isEmpty?: boolean;
+               disabled?: boolean;
+               trigger: Snippet;
+               children: Snippet;
+               footer?: Snippet;
+       }
+
+       let {
+               open = $bindable(false),
+               onOpenChange,
+               placeholder = 'Search...',
+               searchValue = $bindable(''),
+               onSearchChange,
+               onSearchKeyDown,
+               align = 'start',
+               contentClass = 'w-72',
+               emptyMessage = 'No items found',
+               isEmpty = false,
+               disabled = false,
+               trigger,
+               children,
+               footer
+       }: Props = $props();
+
+       function handleOpenChange(newOpen: boolean) {
+               open = newOpen;
+
+               if (!newOpen) {
+                       searchValue = '';
+                       onSearchChange?.('');
+               }
+
+               onOpenChange?.(newOpen);
+       }
+</script>
+
+<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
+       <DropdownMenu.Trigger
+               {disabled}
+               onclick={(e) => {
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+       >
+               {@render trigger()}
+       </DropdownMenu.Trigger>
+
+       <DropdownMenu.Content {align} class={cn(contentClass, 'pt-0')}>
+               <div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
+                       <SearchInput
+                               {placeholder}
+                               bind:value={searchValue}
+                               onInput={onSearchChange}
+                               onKeyDown={onSearchKeyDown}
+                       />
+               </div>
+
+               <div class={cn('overflow-y-auto')}>
+                       {@render children()}
+
+                       {#if isEmpty}
+                               <div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
+                       {/if}
+               </div>
+
+               {#if footer}
+                       <DropdownMenu.Separator />
+
+                       {@render footer()}
+               {/if}
+       </DropdownMenu.Content>
+</DropdownMenu.Root>
index cb3ae17a63fbae601ccb876c8753f8128cb45e0e..0084499f85fb497bc7d98d7c868e2f89727cd66b 100644 (file)
                text-decoration: underline;
                text-underline-offset: 2px;
                transition: color 0.2s ease;
+               overflow-wrap: anywhere;
+               word-break: break-all;
        }
 
        div :global(a:hover) {
index ae9f59a39c38218f7375199131dd38270cb0e1ad..bdf2ca26fd552840de3db12022b4d595b1fba206 100644 (file)
@@ -51,3 +51,75 @@ export function formatNumber(num: number | unknown): string {
 
        return num.toLocaleString();
 }
+
+/**
+ * Format JSON string with pretty printing (2-space indentation)
+ * Returns original string if parsing fails
+ *
+ * @param jsonString - JSON string to format
+ * @returns Pretty-printed JSON string or original if invalid
+ */
+export function formatJsonPretty(jsonString: string): string {
+       try {
+               const parsed = JSON.parse(jsonString);
+               return JSON.stringify(parsed, null, 2);
+       } catch {
+               return jsonString;
+       }
+}
+
+/**
+ * Format time as HH:MM:SS in 24-hour format
+ *
+ * @param date - Date object to format
+ * @returns Formatted time string (HH:MM:SS)
+ */
+export function formatTime(date: Date): string {
+       return date.toLocaleTimeString('en-US', {
+               hour12: false,
+               hour: '2-digit',
+               minute: '2-digit',
+               second: '2-digit'
+       });
+}
+
+/**
+ * Formats milliseconds to a human-readable time string for performance metrics.
+ * Examples: "4h 12min 54s", "12min 34s", "45s", "0.5s"
+ *
+ * @param ms - Time in milliseconds
+ * @returns Formatted time string
+ */
+export function formatPerformanceTime(ms: number): string {
+       if (ms < 0) return '0s';
+
+       const totalSeconds = ms / 1000;
+
+       if (totalSeconds < 1) {
+               return `${totalSeconds.toFixed(1)}s`;
+       }
+
+       if (totalSeconds < 10) {
+               return `${totalSeconds.toFixed(1)}s`;
+       }
+
+       const hours = Math.floor(totalSeconds / 3600);
+       const minutes = Math.floor((totalSeconds % 3600) / 60);
+       const seconds = Math.floor(totalSeconds % 60);
+
+       const parts: string[] = [];
+
+       if (hours > 0) {
+               parts.push(`${hours}h`);
+       }
+
+       if (minutes > 0) {
+               parts.push(`${minutes}min`);
+       }
+
+       if (seconds > 0 || parts.length === 0) {
+               parts.push(`${seconds}s`);
+       }
+
+       return parts.join(' ');
+}
index 18319e8e611817e981238669b705a8eeb75d8768..a8a4c21b445e2aa95fdcf89c2ca950775260acbb 100644 (file)
@@ -2,7 +2,6 @@
        import { defineMeta } from '@storybook/addon-svelte-csf';
        import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
        import { expect } from 'storybook/test';
-       import { mockServerProps, mockConfigs } from './fixtures/storybook-mocks';
        import jpgAsset from './fixtures/assets/1.jpg?url';
        import svgAsset from './fixtures/assets/hf-logo.svg?url';
        import pdfAsset from './fixtures/assets/example.pdf?raw';
@@ -46,8 +45,6 @@
        name="Default"
        args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
        play={async ({ canvas, userEvent }) => {
-               mockServerProps(mockConfigs.noModalities);
-
                const textarea = await canvas.findByRole('textbox');
                const submitButton = await canvas.findByRole('button', { name: 'Send' });
 
 
                const fileInput = document.querySelector('input[type="file"]');
                await expect(fileInput).not.toHaveAttribute('accept');
-
-               // Open file attachments dropdown
-               const fileUploadButton = canvas.getByText('Attach files');
-               await userEvent.click(fileUploadButton);
-
-               // Check dropdown menu items are disabled (no modalities)
-               const imagesButton = document.querySelector('.images-button');
-               const audioButton = document.querySelector('.audio-button');
-
-               await expect(imagesButton).toHaveAttribute('data-disabled');
-               await expect(audioButton).toHaveAttribute('data-disabled');
-
-               // Close dropdown by pressing Escape
-               await userEvent.keyboard('{Escape}');
        }}
 />
 
 <Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
 
-<Story
-       name="VisionModality"
-       args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
-       play={async ({ canvas, userEvent }) => {
-               mockServerProps(mockConfigs.visionOnly);
-
-               // Open file attachments dropdown and verify it works
-               const fileUploadButton = canvas.getByText('Attach files');
-               await userEvent.click(fileUploadButton);
-
-               // Verify dropdown menu items exist
-               const imagesButton = document.querySelector('.images-button');
-               const audioButton = document.querySelector('.audio-button');
-
-               await expect(imagesButton).toBeInTheDocument();
-               await expect(audioButton).toBeInTheDocument();
-
-               // Close dropdown by pressing Escape
-               await userEvent.keyboard('{Escape}');
-
-               console.log('✅ Vision modality: Dropdown menu verified');
-       }}
-/>
-
-<Story
-       name="AudioModality"
-       args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
-       play={async ({ canvas, userEvent }) => {
-               mockServerProps(mockConfigs.audioOnly);
-
-               // Open file attachments dropdown and verify it works
-               const fileUploadButton = canvas.getByText('Attach files');
-               await userEvent.click(fileUploadButton);
-
-               // Verify dropdown menu items exist
-               const imagesButton = document.querySelector('.images-button');
-               const audioButton = document.querySelector('.audio-button');
-
-               await expect(imagesButton).toBeInTheDocument();
-               await expect(audioButton).toBeInTheDocument();
-
-               // Close dropdown by pressing Escape
-               await userEvent.keyboard('{Escape}');
-
-               console.log('✅ Audio modality: Dropdown menu verified');
-       }}
-/>
-
 <Story
        name="FileAttachments"
        args={{
                uploadedFiles: fileAttachments
        }}
        play={async ({ canvas }) => {
-               mockServerProps(mockConfigs.bothModalities);
-
                const jpgAttachment = canvas.getByAltText('1.jpg');
                const svgAttachment = canvas.getByAltText('hf-logo.svg');
                const pdfFileExtension = canvas.getByText('PDF');