From a935fbffe1a3d31509c325c116454ab5d56b2eb8 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Fri, 10 Jul 2026 14:42:17 +0200 Subject: [PATCH] server: remove loading.html (#25500) * server: remove loading.html * apply ui changes --- .github/workflows/ui-publish.yml | 1 - tools/server/server-http.cpp | 27 +++++----- tools/ui/embed.cpp | 1 - .../ChatScreen/ChatScreenServerError.svelte | 37 +++++++++----- tools/ui/src/lib/constants/pwa.ts | 7 --- tools/ui/src/lib/stores/server.svelte.ts | 51 +++++++++++++++++-- tools/ui/src/lib/utils/api-fetch.ts | 19 ++++++- tools/ui/static/loading.html | 12 ----- tools/ui/tests/unit/pwa.spec.ts | 4 -- 9 files changed, 99 insertions(+), 60 deletions(-) delete mode 100644 tools/ui/static/loading.html diff --git a/.github/workflows/ui-publish.yml b/.github/workflows/ui-publish.yml index c3b7343c65..99a6d8420f 100644 --- a/.github/workflows/ui-publish.yml +++ b/.github/workflows/ui-publish.yml @@ -73,4 +73,3 @@ jobs: 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 diff --git a/tools/server/server-http.cpp b/tools/server/server-http.cpp index bb88dda219..87eee5fd4d 100644 --- a/tools/server/server-http.cpp +++ b/tools/server/server-http.cpp @@ -175,6 +175,15 @@ bool server_http_context::init(const common_params & params) { // Middlewares // + // Frontend paths - all embedded UI assets + static const std::unordered_set frontend_paths = []() { + std::unordered_set 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 get_public_endpoints = []() { std::unordered_set endpoints { @@ -182,11 +191,8 @@ bool server_http_context::init(const common_params & params) { "/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; }(); @@ -239,18 +245,9 @@ bool server_http_context::init(const common_params & params) { 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(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(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; diff --git a/tools/ui/embed.cpp b/tools/ui/embed.cpp index cdbb642323..914d51fa1d 100644 --- a/tools/ui/embed.cpp +++ b/tools/ui/embed.cpp @@ -187,7 +187,6 @@ int main(int argc, char ** argv) { 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 }, diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte index 2a998dbebf..5345f19a57 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte @@ -1,10 +1,11 @@ {#if hasError} @@ -12,23 +13,31 @@ class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1" use:fadeInView={{ y: 10, duration: 250 }} > - - + + {#if isLoadingModel} + + {:else} + + {/if} - Server unavailable + {isLoadingModel ? 'Loading model' : 'Server unavailable'} - + {#if !isLoadingModel} + + {/if} - {serverError()} + {#if !isLoadingModel} + {serverError()} + {/if} {/if} diff --git a/tools/ui/src/lib/constants/pwa.ts b/tools/ui/src/lib/constants/pwa.ts index b9171d4bbf..343bcaf3cd 100644 --- a/tools/ui/src/lib/constants/pwa.ts +++ b/tools/ui/src/lib/constants/pwa.ts @@ -258,12 +258,6 @@ export const GLOB_PATTERNS: string[] = [ '**/*.{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: { @@ -317,7 +311,6 @@ export const SVELTEKIT_PWA_OPTIONS: SvelteKitPWAOptions = { // 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 diff --git a/tools/ui/src/lib/stores/server.svelte.ts b/tools/ui/src/lib/stores/server.svelte.ts index d9a9f855a9..66ab411194 100644 --- a/tools/ui/src/lib/stores/server.svelte.ts +++ b/tools/ui/src/lib/stores/server.svelte.ts @@ -1,5 +1,8 @@ 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 @@ -29,8 +32,10 @@ class ServerStore { props = $state(null); loading = $state(false); error = $state(null); + status = $state(null); role = $state(null); private fetchPromise: Promise | null = null; + private retryTimer: ReturnType | null = null; /** * @@ -70,23 +75,43 @@ class ServerStore { * */ - async fetch(): Promise { + /** + * @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 { 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; } })(); @@ -96,13 +121,30 @@ class ServerStore { } 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; + } + } + /** * * @@ -125,6 +167,7 @@ export const serverStore = new ServerStore(); 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; diff --git a/tools/ui/src/lib/utils/api-fetch.ts b/tools/ui/src/lib/utils/api-fetch.ts index 82a9383ddf..e9d9062583 100644 --- a/tools/ui/src/lib/utils/api-fetch.ts +++ b/tools/ui/src/lib/utils/api-fetch.ts @@ -12,6 +12,21 @@ import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants/error'; * - 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 { /** * Use auth-only headers (no Content-Type). @@ -67,7 +82,7 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): if (!response.ok) { const errorMessage = await parseErrorMessage(response); - throw new Error(errorMessage); + throw new ApiError(errorMessage, response.status); } return response.json() as Promise; @@ -119,7 +134,7 @@ export async function apiFetchWithParams( if (!response.ok) { const errorMessage = await parseErrorMessage(response); - throw new Error(errorMessage); + throw new ApiError(errorMessage, response.status); } return response.json() as Promise; diff --git a/tools/ui/static/loading.html b/tools/ui/static/loading.html deleted file mode 100644 index c3fd19a0f5..0000000000 --- a/tools/ui/static/loading.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - -
- The model is loading. Please wait.
- The user interface will appear soon. -
- - diff --git a/tools/ui/tests/unit/pwa.spec.ts b/tools/ui/tests/unit/pwa.spec.ts index 17da279866..64b9630d22 100644 --- a/tools/ui/tests/unit/pwa.spec.ts +++ b/tools/ui/tests/unit/pwa.spec.ts @@ -189,9 +189,5 @@ describe('PWA Build Output', () => { 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(); - }); }); });