server: remove loading.html (#25500)
* server: remove loading.html * apply ui changes
This commit is contained in:
@@ -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 }}/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.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 }}/bundle.css --yes 2>/dev/null || true
|
||||||
hf buckets rm ggml-org/${{ env.HF_BUCKET_NAME }}/loading.html --yes 2>/dev/null || true
|
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ bool server_http_context::init(const common_params & params) {
|
|||||||
// Middlewares
|
// 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
|
// Public endpoints - API routes plus all embedded UI assets
|
||||||
static const std::unordered_set<std::string> get_public_endpoints = []() {
|
static const std::unordered_set<std::string> get_public_endpoints = []() {
|
||||||
std::unordered_set<std::string> endpoints {
|
std::unordered_set<std::string> endpoints {
|
||||||
@@ -182,11 +191,8 @@ bool server_http_context::init(const common_params & params) {
|
|||||||
"/v1/health",
|
"/v1/health",
|
||||||
"/models",
|
"/models",
|
||||||
"/v1/models",
|
"/v1/models",
|
||||||
"/",
|
|
||||||
};
|
};
|
||||||
for (const llama_ui_asset & a : llama_ui_get_assets()) {
|
endpoints.insert(frontend_paths.begin(), frontend_paths.end());
|
||||||
endpoints.insert("/" + a.name);
|
|
||||||
}
|
|
||||||
return endpoints;
|
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) {
|
auto middleware_server_state = [this](const httplib::Request & req, httplib::Response & res) {
|
||||||
if (!is_ready.load()) {
|
if (!is_ready.load()) {
|
||||||
#if defined(LLAMA_UI_HAS_ASSETS)
|
if (frontend_paths.count(req.path)) {
|
||||||
if (const auto tmp = string_split<std::string>(req.path, '.');
|
return true; // frontend asset, allow it to load and show "loading"
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
(void)req;
|
|
||||||
#endif
|
|
||||||
// no endpoints are allowed to be accessed when the server is not ready
|
// no endpoints are allowed to be accessed when the server is not ready
|
||||||
// this is to prevent any data races or inconsistent states
|
// this is to prevent any data races or inconsistent states
|
||||||
res.status = 503;
|
res.status = 503;
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ int main(int argc, char ** argv) {
|
|||||||
struct required_check { const char * label; match_fn match; bool found; };
|
struct required_check { const char * label; match_fn match; bool found; };
|
||||||
required_check checks[] = {
|
required_check checks[] = {
|
||||||
{ "index.html", exact("index.html"), false },
|
{ "index.html", exact("index.html"), false },
|
||||||
{ "loading.html", exact("loading.html"), false },
|
|
||||||
{ "manifest.webmanifest", exact("manifest.webmanifest"), false },
|
{ "manifest.webmanifest", exact("manifest.webmanifest"), false },
|
||||||
{ "sw.js", exact("sw.js"), false },
|
{ "sw.js", exact("sw.js"), false },
|
||||||
{ "build.json", exact("build.json"), false },
|
{ "build.json", exact("build.json"), false },
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<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 { fadeInView } from '$lib/actions/fade-in-view.svelte';
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
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 hasError = $derived(!!serverError());
|
||||||
|
let isLoadingModel = $derived(serverStatus() === 503);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if hasError}
|
{#if hasError}
|
||||||
@@ -12,23 +13,31 @@
|
|||||||
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
|
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
|
||||||
use:fadeInView={{ y: 10, duration: 250 }}
|
use:fadeInView={{ y: 10, duration: 250 }}
|
||||||
>
|
>
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
|
||||||
<AlertTriangle class="h-4 w-4" />
|
{#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">
|
<Alert.Title class="flex items-center justify-between">
|
||||||
<span>Server unavailable</span>
|
<span>{isLoadingModel ? 'Loading model' : 'Server unavailable'}</span>
|
||||||
|
|
||||||
<button
|
{#if !isLoadingModel}
|
||||||
onclick={() => serverStore.fetch()}
|
<button
|
||||||
disabled={serverLoading()}
|
onclick={() => serverStore.fetch()}
|
||||||
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"
|
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'}
|
<RefreshCw class="h-3 w-3 {serverLoading() ? 'animate-spin' : ''}" />
|
||||||
</button>
|
{serverLoading() ? 'Retrying...' : 'Retry'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</Alert.Title>
|
</Alert.Title>
|
||||||
|
|
||||||
<Alert.Description>{serverError()}</Alert.Description>
|
{#if !isLoadingModel}
|
||||||
|
<Alert.Description>{serverError()}</Alert.Description>
|
||||||
|
{/if}
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -258,12 +258,6 @@ export const GLOB_PATTERNS: string[] = [
|
|||||||
'**/*.{js,css,html,ico,svg,png,webp,woff,woff2,json,webmanifest}'
|
'**/*.{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 = {
|
export const SW_CONFIG = {
|
||||||
CHECK_INTERVAL_MS: 60000,
|
CHECK_INTERVAL_MS: 60000,
|
||||||
UPDATE_FETCH_OPTIONS: {
|
UPDATE_FETCH_OPTIONS: {
|
||||||
@@ -317,7 +311,6 @@ export const SVELTEKIT_PWA_OPTIONS: SvelteKitPWAOptions = {
|
|||||||
// Uses '**/' because SvelteKit outputs files under _app/immutable/
|
// Uses '**/' because SvelteKit outputs files under _app/immutable/
|
||||||
// subdirectories.
|
// subdirectories.
|
||||||
globPatterns: GLOB_PATTERNS,
|
globPatterns: GLOB_PATTERNS,
|
||||||
globIgnores: GLOB_IGNORES,
|
|
||||||
maximumFileSizeToCacheInBytes: CACHE_SETTINGS.MAX_FILE_SIZE_BYTES,
|
maximumFileSizeToCacheInBytes: CACHE_SETTINGS.MAX_FILE_SIZE_BYTES,
|
||||||
|
|
||||||
// Prevent @vite-pwa/sveltekit from auto-adding a NavigationRoute by
|
// Prevent @vite-pwa/sveltekit from auto-adding a NavigationRoute by
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { PropsService } from '$lib/services/props.service';
|
import { PropsService } from '$lib/services/props.service';
|
||||||
import { ServerRole } from '$lib/enums';
|
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
|
* serverStore - Server connection state, configuration, and role detection
|
||||||
@@ -29,8 +32,10 @@ class ServerStore {
|
|||||||
props = $state<ApiLlamaCppServerProps | null>(null);
|
props = $state<ApiLlamaCppServerProps | null>(null);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
|
status = $state<number | null>(null);
|
||||||
role = $state<ServerRole | null>(null);
|
role = $state<ServerRole | null>(null);
|
||||||
private fetchPromise: Promise<void> | null = null;
|
private fetchPromise: Promise<void> | null = null;
|
||||||
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -70,23 +75,43 @@ class ServerStore {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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;
|
if (this.fetchPromise) return this.fetchPromise;
|
||||||
|
|
||||||
this.loading = true;
|
this.clearRetryTimer();
|
||||||
this.error = null;
|
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 () => {
|
const fetchPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const props = await PropsService.fetch();
|
const props = await PropsService.fetch();
|
||||||
this.props = props;
|
this.props = props;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.status = null;
|
||||||
this.detectRole(props);
|
this.detectRole(props);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.error = error instanceof Error ? error.message : String(error);
|
this.error = error instanceof Error ? error.message : String(error);
|
||||||
|
this.status = error instanceof ApiError ? error.status : null;
|
||||||
console.error('Error fetching server properties:', error);
|
console.error('Error fetching server properties:', error);
|
||||||
|
|
||||||
|
if (this.status === 503) {
|
||||||
|
this.scheduleRetry();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
if (!background) {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
this.fetchPromise = null;
|
this.fetchPromise = null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -96,13 +121,30 @@ class ServerStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
this.clearRetryTimer();
|
||||||
this.props = null;
|
this.props = null;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.status = null;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.role = null;
|
this.role = null;
|
||||||
this.fetchPromise = 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 serverProps = () => serverStore.props;
|
||||||
export const serverLoading = () => serverStore.loading;
|
export const serverLoading = () => serverStore.loading;
|
||||||
export const serverError = () => serverStore.error;
|
export const serverError = () => serverStore.error;
|
||||||
|
export const serverStatus = () => serverStore.status;
|
||||||
export const serverRole = () => serverStore.role;
|
export const serverRole = () => serverStore.role;
|
||||||
export const defaultParams = () => serverStore.defaultParams;
|
export const defaultParams = () => serverStore.defaultParams;
|
||||||
export const contextSize = () => serverStore.contextSize;
|
export const contextSize = () => serverStore.contextSize;
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants/error';
|
|||||||
* - Base path resolution
|
* - 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'> {
|
export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
|
||||||
/**
|
/**
|
||||||
* Use auth-only headers (no Content-Type).
|
* Use auth-only headers (no Content-Type).
|
||||||
@@ -67,7 +82,7 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = await parseErrorMessage(response);
|
const errorMessage = await parseErrorMessage(response);
|
||||||
throw new Error(errorMessage);
|
throw new ApiError(errorMessage, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
@@ -119,7 +134,7 @@ export async function apiFetchWithParams<T>(
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorMessage = await parseErrorMessage(response);
|
const errorMessage = await parseErrorMessage(response);
|
||||||
throw new Error(errorMessage);
|
throw new ApiError(errorMessage, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -189,9 +189,5 @@ describe('PWA Build Output', () => {
|
|||||||
expect(existsSync(resolve(DIST_DIR, 'pwa-192x192.png'))).toBeTruthy();
|
expect(existsSync(resolve(DIST_DIR, 'pwa-192x192.png'))).toBeTruthy();
|
||||||
expect(existsSync(resolve(DIST_DIR, 'pwa-512x512.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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user