webui: UI and routing fixes (#19586)
* 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 <admin@serveurperso.com>
This commit is contained in:
parent
43919b7f4f
commit
5174d7206f
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
import {
|
import {
|
||||||
chatStore,
|
chatStore,
|
||||||
pendingEditMessageId,
|
pendingEditMessageId,
|
||||||
|
|
@ -119,7 +120,7 @@
|
||||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||||
|
|
||||||
if (conversationDeleted) {
|
if (conversationDeleted) {
|
||||||
goto('/');
|
goto(`${base}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -220,7 +221,7 @@
|
||||||
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
const conversationDeleted = await removeSystemPromptPlaceholder(message.id);
|
||||||
isEditing = false;
|
isEditing = false;
|
||||||
if (conversationDeleted) {
|
if (conversationDeleted) {
|
||||||
goto('/');
|
goto(`${base}/`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { BadgeChatStatistic } from '$lib/components/app';
|
import { BadgeChatStatistic } from '$lib/components/app';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { ChatMessageStatsView } from '$lib/enums';
|
import { ChatMessageStatsView } from '$lib/enums';
|
||||||
|
import { formatPerformanceTime } from '$lib/utils/formatters';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
predictedTokens?: number;
|
predictedTokens?: number;
|
||||||
|
|
@ -57,8 +58,8 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
|
let tokensPerSecond = $derived(hasGenerationStats ? (predictedTokens! / predictedMs!) * 1000 : 0);
|
||||||
let timeInSeconds = $derived(
|
let formattedTime = $derived(
|
||||||
predictedMs !== undefined ? (predictedMs / 1000).toFixed(2) : '0.00'
|
predictedMs !== undefined ? formatPerformanceTime(predictedMs) : '0s'
|
||||||
);
|
);
|
||||||
|
|
||||||
let promptTokensPerSecond = $derived(
|
let promptTokensPerSecond = $derived(
|
||||||
|
|
@ -67,15 +68,15 @@
|
||||||
: undefined
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
let promptTimeInSeconds = $derived(
|
let formattedPromptTime = $derived(
|
||||||
promptMs !== undefined ? (promptMs / 1000).toFixed(2) : undefined
|
promptMs !== undefined ? formatPerformanceTime(promptMs) : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
let hasPromptStats = $derived(
|
let hasPromptStats = $derived(
|
||||||
promptTokens !== undefined &&
|
promptTokens !== undefined &&
|
||||||
promptMs !== undefined &&
|
promptMs !== undefined &&
|
||||||
promptTokensPerSecond !== undefined &&
|
promptTokensPerSecond !== undefined &&
|
||||||
promptTimeInSeconds !== undefined
|
formattedPromptTime !== undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
// In live mode, generation tab is disabled until we have generation stats
|
// In live mode, generation tab is disabled until we have generation stats
|
||||||
|
|
@ -142,7 +143,7 @@
|
||||||
<BadgeChatStatistic
|
<BadgeChatStatistic
|
||||||
class="bg-transparent"
|
class="bg-transparent"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
value="{timeInSeconds}s"
|
value={formattedTime}
|
||||||
tooltipLabel="Generation time"
|
tooltipLabel="Generation time"
|
||||||
/>
|
/>
|
||||||
<BadgeChatStatistic
|
<BadgeChatStatistic
|
||||||
|
|
@ -161,7 +162,7 @@
|
||||||
<BadgeChatStatistic
|
<BadgeChatStatistic
|
||||||
class="bg-transparent"
|
class="bg-transparent"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
value="{promptTimeInSeconds}s"
|
value={formattedPromptTime ?? '0s'}
|
||||||
tooltipLabel="Prompt processing time"
|
tooltipLabel="Prompt processing time"
|
||||||
/>
|
/>
|
||||||
<BadgeChatStatistic
|
<BadgeChatStatistic
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -486,6 +486,8 @@
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
div :global(a:hover) {
|
div :global(a:hover) {
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,75 @@ export function formatNumber(num: number | unknown): string {
|
||||||
|
|
||||||
return num.toLocaleString();
|
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(' ');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
||||||
import { expect } from 'storybook/test';
|
import { expect } from 'storybook/test';
|
||||||
import { mockServerProps, mockConfigs } from './fixtures/storybook-mocks';
|
|
||||||
import jpgAsset from './fixtures/assets/1.jpg?url';
|
import jpgAsset from './fixtures/assets/1.jpg?url';
|
||||||
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
||||||
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
||||||
|
|
@ -46,8 +45,6 @@
|
||||||
name="Default"
|
name="Default"
|
||||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||||
play={async ({ canvas, userEvent }) => {
|
play={async ({ canvas, userEvent }) => {
|
||||||
mockServerProps(mockConfigs.noModalities);
|
|
||||||
|
|
||||||
const textarea = await canvas.findByRole('textbox');
|
const textarea = await canvas.findByRole('textbox');
|
||||||
const submitButton = await canvas.findByRole('button', { name: 'Send' });
|
const submitButton = await canvas.findByRole('button', { name: 'Send' });
|
||||||
|
|
||||||
|
|
@ -66,73 +63,11 @@
|
||||||
|
|
||||||
const fileInput = document.querySelector('input[type="file"]');
|
const fileInput = document.querySelector('input[type="file"]');
|
||||||
await expect(fileInput).not.toHaveAttribute('accept');
|
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="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
|
<Story
|
||||||
name="FileAttachments"
|
name="FileAttachments"
|
||||||
args={{
|
args={{
|
||||||
|
|
@ -140,8 +75,6 @@
|
||||||
uploadedFiles: fileAttachments
|
uploadedFiles: fileAttachments
|
||||||
}}
|
}}
|
||||||
play={async ({ canvas }) => {
|
play={async ({ canvas }) => {
|
||||||
mockServerProps(mockConfigs.bothModalities);
|
|
||||||
|
|
||||||
const jpgAttachment = canvas.getByAltText('1.jpg');
|
const jpgAttachment = canvas.getByAltText('1.jpg');
|
||||||
const svgAttachment = canvas.getByAltText('hf-logo.svg');
|
const svgAttachment = canvas.getByAltText('hf-logo.svg');
|
||||||
const pdfFileExtension = canvas.getByText('PDF');
|
const pdfFileExtension = canvas.getByText('PDF');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue