hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/index.html --yes 2>/dev/null || true
hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/bundle.js --yes 2>/dev/null || true
hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/bundle.css --yes 2>/dev/null || true
- hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/loading.html --yes 2>/dev/null || true
// Middlewares
//
+ // Frontend paths - all embedded UI assets
+ static const std::unordered_set<std::string> frontend_paths = []() {
+ std::unordered_set<std::string> paths { "/" };
+ for (const llama_ui_asset & a : llama_ui_get_assets()) {
+ paths.insert("/" + a.name);
+ }
+ return paths;
+ }();
+
// Public endpoints - API routes plus all embedded UI assets
static const std::unordered_set<std::string> get_public_endpoints = []() {
std::unordered_set<std::string> endpoints {
"/v1/health",
"/models",
"/v1/models",
- "/",
};
- for (const llama_ui_asset & a : llama_ui_get_assets()) {
- endpoints.insert("/" + a.name);
- }
+ endpoints.insert(frontend_paths.begin(), frontend_paths.end());
return endpoints;
}();
auto middleware_server_state = [this](const httplib::Request & req, httplib::Response & res) {
if (!is_ready.load()) {
-#if defined(LLAMA_UI_HAS_ASSETS)
- if (const auto tmp = string_split<std::string>(req.path, '.');
- req.path == "/" || (!tmp.empty() && tmp.back() == "html")) {
- if (const llama_ui_asset * a = llama_ui_find_asset("loading.html")) {
- res.status = 503;
- res.set_content(reinterpret_cast<const char*>(a->data), a->size, "text/html; charset=utf-8");
- return false;
- }
+ if (frontend_paths.count(req.path)) {
+ return true; // frontend asset, allow it to load and show "loading"
}
-#else
- (void)req;
-#endif
// no endpoints are allowed to be accessed when the server is not ready
// this is to prevent any data races or inconsistent states
res.status = 503;
struct required_check { const char * label; match_fn match; bool found; };
required_check checks[] = {
{ "index.html", exact("index.html"), false },
- { "loading.html", exact("loading.html"), false },
{ "manifest.webmanifest", exact("manifest.webmanifest"), false },
{ "sw.js", exact("sw.js"), false },
{ "build.json", exact("build.json"), false },
<script lang="ts">
- import { AlertTriangle, RefreshCw } from '@lucide/svelte';
+ import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import * as Alert from '$lib/components/ui/alert';
- import { serverError, serverLoading, serverStore } from '$lib/stores/server.svelte';
+ import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
let hasError = $derived(!!serverError());
+ let isLoadingModel = $derived(serverStatus() === 503);
</script>
{#if hasError}
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
use:fadeInView={{ y: 10, duration: 250 }}
>
- <Alert.Root variant="destructive">
- <AlertTriangle class="h-4 w-4" />
+ <Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
+ {#if isLoadingModel}
+ <Loader2 class="h-4 w-4 animate-spin" />
+ {:else}
+ <AlertTriangle class="h-4 w-4" />
+ {/if}
<Alert.Title class="flex items-center justify-between">
- <span>Server unavailable</span>
+ <span>{isLoadingModel ? 'Loading model' : 'Server unavailable'}</span>
- <button
- onclick={() => serverStore.fetch()}
- disabled={serverLoading()}
- class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
- >
- <RefreshCw class="h-3 w-3 {serverLoading() ? 'animate-spin' : ''}" />
- {serverLoading() ? 'Retrying...' : 'Retry'}
- </button>
+ {#if !isLoadingModel}
+ <button
+ onclick={() => serverStore.fetch()}
+ disabled={serverLoading()}
+ class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
+ >
+ <RefreshCw class="h-3 w-3 {serverLoading() ? 'animate-spin' : ''}" />
+ {serverLoading() ? 'Retrying...' : 'Retry'}
+ </button>
+ {/if}
</Alert.Title>
- <Alert.Description>{serverError()}</Alert.Description>
+ {#if !isLoadingModel}
+ <Alert.Description>{serverError()}</Alert.Description>
+ {/if}
</Alert.Root>
</div>
{/if}
'**/*.{js,css,html,ico,svg,png,webp,woff,woff2,json,webmanifest}'
];
-// loading.html is the model loading page served by llama-server itself.
-// The SvelteKit PWA manifest transform strips the html extension from every
-// precache entry to match clean URLs, but loading.html is a plain static asset
-// with no clean URL, so static servers answer 404 and the SW install fails.
-export const GLOB_IGNORES: string[] = ['**/loading.html'];
-
export const SW_CONFIG = {
CHECK_INTERVAL_MS: 60000,
UPDATE_FETCH_OPTIONS: {
// Uses '**/' because SvelteKit outputs files under _app/immutable/
// subdirectories.
globPatterns: GLOB_PATTERNS,
- globIgnores: GLOB_IGNORES,
maximumFileSizeToCacheInBytes: CACHE_SETTINGS.MAX_FILE_SIZE_BYTES,
// Prevent @vite-pwa/sveltekit from auto-adding a NavigationRoute by
import { PropsService } from '$lib/services/props.service';
import { ServerRole } from '$lib/enums';
+import { ApiError } from '$lib/utils/api-fetch';
+
+const LOADING_RETRY_INTERVAL_MS = 1000;
/**
* serverStore - Server connection state, configuration, and role detection
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
error = $state<string | null>(null);
+ status = $state<number | null>(null);
role = $state<ServerRole | null>(null);
private fetchPromise: Promise<void> | null = null;
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
/**
*
*
*/
- async fetch(): Promise<void> {
+ /**
+ * @param background - Set by the automatic "still loading" poll. Skips the
+ * `loading` flag flip so the UI doesn't bounce between the full loading
+ * splash and the chat screen every retry tick.
+ */
+ async fetch({ background = false }: { background?: boolean } = {}): Promise<void> {
if (this.fetchPromise) return this.fetchPromise;
- this.loading = true;
- this.error = null;
+ this.clearRetryTimer();
+ if (!background) {
+ this.loading = true;
+ }
+ // Don't clear an existing "still loading" error before a retry -
+ // doing so would unmount/remount the error banner every second.
+ if (this.status !== 503) {
+ this.error = null;
+ }
const fetchPromise = (async () => {
try {
const props = await PropsService.fetch();
this.props = props;
this.error = null;
+ this.status = null;
this.detectRole(props);
} catch (error: unknown) {
this.error = error instanceof Error ? error.message : String(error);
+ this.status = error instanceof ApiError ? error.status : null;
console.error('Error fetching server properties:', error);
+
+ if (this.status === 503) {
+ this.scheduleRetry();
+ }
} finally {
- this.loading = false;
+ if (!background) {
+ this.loading = false;
+ }
this.fetchPromise = null;
}
})();
}
clear(): void {
+ this.clearRetryTimer();
this.props = null;
this.error = null;
+ this.status = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
}
+ private scheduleRetry(): void {
+ if (this.retryTimer) return;
+ this.retryTimer = setTimeout(() => {
+ this.retryTimer = null;
+ this.fetch({ background: true });
+ }, LOADING_RETRY_INTERVAL_MS);
+ }
+
+ private clearRetryTimer(): void {
+ if (this.retryTimer) {
+ clearTimeout(this.retryTimer);
+ this.retryTimer = null;
+ }
+ }
+
/**
*
*
export const serverProps = () => serverStore.props;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
+export const serverStatus = () => serverStore.status;
export const serverRole = () => serverStore.role;
export const defaultParams = () => serverStore.defaultParams;
export const contextSize = () => serverStore.contextSize;
* - Base path resolution
*/
+/**
+ * Error thrown when an API request fails, carrying the HTTP status code
+ * so callers can distinguish e.g. a 503 "still loading" response from a
+ * genuine failure.
+ */
+export class ApiError extends Error {
+ status: number;
+
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = 'ApiError';
+ this.status = status;
+ }
+}
+
export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
/**
* Use auth-only headers (no Content-Type).
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
- throw new Error(errorMessage);
+ throw new ApiError(errorMessage, response.status);
}
return response.json() as Promise<T>;
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
- throw new Error(errorMessage);
+ throw new ApiError(errorMessage, response.status);
}
return response.json() as Promise<T>;
+++ /dev/null
-<!DOCTYPE html>
-<html>
- <head>
- <meta http-equiv="refresh" content="5">
- </head>
- <body>
- <div id="loading">
- The model is loading. Please wait.<br/>
- The user interface will appear soon.
- </div>
- </body>
-</html>
expect(existsSync(resolve(DIST_DIR, 'pwa-192x192.png'))).toBeTruthy();
expect(existsSync(resolve(DIST_DIR, 'pwa-512x512.png'))).toBeTruthy();
});
-
- it('has loading.html fallback page', () => {
- expect(existsSync(resolve(DIST_DIR, 'loading.html'))).toBeTruthy();
- });
});
});