Quellcode durchsuchen

webui: switch to hash-based routing (alternative of #16079) (#16157)

* Switched web UI to hash-based routing

* Added hash to missed goto function call

* Removed outdated SPA handling code

* Fixed broken sidebar home link
Isaac McFadyen vor 3 Monaten
Ursprung
Commit
e0539eb6ae

+ 0 - 36
tools/server/server.cpp

@@ -5262,42 +5262,6 @@ int main(int argc, char ** argv) {
     svr->Get (params.api_prefix + "/slots",               handle_slots);
     svr->Get (params.api_prefix + "/slots",               handle_slots);
     svr->Post(params.api_prefix + "/slots/:id_slot",      handle_slots_action);
     svr->Post(params.api_prefix + "/slots/:id_slot",      handle_slots_action);
 
 
-    // SPA fallback route - serve index.html for any route that doesn't match API endpoints
-    // This enables client-side routing for dynamic routes like /chat/[id]
-    if (params.webui && params.public_path.empty()) {
-        // Only add fallback when using embedded static files
-        svr->Get(".*", [](const httplib::Request & req, httplib::Response & res) {
-            // Skip API routes - they should have been handled above
-            if (req.path.find("/v1/") != std::string::npos ||
-                req.path.find("/health") != std::string::npos ||
-                req.path.find("/metrics") != std::string::npos ||
-                req.path.find("/props") != std::string::npos ||
-                req.path.find("/models") != std::string::npos ||
-                req.path.find("/api/tags") != std::string::npos ||
-                req.path.find("/completions") != std::string::npos ||
-                req.path.find("/chat/completions") != std::string::npos ||
-                req.path.find("/embeddings") != std::string::npos ||
-                req.path.find("/tokenize") != std::string::npos ||
-                req.path.find("/detokenize") != std::string::npos ||
-                req.path.find("/lora-adapters") != std::string::npos ||
-                req.path.find("/slots") != std::string::npos) {
-                return false; // Let other handlers process API routes
-            }
-
-            // Serve index.html for all other routes (SPA fallback)
-            if (req.get_header_value("Accept-Encoding").find("gzip") == std::string::npos) {
-                res.set_content("Error: gzip is not supported by this browser", "text/plain");
-            } else {
-                res.set_header("Content-Encoding", "gzip");
-                // COEP and COOP headers, required by pyodide (python interpreter)
-                res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
-                res.set_header("Cross-Origin-Opener-Policy", "same-origin");
-                res.set_content(reinterpret_cast<const char*>(index_html_gz), index_html_gz_len, "text/html; charset=utf-8");
-            }
-            return false;
-        });
-    }
-
     //
     //
     // Start the server
     // Start the server
     //
     //

+ 2 - 2
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte

@@ -64,13 +64,13 @@
 			searchQuery = '';
 			searchQuery = '';
 		}
 		}
 
 
-		await goto(`/chat/${id}`);
+		await goto(`#/chat/${id}`);
 	}
 	}
 </script>
 </script>
 
 
 <ScrollArea class="h-[100vh]">
 <ScrollArea class="h-[100vh]">
 	<Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 pt-4 pb-2 backdrop-blur-lg md:sticky">
 	<Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 pt-4 pb-2 backdrop-blur-lg md:sticky">
-		<a href="/" onclick={handleMobileSidebarItemClick}>
+		<a href="#/" onclick={handleMobileSidebarItemClick}>
 			<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
 			<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
 		</a>
 		</a>
 
 

+ 1 - 1
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte

@@ -51,7 +51,7 @@
 	{:else}
 	{:else}
 		<Button
 		<Button
 			class="w-full justify-between hover:[&>kbd]:opacity-100"
 			class="w-full justify-between hover:[&>kbd]:opacity-100"
-			href="/?new_chat=true"
+			href="?new_chat=true#/"
 			onclick={handleMobileSidebarItemClick}
 			onclick={handleMobileSidebarItemClick}
 			variant="ghost"
 			variant="ghost"
 		>
 		>

+ 2 - 2
tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte

@@ -64,7 +64,7 @@
 			updateConfig('apiKey', apiKeyInput.trim());
 			updateConfig('apiKey', apiKeyInput.trim());
 
 
 			// Test the API key by making a real request to the server
 			// Test the API key by making a real request to the server
