Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified tools/server/public/index.html.gz
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
updateConversationName
} from '$lib/stores/chat.svelte';
import ChatSidebarActions from './ChatSidebarActions.svelte';
import ModelSelector from './ModelSelector.svelte';
const sidebar = Sidebar.useSidebar();
Expand Down Expand Up @@ -74,6 +75,8 @@
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
</a>

<ModelSelector />

<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
</Sidebar.Header>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Loader2, RefreshCw } from '@lucide/svelte';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import {
fetchModels,
modelOptions,
modelsError,
modelsLoading,
modelsUpdating,
selectModel,
selectedModelId
} from '$lib/stores/models.svelte';
import type { ModelOption } from '$lib/stores/models.svelte';
import { toast } from 'svelte-sonner';

let options = $derived(modelOptions());
let loading = $derived(modelsLoading());
let updating = $derived(modelsUpdating());
let error = $derived(modelsError());
let activeId = $derived(selectedModelId());

let isMounted = $state(false);

onMount(async () => {
try {
await fetchModels();
} catch (error) {
reportError('Unable to load models', error);
} finally {
isMounted = true;
}
});

async function handleRefresh() {
try {
await fetchModels(true);
toast.success('Model list refreshed');
} catch (error) {
reportError('Failed to refresh model list', error);
}
}

async function handleSelect(value: string | undefined) {
if (!value) return;

const option = options.find((item) => item.id === value);
if (!option) {
reportError('Model is no longer available', new Error('Unknown model'));
return;
}

try {
await selectModel(option.id);
toast.success(`Switched to ${option.name}`);
} catch (error) {
reportError('Failed to switch model', error);
}
}

function reportError(message: string, error: unknown) {
const description = error instanceof Error ? error.message : 'Unknown error';
toast.error(message, { description });
}

function getDisplayOption(): ModelOption | undefined {
if (activeId) {
return options.find((option) => option.id === activeId);
}

return options[0];
}

function getCapabilityLabel(capability: string): string {
switch (capability.toLowerCase()) {
case 'vision':
return 'Vision';
case 'audio':
return 'Audio';
case 'multimodal':
return 'Multimodal';
case 'completion':
return 'Text';
default:
return capability;
}
}
</script>

<div class="rounded-lg border border-border/40 bg-background/5 p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between">
<p class="text-xs font-medium text-muted-foreground">Model selector</p>

<Button
aria-label="Refresh model list"
class="h-7 w-7"
disabled={loading}
onclick={handleRefresh}
size="icon"
variant="ghost"
>
{#if loading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<RefreshCw class="h-4 w-4" />
{/if}
</Button>
</div>

{#if loading && options.length === 0 && !isMounted}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
Loading models…
</div>
{:else if options.length === 0}
<p class="text-xs text-muted-foreground">No models available.</p>
{:else}
{@const selectedOption = getDisplayOption()}

<Select.Root
type="single"
value={selectedOption?.id ?? ''}
onValueChange={handleSelect}
disabled={loading || updating}
>
<Select.Trigger class="h-9 w-full justify-between">
<span class="truncate text-sm font-medium">{selectedOption?.name || 'Select model'}</span>

{#if updating}
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
{/if}
</Select.Trigger>

<Select.Content class="z-[100000]">
{#each options as option (option.id)}
<Select.Item value={option.id} label={option.name}>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">{option.name}</span>

{#if option.description}
<span class="text-xs text-muted-foreground">{option.description}</span>
{/if}

{#if option.capabilities.length > 0}
<div class="flex flex-wrap gap-1">
{#each option.capabilities as capability (capability)}
<Badge variant="secondary" class="text-[10px]">
{getCapabilityLabel(capability)}
</Badge>
{/each}
</div>
{/if}
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

{#if selectedOption?.capabilities.length}
<div class="mt-3 flex flex-wrap gap-1">
{#each selectedOption.capabilities as capability (capability)}
<Badge variant="outline" class="text-[10px]">
{getCapabilityLabel(capability)}
</Badge>
{/each}
</div>
{/if}
{/if}

{#if error}
<p class="mt-2 text-xs text-destructive">{error}</p>
{/if}
</div>
1 change: 1 addition & 0 deletions tools/server/webui/src/lib/components/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsF
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
export { default as ChatSidebarModelSelector } from './chat/ChatSidebar/ModelSelector.svelte';

export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';

Expand Down
6 changes: 6 additions & 0 deletions tools/server/webui/src/lib/services/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { config } from '$lib/stores/settings.svelte';
import { selectedModelName } from '$lib/stores/models.svelte';
import { slotsService } from './slots';
/**
* ChatService - Low-level API communication layer for llama.cpp server interactions
Expand Down Expand Up @@ -117,6 +118,11 @@ export class ChatService {
stream
};

const activeModel = selectedModelName();
if (activeModel) {
requestBody.model = activeModel;
}

requestBody.reasoning_format = 'auto';

if (temperature !== undefined) requestBody.temperature = temperature;
Expand Down
22 changes: 22 additions & 0 deletions tools/server/webui/src/lib/services/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { base } from '$app/paths';
import { config } from '$lib/stores/settings.svelte';
import type { ApiModelListResponse } from '$lib/types/api';

export class ModelsService {
static async list(): Promise<ApiModelListResponse> {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();

const response = await fetch(`${base}/v1/models`, {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});

if (!response.ok) {
throw new Error(`Failed to fetch model list (status ${response.status})`);
}

return response.json() as Promise<ApiModelListResponse>;
}
}
9 changes: 9 additions & 0 deletions tools/server/webui/src/lib/stores/chat.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,12 +313,21 @@ class ChatStore {

let streamedReasoningContent = '';

let hasSyncedServerProps = false;

slotsService.startStreaming();

await chatService.sendMessage(allMessages, {
...this.getApiOptions(),

onChunk: (chunk: string) => {
if (!hasSyncedServerProps) {
hasSyncedServerProps = true;
void serverStore.fetchServerProps().catch((error) => {
console.warn('Failed to refresh server props after first chunk:', error);
});
}

streamedContent += chunk;
this.currentResponse = streamedContent;

Expand Down
Loading