summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/routes')
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+error.svelte70
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+layout.svelte223
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+page.svelte91
-rw-r--r--llama.cpp/tools/server/webui/src/routes/+page.ts6
-rw-r--r--llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte176
-rw-r--r--llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts6
6 files changed, 572 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/routes/+error.svelte b/llama.cpp/tools/server/webui/src/routes/+error.svelte
new file mode 100644
index 0000000..faddf0b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+error.svelte
@@ -0,0 +1,70 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { goto } from '$app/navigation';
+ import { ServerErrorSplash } from '$lib/components/app';
+
+ let error = $derived($page.error);
+ let status = $derived($page.status);
+
+ // Check if this is an API key related error
+ let isApiKeyError = $derived(
+ status === 401 ||
+ status === 403 ||
+ error?.message?.toLowerCase().includes('access denied') ||
+ error?.message?.toLowerCase().includes('unauthorized') ||
+ error?.message?.toLowerCase().includes('invalid api key')
+ );
+
+ function handleRetry() {
+ // Navigate back to home page after successful API key validation
+ goto('#/');
+ }
+</script>
+
+<svelte:head>
+ <title>Error {status} - WebUI</title>
+</svelte:head>
+
+{#if isApiKeyError}
+ <ServerErrorSplash
+ error={error?.message || 'Access denied - check server permissions'}
+ onRetry={handleRetry}
+ showRetry={false}
+ showTroubleshooting={false}
+ />
+{:else}
+ <!-- Generic error page for non-API key errors -->
+ <div class="flex h-full items-center justify-center">
+ <div class="w-full max-w-md px-4 text-center">
+ <div class="mb-6">
+ <div
+ class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10"
+ >
+ <svg
+ class="h-8 w-8 text-destructive"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
+ />
+ </svg>
+ </div>
+ <h1 class="mb-2 text-2xl font-bold">Error {status}</h1>
+ <p class="text-muted-foreground">
+ {error?.message || 'Something went wrong'}
+ </p>
+ </div>
+ <button
+ onclick={() => goto('#/')}
+ class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
+ >
+ Go Home
+ </button>
+ </div>
+ </div>
+{/if}
diff --git a/llama.cpp/tools/server/webui/src/routes/+layout.svelte b/llama.cpp/tools/server/webui/src/routes/+layout.svelte
new file mode 100644
index 0000000..095827b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+layout.svelte
@@ -0,0 +1,223 @@
+<script lang="ts">
+ import '../app.css';
+ import { base } from '$app/paths';
+ import { page } from '$app/state';
+ import { untrack } from 'svelte';
+ import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
+ import { isLoading } from '$lib/stores/chat.svelte';
+ import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
+ import * as Sidebar from '$lib/components/ui/sidebar/index.js';
+ import * as Tooltip from '$lib/components/ui/tooltip';
+ import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
+ import { config, settingsStore } from '$lib/stores/settings.svelte';
+ import { ModeWatcher } from 'mode-watcher';
+ import { Toaster } from 'svelte-sonner';
+ import { goto } from '$app/navigation';
+ import { modelsStore } from '$lib/stores/models.svelte';
+ import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
+ import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+
+ let { children } = $props();
+
+ let isChatRoute = $derived(page.route.id === '/chat/[id]');
+ let isHomeRoute = $derived(page.route.id === '/');
+ let isNewChatMode = $derived(page.url.searchParams.get('new_chat') === 'true');
+ let showSidebarByDefault = $derived(activeMessages().length > 0 || isLoading());
+ let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
+ let autoShowSidebarOnNewChat = $derived(config().autoShowSidebarOnNewChat);
+ let isMobile = new IsMobile();
+ let isDesktop = $derived(!isMobile.current);
+ let sidebarOpen = $state(false);
+ let innerHeight = $state<number | undefined>();
+ let chatSidebar:
+ | { activateSearchMode?: () => void; editActiveConversation?: () => void }
+ | undefined = $state();
+
+ // Conversation title update dialog state
+ let titleUpdateDialogOpen = $state(false);
+ let titleUpdateCurrentTitle = $state('');
+ let titleUpdateNewTitle = $state('');
+ let titleUpdateResolve: ((value: boolean) => void) | null = null;
+
+ // Global keyboard shortcuts
+ function handleKeydown(event: KeyboardEvent) {
+ const isCtrlOrCmd = event.ctrlKey || event.metaKey;
+
+ if (isCtrlOrCmd && event.key === 'k') {
+ event.preventDefault();
+ if (chatSidebar?.activateSearchMode) {
+ chatSidebar.activateSearchMode();
+ sidebarOpen = true;
+ }
+ }
+
+ if (isCtrlOrCmd && event.shiftKey && event.key === 'O') {
+ event.preventDefault();
+ goto('?new_chat=true#/');
+ }
+
+ if (event.shiftKey && isCtrlOrCmd && event.key === 'E') {
+ event.preventDefault();
+
+ if (chatSidebar?.editActiveConversation) {
+ chatSidebar.editActiveConversation();
+ }
+ }
+ }
+
+ function handleTitleUpdateCancel() {
+ titleUpdateDialogOpen = false;
+ if (titleUpdateResolve) {
+ titleUpdateResolve(false);
+ titleUpdateResolve = null;
+ }
+ }
+
+ function handleTitleUpdateConfirm() {
+ titleUpdateDialogOpen = false;
+ if (titleUpdateResolve) {
+ titleUpdateResolve(true);
+ titleUpdateResolve = null;
+ }
+ }
+
+ $effect(() => {
+ if (alwaysShowSidebarOnDesktop && isDesktop) {
+ sidebarOpen = true;
+ return;
+ }
+
+ if (isHomeRoute && !isNewChatMode) {
+ // Auto-collapse sidebar when navigating to home route (but not in new chat mode)
+ sidebarOpen = false;
+ } else if (isHomeRoute && isNewChatMode) {
+ // Keep sidebar open in new chat mode
+ sidebarOpen = true;
+ } else if (isChatRoute) {
+ // On chat routes, only auto-show sidebar if setting is enabled
+ if (autoShowSidebarOnNewChat) {
+ sidebarOpen = true;
+ }
+ // If setting is disabled, don't change sidebar state - let user control it manually
+ } else {
+ // Other routes follow default behavior
+ sidebarOpen = showSidebarByDefault;
+ }
+ });
+
+ // Initialize server properties on app load (run once)
+ $effect(() => {
+ // Only fetch if we don't already have props
+ if (!serverStore.props) {
+ untrack(() => {
+ serverStore.fetch();
+ });
+ }
+ });
+
+ // Sync settings when server props are loaded
+ $effect(() => {
+ const serverProps = serverStore.props;
+
+ if (serverProps) {
+ settingsStore.syncWithServerDefaults();
+ }
+ });
+
+ // Fetch router models when in router mode (for status and modalities)
+ // Wait for models to be loaded first, run only once
+ let routerModelsFetched = false;
+
+ $effect(() => {
+ const isRouter = isRouterMode();
+ const modelsCount = modelsStore.models.length;
+
+ // Only fetch router models once when we have models loaded and in router mode
+ if (isRouter && modelsCount > 0 && !routerModelsFetched) {
+ routerModelsFetched = true;
+ untrack(() => {
+ modelsStore.fetchRouterModels();
+ });
+ }
+ });
+
+ // Monitor API key changes and redirect to error page if removed or changed when required
+ $effect(() => {
+ const apiKey = config().apiKey;
+
+ if (
+ (page.route.id === '/' || page.route.id === '/chat/[id]') &&
+ page.status !== 401 &&
+ page.status !== 403
+ ) {
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json'
+ };
+
+ if (apiKey && apiKey.trim() !== '') {
+ headers.Authorization = `Bearer ${apiKey.trim()}`;
+ }
+
+ fetch(`${base}/props`, { headers })
+ .then((response) => {
+ if (response.status === 401 || response.status === 403) {
+ window.location.reload();
+ }
+ })
+ .catch((e) => {
+ console.error('Error checking API key:', e);
+ });
+ }
+ });
+
+ // Set up title update confirmation callback
+ $effect(() => {
+ conversationsStore.setTitleUpdateConfirmationCallback(
+ async (currentTitle: string, newTitle: string) => {
+ return new Promise<boolean>((resolve) => {
+ titleUpdateCurrentTitle = currentTitle;
+ titleUpdateNewTitle = newTitle;
+ titleUpdateResolve = resolve;
+ titleUpdateDialogOpen = true;
+ });
+ }
+ );
+ });
+</script>
+
+<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
+ <ModeWatcher />
+
+ <Toaster richColors />
+
+ <DialogConversationTitleUpdate
+ bind:open={titleUpdateDialogOpen}
+ currentTitle={titleUpdateCurrentTitle}
+ newTitle={titleUpdateNewTitle}
+ onConfirm={handleTitleUpdateConfirm}
+ onCancel={handleTitleUpdateCancel}
+ />
+
+ <Sidebar.Provider bind:open={sidebarOpen}>
+ <div class="flex h-screen w-full" style:height="{innerHeight}px">
+ <Sidebar.Root class="h-full">
+ <ChatSidebar bind:this={chatSidebar} />
+ </Sidebar.Root>
+
+ {#if !(alwaysShowSidebarOnDesktop && isDesktop)}
+ <Sidebar.Trigger
+ class="transition-left absolute left-0 z-[900] h-8 w-8 duration-200 ease-linear {sidebarOpen
+ ? 'md:left-[var(--sidebar-width)]'
+ : ''}"
+ style="translate: 1rem 1rem;"
+ />
+ {/if}
+
+ <Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
+ {@render children?.()}
+ </Sidebar.Inset>
+ </div>
+ </Sidebar.Provider>
+</Tooltip.Provider>
+
+<svelte:window onkeydown={handleKeydown} bind:innerHeight />
diff --git a/llama.cpp/tools/server/webui/src/routes/+page.svelte b/llama.cpp/tools/server/webui/src/routes/+page.svelte
new file mode 100644
index 0000000..32a7c2e
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+page.svelte
@@ -0,0 +1,91 @@
+<script lang="ts">
+ import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
+ import { chatStore } from '$lib/stores/chat.svelte';
+ import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
+ import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
+ import { onMount } from 'svelte';
+ import { page } from '$app/state';
+ import { replaceState } from '$app/navigation';
+
+ let qParam = $derived(page.url.searchParams.get('q'));
+ let modelParam = $derived(page.url.searchParams.get('model'));
+ let newChatParam = $derived(page.url.searchParams.get('new_chat'));
+
+ // Dialog state for model not available error
+ let showModelNotAvailable = $state(false);
+ let requestedModelName = $state('');
+ let availableModelNames = $derived(modelOptions().map((m) => m.model));
+
+ /**
+ * Clear URL params after message is sent to prevent re-sending on refresh
+ */
+ function clearUrlParams() {
+ const url = new URL(page.url);
+
+ url.searchParams.delete('q');
+ url.searchParams.delete('model');
+ url.searchParams.delete('new_chat');
+
+ replaceState(url.toString(), {});
+ }
+
+ async function handleUrlParams() {
+ await modelsStore.fetch();
+
+ if (modelParam) {
+ const model = modelsStore.findModelByName(modelParam);
+
+ if (model) {
+ try {
+ await modelsStore.selectModelById(model.id);
+ } catch (error) {
+ console.error('Failed to select model:', error);
+ requestedModelName = modelParam;
+ showModelNotAvailable = true;
+
+ return;
+ }
+ } else {
+ requestedModelName = modelParam;
+ showModelNotAvailable = true;
+
+ return;
+ }
+ }
+
+ // Handle ?q= parameter - create new conversation and send message
+ if (qParam !== null) {
+ await conversationsStore.createConversation();
+ await chatStore.sendMessage(qParam);
+ clearUrlParams();
+ } else if (modelParam || newChatParam === 'true') {
+ clearUrlParams();
+ }
+ }
+
+ onMount(async () => {
+ if (!isConversationsInitialized()) {
+ await conversationsStore.initialize();
+ }
+
+ conversationsStore.clearActiveConversation();
+ chatStore.clearUIState();
+
+ // Handle URL params only if we have ?q= or ?model= or ?new_chat=true
+ if (qParam !== null || modelParam !== null || newChatParam === 'true') {
+ await handleUrlParams();
+ }
+ });
+</script>
+
+<svelte:head>
+ <title>llama.cpp - AI Chat Interface</title>
+</svelte:head>
+
+<ChatScreen showCenteredEmpty={true} />
+
+<DialogModelNotAvailable
+ bind:open={showModelNotAvailable}
+ modelName={requestedModelName}
+ availableModels={availableModelNames}
+/>
diff --git a/llama.cpp/tools/server/webui/src/routes/+page.ts b/llama.cpp/tools/server/webui/src/routes/+page.ts
new file mode 100644
index 0000000..7905af6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/+page.ts
@@ -0,0 +1,6 @@
+import type { PageLoad } from './$types';
+import { validateApiKey } from '$lib/utils';
+
+export const load: PageLoad = async ({ fetch }) => {
+ await validateApiKey(fetch);
+};
diff --git a/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte
new file mode 100644
index 0000000..b897ef5
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import { goto, replaceState } from '$app/navigation';
+ import { page } from '$app/state';
+ import { afterNavigate } from '$app/navigation';
+ import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
+ import { chatStore, isLoading } from '$lib/stores/chat.svelte';
+ import {
+ conversationsStore,
+ activeConversation,
+ activeMessages
+ } from '$lib/stores/conversations.svelte';
+ import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
+
+ let chatId = $derived(page.params.id);
+ let currentChatId: string | undefined = undefined;
+
+ // URL parameters for prompt and model selection
+ let qParam = $derived(page.url.searchParams.get('q'));
+ let modelParam = $derived(page.url.searchParams.get('model'));
+
+ // Dialog state for model not available error
+ let showModelNotAvailable = $state(false);
+ let requestedModelName = $state('');
+ let availableModelNames = $derived(modelOptions().map((m) => m.model));
+
+ // Track if URL params have been processed for this chat
+ let urlParamsProcessed = $state(false);
+
+ /**
+ * Clear URL params after message is sent to prevent re-sending on refresh
+ */
+ function clearUrlParams() {
+ const url = new URL(page.url);
+ url.searchParams.delete('q');
+ url.searchParams.delete('model');
+ replaceState(url.toString(), {});
+ }
+
+ async function handleUrlParams() {
+ // Ensure models are loaded first
+ await modelsStore.fetch();
+
+ // Handle model parameter - select model if provided
+ if (modelParam) {
+ const model = modelsStore.findModelByName(modelParam);
+ if (model) {
+ try {
+ await modelsStore.selectModelById(model.id);
+ } catch (error) {
+ console.error('Failed to select model:', error);
+ requestedModelName = modelParam;
+ showModelNotAvailable = true;
+ return;
+ }
+ } else {
+ // Model not found - show error dialog
+ requestedModelName = modelParam;
+ showModelNotAvailable = true;
+ return;
+ }
+ }
+
+ // Handle ?q= parameter - send message in current conversation
+ if (qParam !== null) {
+ await chatStore.sendMessage(qParam);
+ // Clear URL params after message is sent
+ clearUrlParams();
+ } else if (modelParam) {
+ // Clear params even if no message was sent (just model selection)
+ clearUrlParams();
+ }
+
+ urlParamsProcessed = true;
+ }
+
+ async function selectModelFromLastAssistantResponse() {
+ const messages = activeMessages();
+ if (messages.length === 0) return;
+
+ let lastMessageWithModel: DatabaseMessage | undefined;
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].model) {
+ lastMessageWithModel = messages[i];
+ break;
+ }
+ }
+
+ if (!lastMessageWithModel) return;
+
+ const currentModelId = selectedModelId();
+ const currentModelName = modelOptions().find((m) => m.id === currentModelId)?.model;
+
+ if (currentModelName === lastMessageWithModel.model) {
+ return;
+ }
+
+ const matchingModel = modelOptions().find(
+ (option) => option.model === lastMessageWithModel.model
+ );
+
+ if (matchingModel) {
+ try {
+ await modelsStore.selectModelById(matchingModel.id);
+ console.log(`Automatically loaded model: ${lastMessageWithModel.model} from last message`);
+ } catch (error) {
+ console.warn('Failed to automatically select model from last message:', error);
+ }
+ }
+ }
+
+ afterNavigate(() => {
+ setTimeout(() => {
+ selectModelFromLastAssistantResponse();
+ }, 100);
+ });
+
+ $effect(() => {
+ if (chatId && chatId !== currentChatId) {
+ currentChatId = chatId;
+ urlParamsProcessed = false; // Reset for new chat
+
+ // Skip loading if this conversation is already active (e.g., just created)
+ if (activeConversation()?.id === chatId) {
+ // Still handle URL params even if conversation is active
+ if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
+ handleUrlParams();
+ }
+ return;
+ }
+
+ (async () => {
+ const success = await conversationsStore.loadConversation(chatId);
+ if (success) {
+ chatStore.syncLoadingStateForChat(chatId);
+
+ // Handle URL params after conversation is loaded
+ if ((qParam !== null || modelParam !== null) && !urlParamsProcessed) {
+ await handleUrlParams();
+ }
+ } else {
+ await goto('#/');
+ }
+ })();
+ }
+ });
+
+ $effect(() => {
+ if (typeof window !== 'undefined') {
+ const handleBeforeUnload = () => {
+ if (isLoading()) {
+ console.log('Page unload detected while streaming - aborting stream');
+ chatStore.stopGeneration();
+ }
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload);
+ };
+ }
+ });
+</script>
+
+<svelte:head>
+ <title>{activeConversation()?.name || 'Chat'} - llama.cpp</title>
+</svelte:head>
+
+<ChatScreen />
+
+<DialogModelNotAvailable
+ bind:open={showModelNotAvailable}
+ modelName={requestedModelName}
+ availableModels={availableModelNames}
+/>
diff --git a/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts
new file mode 100644
index 0000000..7905af6
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/routes/chat/[id]/+page.ts
@@ -0,0 +1,6 @@
+import type { PageLoad } from './$types';
+import { validateApiKey } from '$lib/utils';
+
+export const load: PageLoad = async ({ fetch }) => {
+ await validateApiKey(fetch);
+};