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
This commit is contained in:
Isaac McFadyen 2025-09-26 11:36:48 -04:00 committed by GitHub
parent 5d0a40f390
commit e0539eb6ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 22 additions and 57 deletions

View File

@ -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
// //

View File

@ -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>

View File

@ -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"
> >

View File

@ -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

View File

@ -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}` } : {})

View File

@ -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}` } : {})
} }

View File

@ -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);

View File

@ -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}` } : {})
} }

View File

@ -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) {

View File

@ -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

View File

@ -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();

View File

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

View File

@ -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('#/');
} }
})(); })();
} }

View File

@ -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',