Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.github/workflows/ui-publish.yml
#	CODEOWNERS
#	docs/ops.md
#	ggml/CMakeLists.txt
#	ggml/src/CMakeLists.txt
#	ggml/src/ggml-hexagon/htp/CMakeLists.txt
#	ggml/src/ggml-hexagon/htp/argsort-ops.c
#	scripts/sync-ggml.last
#	tools/mtmd/tests/test-deepseek-ocr.py
This commit is contained in:
Concedo
2026-07-11 11:36:59 +08:00
32 changed files with 1234 additions and 650 deletions
-1
View File
@@ -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 },
@@ -1,10 +1,11 @@
<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}
@@ -12,23 +13,31 @@
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}
@@ -5,7 +5,7 @@
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
sideOffset = 4,
side = 'top',
children,
arrowClasses,
-7
View File
@@ -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
@@ -1115,21 +1115,18 @@ class ConversationsStore {
}
/**
* Downloads a conversation as JSON file.
* Downloads a single conversation as a JSONL file, serializing the full message tree.
* @param convId - The conversation ID to download
*/
async downloadConversation(convId: string): Promise<void> {
let conversation: DatabaseConversation | null;
let messages: DatabaseMessage[];
const conversation =
this.activeConversation?.id === convId
? this.activeConversation
: await DatabaseService.getConversation(convId);
if (this.activeConversation?.id === convId) {
conversation = this.activeConversation;
messages = this.activeMessages;
} else {
conversation = await DatabaseService.getConversation(convId);
if (!conversation) return;
messages = await DatabaseService.getConversationMessages(convId);
}
if (!conversation) return;
const messages = await DatabaseService.getConversationMessages(convId);
this.downloadConversationFile({ conv: conversation, messages });
}
+10 -1
View File
@@ -145,6 +145,10 @@ class ModelsStore {
*/
getModelModalities(modelId: string): ModelModalities | null {
if (!isRouterMode() && serverStore.props?.modalities) {
return this.buildModalities(serverStore.props.modalities);
}
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
@@ -629,7 +633,12 @@ class ModelsStore {
}
findModelByName(modelName: string): ModelOption | null {
return this.models.find((model) => model.model === modelName) ?? null;
return (
this.models.find(
(model) =>
model.model === modelName || model.id === modelName || model.aliases?.includes(modelName)
) ?? null
);
}
findModelById(modelId: string): ModelOption | null {
+47 -4
View File
@@ -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<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;
/**
*
@@ -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;
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;
+17 -2
View File
@@ -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<RequestInit, 'headers'> {
/**
* Use auth-only headers (no Content-Type).
@@ -67,7 +82,7 @@ export async function apiFetch<T>(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<T>;
@@ -119,7 +134,7 @@ export async function apiFetchWithParams<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>;
-12
View File
@@ -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>
-4
View File
@@ -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();
});
});
});