-			const response = await fetch('/props', {
+			const response = await fetch('./props', {
 				headers: {
 				headers: {
 					'Content-Type': 'application/json',
 					'Content-Type': 'application/json',
 					Authorization: `Bearer ${apiKeyInput.trim()}`
 					Authorization: `Bearer ${apiKeyInput.trim()}`
@@ -77,7 +77,7 @@
 
 
 				// Show success state briefly, then navigate to home
 				// Show success state briefly, then navigate to home
 				setTimeout(() => {
 				setTimeout(() => {
-					goto('/');
+					goto(`#/`);
 				}, 1000);
 				}, 1000);
 			} else {
 			} else {
 				// API key is invalid - User Story A
 				// API key is invalid - User Story A

+ 2 - 2
tools/server/webui/src/lib/services/chat.ts

@@ -164,7 +164,7 @@ export class ChatService {
 			const currentConfig = config();
 			const currentConfig = config();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 
 
-			const response = await fetch(`/v1/chat/completions`, {
+			const response = await fetch(`./v1/chat/completions`, {
 				method: 'POST',
 				method: 'POST',
 				headers: {
 				headers: {
 					'Content-Type': 'application/json',
 					'Content-Type': 'application/json',
@@ -533,7 +533,7 @@ export class ChatService {
 			const currentConfig = config();
 			const currentConfig = config();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 
 
-			const response = await fetch(`/props`, {
+			const response = await fetch(`./props`, {
 				headers: {
 				headers: {
 					'Content-Type': 'application/json',
 					'Content-Type': 'application/json',
 					...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
 					...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})

+ 1 - 1
tools/server/webui/src/lib/services/slots.ts

@@ -138,7 +138,7 @@ export class SlotsService {
 			const currentConfig = config();
 			const currentConfig = config();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 
 
-			const response = await fetch('/slots', {
+			const response = await fetch(`./slots`, {
 				headers: {
 				headers: {
 					...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
 					...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
 				}
 				}

+ 2 - 2
tools/server/webui/src/lib/stores/chat.svelte.ts

@@ -100,7 +100,7 @@ class ChatStore {
 
 
 		this.maxContextError = null;
 		this.maxContextError = null;
 
 
-		await goto(`/chat/${conversation.id}`);
+		await goto(`#/chat/${conversation.id}`);
 
 
 		return conversation.id;
 		return conversation.id;
 	}
 	}
@@ -910,7 +910,7 @@ class ChatStore {
 			if (this.activeConversation?.id === convId) {
 			if (this.activeConversation?.id === convId) {
 				this.activeConversation = null;
 				this.activeConversation = null;
 				this.activeMessages = [];
 				this.activeMessages = [];
-				await goto('/?new_chat=true');
+				await goto(`?new_chat=true#/`);
 			}
 			}
 		} catch (error) {
 		} catch (error) {
 			console.error('Failed to delete conversation:', error);
 			console.error('Failed to delete conversation:', error);

+ 1 - 1
tools/server/webui/src/lib/stores/server.svelte.ts

@@ -98,7 +98,7 @@ class ServerStore {
 			const currentConfig = config();
 			const currentConfig = config();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 			const apiKey = currentConfig.apiKey?.toString().trim();
 
 
-			const response = await fetch('/slots', {
+			const response = await fetch(`./slots`, {
 				headers: {
 				headers: {
 					...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
 					...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
 				}
 				}

+ 1 - 1
tools/server/webui/src/lib/utils/api-key-validation.ts

@@ -22,7 +22,7 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
 			headers.Authorization = `Bearer ${apiKey}`;
 			headers.Authorization = `Bearer ${apiKey}`;
 		}
 		}
 
 
-		const response = await fetch('/props', { headers });
+		const response = await fetch(`./props`, { headers });
 
 
 		if (!response.ok) {
 		if (!response.ok) {
 			if (response.status === 401 || response.status === 403) {
 			if (response.status === 401 || response.status === 403) {

+ 2 - 2
tools/server/webui/src/routes/+error.svelte

@@ -17,7 +17,7 @@
 
 
 	function handleRetry() {
 	function handleRetry() {
 		// Navigate back to home page after successful API key validation
 		// Navigate back to home page after successful API key validation
-		goto('/');
+		goto('#/');
 	}
 	}
 </script>
 </script>
 
 
@@ -60,7 +60,7 @@
 				</p>
 				</p>
 			</div>
 			</div>
 			<button
 			<button
-				onclick={() => goto('/')}
+				onclick={() => goto('#/')}
 				class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
 				class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
 			>
 			>
 				Go Home
 				Go Home

+ 2 - 2
tools/server/webui/src/routes/+layout.svelte

@@ -49,7 +49,7 @@
 
 
 		if (isCtrlOrCmd && event.shiftKey && event.key === 'o') {
 		if (isCtrlOrCmd && event.shiftKey && event.key === 'o') {
 			event.preventDefault();
 			event.preventDefault();
-			goto('/?new_chat=true');
+			goto('?new_chat=true#/');
 		}
 		}
 
 
 		if (event.shiftKey && isCtrlOrCmd && event.key === 'e') {
 		if (event.shiftKey && isCtrlOrCmd && event.key === 'e') {
@@ -115,7 +115,7 @@
 				headers.Authorization = `Bearer ${apiKey.trim()}`;
 				headers.Authorization = `Bearer ${apiKey.trim()}`;
 			}
 			}
 
 
-			fetch('/props', { headers })
+			fetch(`./props`, { headers })
 				.then((response) => {
 				.then((response) => {
 					if (response.status === 401 || response.status === 403) {
 					if (response.status === 401 || response.status === 403) {
 						window.location.reload();
 						window.location.reload();

+ 0 - 3
tools/server/webui/src/routes/+layout.ts

@@ -1,3 +0,0 @@
-export const csr = true;
-export const prerender = false;
-export const ssr = false;

+ 2 - 2
tools/server/webui/src/routes/chat/[id]/+page.svelte

@@ -26,7 +26,7 @@
 			await gracefulStop();
 			await gracefulStop();
 
 
 			if (to?.url) {
 			if (to?.url) {
-				await goto(to.url.pathname + to.url.search);
+				await goto(to.url.pathname + to.url.search + to.url.hash);
 			}
 			}
 		}
 		}
 	});
 	});
@@ -44,7 +44,7 @@
 				const success = await chatStore.loadConversation(chatId);
 				const success = await chatStore.loadConversation(chatId);
 
 
 				if (!success) {
 				if (!success) {
-					await goto('/');
+					await goto('#/');
 				}
 				}
 			})();
 			})();
 		}
 		}

+ 4 - 0
tools/server/webui/svelte.config.js

@@ -8,6 +8,10 @@ const config = {
 	// for more information about preprocessors
 	// for more information about preprocessors
 	preprocess: [vitePreprocess(), mdsvex()],
 	preprocess: [vitePreprocess(), mdsvex()],
 	kit: {
 	kit: {
+		paths: {
+			relative: true
+		},
+		router: { type: 'hash' },
 		adapter: adapter({
 		adapter: adapter({
 			pages: '../public',
 			pages: '../public',
 			assets: '../public',
 			assets: '../public',