diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/routes')
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); +}; |
