ui: PWA support (#23871)
* feat: Add basic PWA support and service worker for offline caching * feat: Vite PWA implementation WIP * feat: Improve PWA icons generation * feat: Add PWA workbox to server routes * feat: Include `version.json` in static assets * feat: Add HTTP cache headers for PWA static assets * feat: Update app name for `apple-mobile-web-app-title` * feat: Implement PWA versioning and automatic update detection * chore: Update `.gitignore` files * feat: Splash Screens * feat: Add dark mode favicon support * refactor: Cleanup * fix: Use dark logo for dark splash screens * refactor: Simplify favicons SVG code * fix: Adjust caching and polling for reliable service worker updates * fix: Add missing favicon entry * fix: Align PWA service worker configuration with SvelteKit build structure * fix: Replace hashed bundle paths with versioned static paths * test: Add PWA tests * ci: Add build output for unit tests * refactor: Cleanup * fix: Server build & release versioning * chore: Update package-lock.json * chore: Increase PWA cache size * chore: Update packages * feat: Update favicons * refactor: Post-merge fix * feat: support explicit build version for PWA cache busting * fix: CI * feat: Improve PWA Refresh Alert UI * feat: Add toggleable build version display * refactor: Cleanup * feat: Add version mismatch detection and manual app reload * refactor: replace dynamic imports with static * refactor: Cleanup * feat: Add safe space for `pwa-<size>.png` rendered icons * fix: use relative paths for PWA assets to support base path deployment * feat: add PWA mode detection via URL query parameter * feat: Use ?cache=true for SW-cached PWA assets * refactor: Build process cleanup * refactor: Decouple PWA versioning and remove ?cache=true workaround * chore: Update README logo * feat: Include PWA Assets generation in build script * refactor: `usePwa` hook for core layout * fix: Relativize base vite plugin * fix: remove unnecessary backslash escapes in test regexes * test: update static asset paths for API Key test * refactor: Move SvelteKit PWA Options config to constants * ui: fix update notification never appearing Keep the PWA hook object intact instead of destructuring needRefreshByStorage, which freezes the reactive getter. Also exclude loading.html from PWA precache to prevent 404 errors and broken SW installation.
This commit is contained in:
@@ -20,6 +20,8 @@
|
||||
import { ColorMode } from '$lib/enums/ui.enums';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { RefreshCw } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import { setChatSettingsConfigContext } from '$lib/contexts';
|
||||
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
|
||||
@@ -164,6 +166,15 @@
|
||||
onConfigChange={handleConfigChange}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
|
||||
{#if currentSection.title === SETTINGS_SECTION_TITLES.GENERAL}
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" onclick={() => window.location.reload()}>
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
Reload app
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { APPLE_META_TAGS, MEDIA_QUERIES, THEME_COLORS } from '$lib/constants/pwa';
|
||||
import { APP_NAME } from '$lib/constants';
|
||||
|
||||
let { appName = APP_NAME } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- Theme color for light/dark modes -->
|
||||
<meta name="theme-color" content={THEME_COLORS.LIGHT} media={MEDIA_QUERIES.PREFERS_LIGHT} />
|
||||
<meta name="theme-color" content={THEME_COLORS.DARK} media={MEDIA_QUERIES.PREFERS_DARK} />
|
||||
|
||||
<!-- Apple mobile web app meta tags -->
|
||||
<meta
|
||||
name={APPLE_META_TAGS.MOBILE_WEB_APP_CAPABLE.name}
|
||||
content={APPLE_META_TAGS.MOBILE_WEB_APP_CAPABLE.content}
|
||||
/>
|
||||
<meta
|
||||
name={APPLE_META_TAGS.STATUS_BAR_STYLE.name}
|
||||
content={APPLE_META_TAGS.STATUS_BAR_STYLE.content}
|
||||
/>
|
||||
<meta name={APPLE_META_TAGS.MOBILE_WEB_APP_TITLE.name} content={appName} />
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
let { needRefresh: needRefreshProp, updateServiceWorker, forceReload } = $props();
|
||||
let needRefresh = $derived(needRefreshProp ?? false);
|
||||
</script>
|
||||
|
||||
{#if needRefresh}
|
||||
<Card.Root class="overflow-hidden gap-1 py-5">
|
||||
<Card.Header class="px-5">
|
||||
<Card.Title class="text-sm font-medium">Update available</Card.Title>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="gap-6 grid px-5">
|
||||
<p class="text-xs text-muted-foreground">A new version is available. Reload to update.</p>
|
||||
|
||||
<Button
|
||||
class="justify-self-end-safe"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
updateServiceWorker();
|
||||
|
||||
if (forceReload) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
needRefresh = false;
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as PwaMetaTags } from './PwaMetaTags.svelte';
|
||||
export { default as PwaRefreshAlert } from './PwaRefreshAlert.svelte';
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_NAME = import.meta.env?.VITE_PUBLIC_APP_NAME || 'llama-ui';
|
||||
@@ -1,4 +1,5 @@
|
||||
export const NEWLINE = '\n';
|
||||
export const TAB = '\t';
|
||||
export const DEFAULT_LANGUAGE = 'text';
|
||||
export const LANG_PATTERN = /^(\w*)\n?/;
|
||||
export const AMPERSAND_REGEX = /&/g;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
export * from './agentic';
|
||||
export * from './api-endpoints';
|
||||
export * from './app';
|
||||
export * from './attachment-labels';
|
||||
export * from './database';
|
||||
export * from './reasoning-effort';
|
||||
@@ -36,6 +37,7 @@ export * from './message-export';
|
||||
export * from './model-id';
|
||||
export * from './precision';
|
||||
export * from './processing-info';
|
||||
export * from './pwa';
|
||||
export * from './routes';
|
||||
export * from './sandbox';
|
||||
export * from './settings-keys';
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Centralized PWA constants to avoid magic strings, regexes, and duplicated
|
||||
* definitions across the codebase.
|
||||
*/
|
||||
|
||||
import { APP_NAME } from './app';
|
||||
|
||||
export const MEDIA_QUERIES = {
|
||||
PREFERS_DARK: '(prefers-color-scheme: dark)',
|
||||
PREFERS_LIGHT: '(prefers-color-scheme: light)'
|
||||
} as const;
|
||||
|
||||
export const THEME_COLORS = {
|
||||
LIGHT: '#ffffff',
|
||||
DARK: '#0d0d0d',
|
||||
ACCENT_BLUE: '#2563eb',
|
||||
ACCENT_BLUE_HOVER: '#1d4ed8',
|
||||
BACKGROUND_LIGHT: 'white',
|
||||
BACKGROUND_DARK: '#111111',
|
||||
TITLE_UPDATE_ALERT: {
|
||||
BORDER_LIGHT: 'zinc-200',
|
||||
BORDER_DARK: 'zinc-700',
|
||||
BG_LIGHT: 'white',
|
||||
BG_DARK: 'zinc-800',
|
||||
TEXT_LIGHT: 'zinc-500',
|
||||
TEXT_DARK: 'zinc-400'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const FAVICON_PATHS = {
|
||||
ICO_LIGHT: 'favicon.ico',
|
||||
ICO_DARK: 'favicon-dark.ico',
|
||||
SVG_LIGHT: 'favicon.svg',
|
||||
SVG_DARK: 'favicon-dark.svg'
|
||||
} as const;
|
||||
|
||||
export const FAVICON_SELECTORS = {
|
||||
ICO_48X48: 'link[rel="icon"][sizes="48x48"]',
|
||||
SVG_ANY: 'link[rel="icon"][type="image/svg+xml"]'
|
||||
} as const;
|
||||
|
||||
export const APPLE_ASSETS = {
|
||||
TOUCH_ICON: 'apple-touch-icon-180x180.png'
|
||||
} as const;
|
||||
|
||||
export const PWA_MANIFEST = {
|
||||
name: APP_NAME,
|
||||
short_name: APP_NAME,
|
||||
description: 'Local AI chat interface powered by llama.cpp',
|
||||
start_url: './',
|
||||
display: 'standalone' as const,
|
||||
background_color: THEME_COLORS.BACKGROUND_LIGHT,
|
||||
theme_color: THEME_COLORS.BACKGROUND_LIGHT,
|
||||
icons: [
|
||||
{ src: 'pwa-64x64.png', sizes: '64x64', type: 'image/png' },
|
||||
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any' as const },
|
||||
{
|
||||
src: 'maskable-icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable' as const
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const PWA_ICON_PATHS = {
|
||||
PWA_64: '/pwa-64x64.png',
|
||||
PWA_192: '/pwa-192x192.png',
|
||||
PWA_512: '/pwa-512x512.png',
|
||||
MASKABLE_512: '/maskable-icon-512x512.png'
|
||||
} as const;
|
||||
|
||||
/** Apple device dimensions (logical points) and DPR, from Apple HIG. */
|
||||
export const APPLE_DEVICES = {
|
||||
// iPhones (DPR 3)
|
||||
'1170x2532': { width: 390, height: 844, dpr: 3 }, // iPhone 13, 15
|
||||
'1179x2556': { width: 393, height: 852, dpr: 3 }, // iPhone 14, 15 Pro, 16
|
||||
'1206x2622': { width: 402, height: 874, dpr: 3 }, // iPhone 16 Plus, 16e
|
||||
'1284x2778': { width: 428, height: 926, dpr: 3 }, // iPhone 15 Plus
|
||||
'1290x2796': { width: 430, height: 932, dpr: 3 }, // iPhone 15 Pro Max, 16 Pro
|
||||
'1320x2868': { width: 440, height: 956, dpr: 3 }, // iPhone 16 Pro Max
|
||||
'750x1334': { width: 375, height: 667, dpr: 2 }, // iPhone 6/7/8, 14
|
||||
'640x1136': { width: 320, height: 568, dpr: 2 }, // iPhone 6/7/8 Plus
|
||||
// iPads (DPR 2)
|
||||
'1668x2388': { width: 834, height: 1194, dpr: 2 }, // iPad Air 11", iPad 11"
|
||||
'2048x2732': { width: 1024, height: 1366, dpr: 2 }, // iPad Pro 12.9"
|
||||
'1640x2360': { width: 820, height: 1180, dpr: 2 }, // iPad Air 10.9"
|
||||
'1032x1376': { width: 1032, height: 1376, dpr: 2 }, // iPad Air 13"
|
||||
'744x1133': { width: 376, height: 573, dpr: 2 } // iPad mini 8.3"
|
||||
} as const;
|
||||
|
||||
export type AppleDeviceKey = keyof typeof APPLE_DEVICES;
|
||||
|
||||
export const PWA_FILE_PATHS = {
|
||||
MANIFEST: '/manifest.webmanifest',
|
||||
SERVICE_WORKER: '/sw.js',
|
||||
VERSION: '/version.json',
|
||||
WORKBOX: '/workbox-<hash>.js'
|
||||
} as const;
|
||||
|
||||
// Used by the server middleware to skip API key validation.
|
||||
// Keep in sync with tools/server/server-http.cpp public_endpoints list.
|
||||
|
||||
export const PUBLIC_ENDPOINTS = [
|
||||
'/health',
|
||||
'/v1/health',
|
||||
'/models',
|
||||
'/v1/models',
|
||||
'/props',
|
||||
'/metrics',
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
'/favicon.ico',
|
||||
'/favicon-dark.ico',
|
||||
'/favicon.svg',
|
||||
'/favicon-dark.svg',
|
||||
'/pwa-64x64.png',
|
||||
'/pwa-192x192.png',
|
||||
'/pwa-512x512.png',
|
||||
'/maskable-icon-512x512.png',
|
||||
'/apple-touch-icon-180x180.png',
|
||||
'/apple-splash-portrait-640x1136.png',
|
||||
'/apple-splash-landscape-640x1136.png',
|
||||
'/apple-splash-portrait-750x1334.png',
|
||||
'/apple-splash-landscape-750x1334.png',
|
||||
'/apple-splash-portrait-1170x2532.png',
|
||||
'/apple-splash-landscape-1170x2532.png',
|
||||
'/apple-splash-portrait-1179x2556.png',
|
||||
'/apple-splash-landscape-1179x2556.png',
|
||||
'/apple-splash-portrait-1206x2622.png',
|
||||
'/apple-splash-landscape-1206x2622.png',
|
||||
'/apple-splash-portrait-1284x2778.png',
|
||||
'/apple-splash-landscape-1284x2778.png',
|
||||
'/apple-splash-portrait-1290x2796.png',
|
||||
'/apple-splash-landscape-1290x2796.png',
|
||||
'/apple-splash-portrait-1320x2868.png',
|
||||
'/apple-splash-landscape-1320x2868.png',
|
||||
'/apple-splash-portrait-1488x2266.png',
|
||||
'/apple-splash-landscape-1488x2266.png',
|
||||
'/apple-splash-portrait-1640x2360.png',
|
||||
'/apple-splash-landscape-1640x2360.png',
|
||||
'/apple-splash-portrait-1668x2388.png',
|
||||
'/apple-splash-landscape-1668x2388.png',
|
||||
'/apple-splash-portrait-2048x2732.png',
|
||||
'/apple-splash-landscape-2048x2732.png',
|
||||
'/apple-splash-portrait-dark-640x1136.png',
|
||||
'/apple-splash-landscape-dark-640x1136.png',
|
||||
'/apple-splash-portrait-dark-750x1334.png',
|
||||
'/apple-splash-landscape-dark-750x1334.png',
|
||||
'/apple-splash-portrait-dark-1170x2532.png',
|
||||
'/apple-splash-landscape-dark-1170x2532.png',
|
||||
'/apple-splash-portrait-dark-1179x2556.png',
|
||||
'/apple-splash-landscape-dark-1179x2556.png',
|
||||
'/apple-splash-portrait-dark-1206x2622.png',
|
||||
'/apple-splash-landscape-dark-1206x2622.png',
|
||||
'/apple-splash-portrait-dark-1284x2778.png',
|
||||
'/apple-splash-landscape-dark-1284x2778.png',
|
||||
'/apple-splash-portrait-dark-1290x2796.png',
|
||||
'/apple-splash-landscape-dark-1290x2796.png',
|
||||
'/apple-splash-portrait-dark-1320x2868.png',
|
||||
'/apple-splash-landscape-dark-1320x2868.png',
|
||||
'/apple-splash-portrait-dark-1488x2266.png',
|
||||
'/apple-splash-landscape-dark-1488x2266.png',
|
||||
'/apple-splash-portrait-dark-1640x2360.png',
|
||||
'/apple-splash-landscape-dark-1640x2360.png',
|
||||
'/apple-splash-portrait-dark-1668x2388.png',
|
||||
'/apple-splash-landscape-dark-1668x2388.png',
|
||||
'/apple-splash-portrait-dark-2048x2732.png',
|
||||
'/apple-splash-landscape-dark-2048x2732.png',
|
||||
'/manifest.webmanifest',
|
||||
'/sw.js',
|
||||
'/version.json',
|
||||
'/workbox-<hash>.js'
|
||||
] as const;
|
||||
export const BUILD_CONFIG = {
|
||||
OUTPUT_DIR: './dist',
|
||||
GUIDE_COMMENT: `
|
||||
<!--
|
||||
This is a static build of the frontend.
|
||||
It is automatically generated by the build process.
|
||||
Do not edit this file directly.
|
||||
To make changes, refer to the "Web UI" section in the README.
|
||||
-->
|
||||
`.trim()
|
||||
} as const;
|
||||
|
||||
export const REGEX_PATTERNS = {
|
||||
SPLASH_FILE: /^apple-splash-(portrait|landscape)-(dark-)?(\d+)x(\d+)\.png$/,
|
||||
HEAD_CLOSE: /\t*<\/head>/
|
||||
} as const;
|
||||
|
||||
// Device names used by @vite-pwa/assets-generator for splash screen generation.
|
||||
// Keep in sync with pwa-assets.config.ts.
|
||||
export const PWA_GENERATOR_DEVICES = [
|
||||
'iPhone 13',
|
||||
'iPhone 13 Pro',
|
||||
'iPhone 13 Pro Max',
|
||||
'iPhone 14',
|
||||
'iPhone 14 Plus',
|
||||
'iPhone 14 Pro',
|
||||
'iPhone 14 Pro Max',
|
||||
'iPhone 15',
|
||||
'iPhone 15 Plus',
|
||||
'iPhone 15 Pro',
|
||||
'iPhone 15 Pro Max',
|
||||
'iPhone 16',
|
||||
'iPhone 16 Plus',
|
||||
'iPhone 16 Pro',
|
||||
'iPhone 16 Pro Max',
|
||||
'iPhone 16e',
|
||||
'iPhone SE 4"',
|
||||
'iPhone SE 4.7"',
|
||||
'iPad 11"',
|
||||
'iPad Air 10.9"',
|
||||
'iPad Air 11"',
|
||||
'iPad Air 13"',
|
||||
'iPad Pro 11"',
|
||||
'iPad Pro 12.9"',
|
||||
'iPad mini 8.3"'
|
||||
] as const;
|
||||
|
||||
// PWA assets generator configuration — used by pwa-assets.config.ts
|
||||
export const PWA_ASSET_GENERATOR = {
|
||||
LINK_PRESET: '2023',
|
||||
SPLASH_PADDING: 0.75,
|
||||
FIT_MODE: 'contain',
|
||||
ADD_MEDIA_SCREEN: true,
|
||||
BASE_PATH: './',
|
||||
XHTML: false,
|
||||
PNG_COMPRESSION_LEVEL: 9,
|
||||
PNG_QUALITY: 60,
|
||||
DARK_PREFIX: 'dark-'
|
||||
} as const;
|
||||
|
||||
export const CACHE_SETTINGS = {
|
||||
IMMUTABLE_MAX_AGE_SECONDS: 31536000,
|
||||
API_CACHE_MAX_AGE_SECONDS: 60 * 60 * 24,
|
||||
API_CACHE_MAX_ENTRIES: 50,
|
||||
MAX_FILE_SIZE_BYTES: 10 * 1024 * 1024
|
||||
} as const;
|
||||
|
||||
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: {
|
||||
CACHE: 'no-store',
|
||||
HEADERS: {
|
||||
CACHE: 'no-store',
|
||||
CACHE_CONTROL: 'no-cache'
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Runtime caching configuration for Workbox
|
||||
export const RUNTIME_CACHING = {
|
||||
HANDLER: 'NetworkFirst',
|
||||
CACHE_NAME: 'api-cache'
|
||||
} as const;
|
||||
|
||||
// Workbox runtime caching patterns
|
||||
export const API_CACHING_PATTERNS = {
|
||||
V1_API: /^\/v1\/.*/,
|
||||
STATIC_API: /^\/(health|props|models|tools|slots|cors-proxy).*/
|
||||
} as const;
|
||||
|
||||
// SvelteKit PWA plugin options
|
||||
export const PWA_KIT_OPTIONS = {
|
||||
NAVIGATE_FALLBACK: './'
|
||||
} as const;
|
||||
|
||||
export const APPLE_META_TAGS = {
|
||||
MOBILE_WEB_APP_CAPABLE: { name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||
STATUS_BAR_STYLE: { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
||||
MOBILE_WEB_APP_TITLE: { name: 'apple-mobile-web-app-title' }
|
||||
} as const;
|
||||
|
||||
// Splash screen HTML link tag prefix used by generateSplashScreenLinks
|
||||
export const SPLASH_LINK = {
|
||||
HTML: '<link rel="apple-touch-startup-image"',
|
||||
DARK_MEDIA_SUFFIX: ' and (prefers-color-scheme: dark)'
|
||||
} as const;
|
||||
|
||||
// SvelteKit PWA plugin configuration — used by @vite.config.ts
|
||||
import type { SvelteKitPWAOptions } from '@vite-pwa/sveltekit';
|
||||
|
||||
export const SVELTEKIT_PWA_OPTIONS: SvelteKitPWAOptions = {
|
||||
// Strategy: generateSW - the plugin generates a service worker automatically
|
||||
// using Workbox. For a custom SW, use 'injectManifest' instead.
|
||||
// Manifest configuration
|
||||
manifest: PWA_MANIFEST,
|
||||
|
||||
// Workbox configuration for generateSW strategy
|
||||
workbox: {
|
||||
// Match all static assets in the build output.
|
||||
// Uses '**/' because SvelteKit outputs files under _app/immutable/
|
||||
// subdirectories.
|
||||
globPatterns: GLOB_PATTERNS,
|
||||
globIgnores: GLOB_IGNORES,
|
||||
maximumFileSizeToCacheInBytes: CACHE_SETTINGS.MAX_FILE_SIZE_BYTES,
|
||||
|
||||
// Runtime caching for API calls - use NetworkFirst so APIs are always fresh
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: API_CACHING_PATTERNS.V1_API,
|
||||
handler: RUNTIME_CACHING.HANDLER,
|
||||
options: {
|
||||
cacheName: RUNTIME_CACHING.CACHE_NAME,
|
||||
expiration: {
|
||||
maxEntries: CACHE_SETTINGS.API_CACHE_MAX_ENTRIES,
|
||||
maxAgeSeconds: CACHE_SETTINGS.API_CACHE_MAX_AGE_SECONDS
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: API_CACHING_PATTERNS.STATIC_API,
|
||||
handler: RUNTIME_CACHING.HANDLER,
|
||||
options: {
|
||||
cacheName: RUNTIME_CACHING.CACHE_NAME,
|
||||
expiration: {
|
||||
maxEntries: CACHE_SETTINGS.API_CACHE_MAX_ENTRIES,
|
||||
maxAgeSeconds: CACHE_SETTINGS.API_CACHE_MAX_AGE_SECONDS
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
suppressWarnings: true,
|
||||
// Use PWA_KIT_OPTIONS.NAVIGATE_FALLBACK to match production SW behaviour
|
||||
// (navigateFallback defaults to the configured base path, which is '/' for this SPA).
|
||||
navigateFallback: PWA_KIT_OPTIONS.NAVIGATE_FALLBACK
|
||||
},
|
||||
|
||||
// SvelteKit-specific options
|
||||
kit: {
|
||||
// Include version file for proper cache invalidation
|
||||
includeVersionFile: true
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,7 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_BUILD_VERSION: 'showBuildVersion',
|
||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||
// Sampling
|
||||
TEMPERATURE: 'temperature',
|
||||
|
||||
@@ -365,6 +365,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_BUILD_VERSION,
|
||||
label: 'Show build version information',
|
||||
help: 'Display the current build version in the bottom-right corner of the interface.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -40,6 +40,9 @@ export const DEPRECATED_MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NA
|
||||
/** @deprecated Use {@link USER_OVERRIDES_LOCALSTORAGE_KEY} instead */
|
||||
export const DEPRECATED_USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME_DEPRECATED}.userOverrides`;
|
||||
|
||||
/** Build version stored in localStorage for non-PWA update detection */
|
||||
export const BUILD_VERSION_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.buildVersion`;
|
||||
|
||||
/** Maps new keys to their deprecated fallback keys */
|
||||
export const NEW_TO_DEPRECATED_MAP: Record<string, string> = {
|
||||
[ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY]: DEPRECATED_ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ROUTES } from './routes';
|
||||
|
||||
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||
export const APP_NAME = import.meta.env.VITE_PUBLIC_APP_NAME || 'llama-ui';
|
||||
|
||||
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
||||
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||
|
||||
@@ -63,3 +63,5 @@ export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol }
|
||||
export { KeyboardKey } from './keyboard.enums';
|
||||
|
||||
export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
|
||||
|
||||
export { SplashOrientation } from './splash.enums';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Splash screen orientation for iOS apple-touch-startup-image
|
||||
*/
|
||||
export enum SplashOrientation {
|
||||
PORTRAIT = 'portrait',
|
||||
LANDSCAPE = 'landscape'
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { useRegisterSW } from 'virtual:pwa-register/svelte';
|
||||
import { versionStore } from '$lib/stores/version.svelte';
|
||||
import { BUILD_VERSION_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
import { SW_CONFIG } from '$lib/constants/pwa';
|
||||
|
||||
/**
|
||||
* Hook for PWA service worker registration, update polling, and build version mismatch detection.
|
||||
*
|
||||
* Combines two concerns that always belong together:
|
||||
* 1. SW registration with periodic polling for updates
|
||||
* 2. localStorage-based version tracking for non-PWA users
|
||||
*/
|
||||
export function usePwa() {
|
||||
let swCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let needRefreshByStorage = $state(false);
|
||||
|
||||
const {
|
||||
// offlineReady, // to do - add installation banners for iOS
|
||||
needRefresh: pwaNeedRefresh,
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
onRegisteredSW(swUrl: string, r: ServiceWorkerRegistration | undefined) {
|
||||
if (swCheckInterval) {
|
||||
clearInterval(swCheckInterval);
|
||||
}
|
||||
swCheckInterval = setInterval(async () => {
|
||||
if (!r || r.installing || !navigator?.onLine) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(swUrl, {
|
||||
cache: SW_CONFIG.UPDATE_FETCH_OPTIONS.CACHE,
|
||||
headers: {
|
||||
cache: SW_CONFIG.UPDATE_FETCH_OPTIONS.HEADERS.CACHE,
|
||||
'cache-control': SW_CONFIG.UPDATE_FETCH_OPTIONS.HEADERS.CACHE_CONTROL
|
||||
}
|
||||
});
|
||||
if (resp?.status === 200) {
|
||||
await r.update();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, SW_CONFIG.CHECK_INTERVAL_MS);
|
||||
},
|
||||
onRegisterError(error: unknown) {
|
||||
console.error('[PWA] SW registration error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Detect version mismatch via localStorage.
|
||||
// _app/version.json is SvelteKit's native version file for PWA cache invalidation.
|
||||
// This comparison detects server upgrades for non-PWA users.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const currentVersion = versionStore.value;
|
||||
if (!currentVersion) return;
|
||||
|
||||
try {
|
||||
const storedVersion = localStorage.getItem(BUILD_VERSION_LOCALSTORAGE_KEY);
|
||||
needRefreshByStorage = !!storedVersion && storedVersion !== currentVersion;
|
||||
localStorage.setItem(BUILD_VERSION_LOCALSTORAGE_KEY, currentVersion);
|
||||
} catch {
|
||||
needRefreshByStorage = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
/** Writable that is true when a PWA service worker update is available */
|
||||
get needRefresh() {
|
||||
return pwaNeedRefresh;
|
||||
},
|
||||
updateServiceWorker,
|
||||
/** Version mismatch detected via localStorage (non-PWA users) */
|
||||
get needRefreshByStorage() {
|
||||
return needRefreshByStorage;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* buildInfoStore - llama.cpp build information
|
||||
*
|
||||
* Reads the build version from `build.json` — embedded at llama.cpp build time
|
||||
* with the llama.cpp build number (LLAMA_BUILD_NUMBER). Shown in the UI when
|
||||
* `showBuildVersion` is enabled.
|
||||
*
|
||||
* In dev mode (via `npm run dev`), falls back to `import.meta.env.DEV`'s truthy
|
||||
* value since the artifact is not produced.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
let build = $state<string>('');
|
||||
|
||||
async function loadBuild() {
|
||||
if (!browser) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
build = 'dev';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
build = data.version ?? '';
|
||||
}
|
||||
} catch {
|
||||
// build.json missing or unreachable - leave as empty string
|
||||
}
|
||||
}
|
||||
|
||||
loadBuild();
|
||||
|
||||
export const buildInfoStore = {
|
||||
get value(): string {
|
||||
return build;
|
||||
}
|
||||
};
|
||||
@@ -489,7 +489,7 @@ class MCPStore {
|
||||
if (!rootDomain) return null;
|
||||
|
||||
const origin = `${url.protocol}//${rootDomain}`;
|
||||
const candidates = ['favicon.ico', 'favicon.svg', 'favicon.png'];
|
||||
const candidates = ['favicon.ico', 'favicon.png'];
|
||||
|
||||
for (const path of candidates) {
|
||||
const faviconUrl = `${origin}/${path}`;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { MEDIA_QUERIES } from '$lib/constants';
|
||||
|
||||
export const theme = $state({
|
||||
isSystemDark: browser && window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches
|
||||
});
|
||||
|
||||
if (browser) {
|
||||
const mql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
|
||||
|
||||
mql.addEventListener('change', (e) => {
|
||||
theme.isSystemDark = e.matches;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* versionStore - Frontend build version
|
||||
*
|
||||
* Reads from SvelteKit's `_app/version.json` — generated by the @vite-pwa/sveltekit
|
||||
* plugin. The version string changes on every build, so comparing it against
|
||||
* localStorage reliably detects server upgrades.
|
||||
*
|
||||
* In dev mode, falls back to `'dev'`.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
let version = $state<string>('');
|
||||
|
||||
async function loadVersion() {
|
||||
if (!browser) return;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
version = 'dev';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
version = data.version ?? '';
|
||||
}
|
||||
} catch {
|
||||
// _app/version.json missing or unreachable - leave as empty string
|
||||
}
|
||||
}
|
||||
|
||||
loadVersion();
|
||||
|
||||
export const versionStore = {
|
||||
get value(): string {
|
||||
return version;
|
||||
}
|
||||
};
|
||||
@@ -165,3 +165,6 @@ export type { ToolEntry, ToolGroup } from './tools';
|
||||
|
||||
// Reasoning
|
||||
export type { ReasoningEffortLevel } from './reasoning';
|
||||
|
||||
// Splash
|
||||
export type { SplashDimensions } from './splash';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type SplashDimensions = { deviceW: number; deviceH: number; dpr: number };
|
||||
@@ -57,7 +57,7 @@ export async function convertPDFToText(file: File): Promise<string> {
|
||||
|
||||
try {
|
||||
const buffer = await getFileAsBuffer(file);
|
||||
const pdf = await pdfjs.getDocument(buffer).promise;
|
||||
const pdf = await pdfjs.getDocument({ data: buffer }).promise;
|
||||
const numPages = pdf.numPages;
|
||||
|
||||
const textContentPromises: Promise<TextContent>[] = [];
|
||||
@@ -94,7 +94,7 @@ export async function convertPDFToImage(file: File, scale: number = 1.5): Promis
|
||||
|
||||
try {
|
||||
const buffer = await getFileAsBuffer(file);
|
||||
const doc = await pdfjs.getDocument(buffer).promise;
|
||||
const doc = await pdfjs.getDocument({ data: buffer }).promise;
|
||||
const pages: Promise<string>[] = [];
|
||||
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
|
||||
Reference in New Issue
Block a user