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 @@ -13,6 +13,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 @@ -110,6 +111,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,99 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Loader2 } from '@lucide/svelte';
import * as Select from '$lib/components/ui/select';
import {
fetchModels,
modelOptions,
modelsError,
modelsLoading,
modelsUpdating,
selectModel,
selectedModelId
} from '$lib/stores/models.svelte';
import type { ModelOption } from '$lib/stores/models.svelte';

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) {
console.error('Unable to load models:', error);
} finally {
isMounted = true;
}
});

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

const option = options.find((item) => item.id === value);
if (!option) {
console.error('Model is no longer available');
return;
}

try {
await selectModel(option.id);
} catch (error) {
console.error('Failed to switch model:', error);
}
}

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

return options[0];
}
</script>

{#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}>
<span class="text-sm font-medium">{option.name}</span>

{#if option.description}
<span class="text-xs text-muted-foreground">{option.description}</span>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}

{#if error}
<p class="mt-2 text-xs text-destructive">{error}</p>
{/if}
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 ChatErrorDialog } from './dialogs/ChatErrorDialog.svelte';
export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
Expand Down
97 changes: 91 additions & 6 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 @@ -50,6 +51,8 @@ export class ChatService {
onChunk,
onComplete,
onError,
onReasoningChunk,
onModel,
// Generation parameters
temperature,
max_tokens,
Expand Down Expand Up @@ -118,6 +121,11 @@ export class ChatService {
stream
};

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

requestBody.reasoning_format = currentConfig.disableReasoningFormat ? 'none' : 'auto';

if (temperature !== undefined) requestBody.temperature = temperature;
Expand Down Expand Up @@ -190,10 +198,11 @@ export class ChatService {
onChunk,
onComplete,
onError,
options.onReasoningChunk
onReasoningChunk,
onModel
);
} else {
return this.handleNonStreamResponse(response, onComplete, onError);
return this.handleNonStreamResponse(response, onComplete, onError, onModel);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
Expand Down Expand Up @@ -251,7 +260,8 @@ export class ChatService {
timings?: ChatMessageTimings
) => void,
onError?: (error: Error) => void,
onReasoningChunk?: (chunk: string) => void
onReasoningChunk?: (chunk: string) => void,
onModel?: (model: string) => void
): Promise<void> {
const reader = response.body?.getReader();

Expand All @@ -265,6 +275,7 @@ export class ChatService {
let hasReceivedData = false;
let lastTimings: ChatMessageTimings | undefined;
let streamFinished = false;
let modelEmitted = false;

try {
let chunk = '';
Expand All @@ -274,7 +285,7 @@ export class ChatService {

chunk += decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
chunk = lines.pop() || ''; // Save incomplete line for next read
chunk = lines.pop() || '';

for (const line of lines) {
if (line.startsWith('data: ')) {
Expand All @@ -287,6 +298,12 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);

const chunkModel = this.extractModelName(parsed);
if (chunkModel && !modelEmitted) {
modelEmitted = true;
onModel?.(chunkModel);
}

const content = parsed.choices[0]?.delta?.content;
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
const timings = parsed.timings;
Expand All @@ -295,7 +312,6 @@ export class ChatService {
if (timings || promptProgress) {
this.updateProcessingState(timings, promptProgress);

// Store the latest timing data
if (timings) {
lastTimings = timings;
}
Expand Down Expand Up @@ -355,7 +371,8 @@ export class ChatService {
reasoningContent?: string,
timings?: ChatMessageTimings
) => void,
onError?: (error: Error) => void
onError?: (error: Error) => void,
onModel?: (model: string) => void
): Promise<string> {
try {
const responseText = await response.text();
Expand All @@ -366,6 +383,11 @@ export class ChatService {
}

const data: ApiChatCompletionResponse = JSON.parse(responseText);
const responseModel = this.extractModelName(data);
if (responseModel) {
onModel?.(responseModel);
}

const content = data.choices[0]?.message?.content || '';
const reasoningContent = data.choices[0]?.message?.reasoning_content;

Expand Down Expand Up @@ -588,6 +610,69 @@ export class ChatService {
}
}

private extractModelName(data: unknown): string | undefined {
if (!data || typeof data !== 'object') {
return undefined;
}

const record = data as Record<string, unknown>;
const normalize = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}

const trimmed = value.trim();

return trimmed.length > 0 ? trimmed : undefined;
};

const rootModel = normalize(record['model']);
if (rootModel) {
return rootModel;
}

const choices = record['choices'];
if (!Array.isArray(choices) || choices.length === 0) {
return undefined;
}

const firstChoice = choices[0] as Record<string, unknown> | undefined;
if (!firstChoice) {
return undefined;
}

const choiceModel = normalize(firstChoice['model']);
if (choiceModel) {
return choiceModel;
}

const delta = firstChoice['delta'] as Record<string, unknown> | undefined;
if (delta) {
const deltaModel = normalize(delta['model']);
if (deltaModel) {
return deltaModel;
}
}

const message = firstChoice['message'] as Record<string, unknown> | undefined;
if (message) {
const messageModel = normalize(message['model']);
if (messageModel) {
return messageModel;
}
}

const metadata = firstChoice['metadata'] as Record<string, unknown> | undefined;
if (metadata) {
const metadataModel = normalize(metadata['model']);
if (metadataModel) {
return metadataModel;
}
}

return undefined;
}

private updateProcessingState(
timings?: ChatMessageTimings,
promptProgress?: ChatMessagePromptProgress
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>;
}
}
Loading