]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
server: clean up static assets handling (#24550)
authorXuan-Son Nguyen <redacted>
Sat, 13 Jun 2026 09:51:20 +0000 (11:51 +0200)
committerGitHub <redacted>
Sat, 13 Jun 2026 09:51:20 +0000 (11:51 +0200)
* server: clean up static assets handling

* nits

* simplify file name handling, use static file name everywhere

* cmake/ui : bundle UI assets in an archive

* ui : run prettier on post-build.js

---------

Co-authored-by: Alde Rojas <redacted>
.github/workflows/ui-build-self-hosted.yml
.github/workflows/ui-build.yml
.github/workflows/ui-publish.yml
scripts/ui-assets.cmake
tools/server/server-http.cpp
tools/ui/CMakeLists.txt
tools/ui/embed.cpp
tools/ui/package.json
tools/ui/scripts/post-build.js [new file with mode: 0644]
tools/ui/scripts/vite-plugin-build-info.ts

index e5d576cda625cf68403127de2129d0c4eb2aee36..7b7f8b60025a3713753962458902459b9132d271 100644 (file)
@@ -28,13 +28,6 @@ jobs:
         run: npm run build
         working-directory: tools/ui
 
-      - name: Generate checksums
-        run: |
-          cd tools/ui/dist
-          for f in *; do
-            sha256sum "$f" | awk '{print $1, $2}' >> checksums.txt
-          done
-
       - name: Upload built UI
         uses: actions/upload-artifact@v6
         with:
index 7ff5860efc2b61fea8b40f34ae8f57e9c8dea0a9..85642f3f4b76b7527a4ecb290ab01de28e8ad583 100644 (file)
@@ -32,7 +32,7 @@ jobs:
       - name: Build application
         env:
           HF_UI_VERSION: ${{ inputs.hf_ui_version || '' }}
-          LLAMA_UI_VERSION: ${{ inputs.hf_ui_version || 'b0000' }}
+          LLAMA_BUILD_NUMBER: ${{ inputs.hf_ui_version || 'b0000' }}
         run: npm run build
         working-directory: tools/ui
 
@@ -40,13 +40,6 @@ jobs:
         run: npx vitest --project=unit --run tests/unit/pwa.spec.ts
         working-directory: tools/ui
 
-      - name: Generate checksums
-        run: |
-          cd tools/ui/dist
-          for f in *; do
-            sha256sum "$f" | awk '{print $1, $2}' >> checksums.txt
-          done
-
       - name: Upload built UI
         uses: actions/upload-artifact@v6
         with:
index cec0fa52a1224ee92e736e5e952cfa155c2d3433..c3b7343c655b07a73cca056b1748cd7aa7503020 100644 (file)
@@ -40,6 +40,12 @@ jobs:
           name: ui-build
           path: tools/ui/dist/
 
+      - name: Create distribution archive
+        run: |
+          tar -czf dist.tar.gz -C tools/ui/dist .
+          sha256sum dist.tar.gz > dist.tar.gz.sha256
+          mv dist.tar.gz dist.tar.gz.sha256 tools/ui/dist/
+
       - name: Install Hugging Face Hub CLI
         run: pip install -U huggingface_hub
 
index 6069d9816e305a5902825460dac685634b6a5748..e5b47863ecf596cf082a022c650cf56c3be553c2 100644 (file)
@@ -4,8 +4,9 @@
 #   1. Pre-built assets in SRC_DIST_DIR (manually built by user)
 #   2. If BUILD_UI=ON: npm build
 #   3. If above did not produce assets and HF_ENABLED=ON: HF Bucket download
+#      of dist.tar.gz (verified against dist.tar.gz.sha256)
 
-cmake_minimum_required(VERSION 3.16)
+cmake_minimum_required(VERSION 3.18)
 
 set(UI_SOURCE_DIR     "" CACHE STRING "UI source directory (to run npm build)")
 set(UI_BINARY_DIR     "" CACHE STRING "UI binary directory (to store generated files)")
@@ -16,80 +17,21 @@ set(HF_ENABLED        "" CACHE STRING "Whether to allow HF Bucket download (ON/O
 set(BUILD_UI          "" CACHE STRING "Build UI via npm (ON/OFF)")
 set(LLAMA_UI_EMBED    "" CACHE STRING "Path to llama-ui-embed helper")
 
-# IMPORTANT: When adding PWA assets, sync across all 3 places:
-#   1. tools/ui/src/lib/constants/pwa.ts   (APPLE_DEVICES, PUBLIC_ENDPOINTS)
-#   2. tools/server/server-http.cpp        (public_endpoints)
-#   3. scripts/ui-assets.cmake             (ASSETS list)
-# - C++ (server-http.cpp) - public endpoints (splash screens generated via helper)
-# - TypeScript (constants/pwa.ts) - APPLE_DEVICES, PWA_MANIFEST, PUBLIC_ENDPOINTS
+# IMPORTANT: When adding PWA assets, sync:
+#   - tools/ui/src/lib/constants/pwa.ts   (APPLE_DEVICES, PWA_MANIFEST)
 #
-# When adding/changing PWA assets, update tools/ui/src/lib/constants/pwa.ts first,
-# then sync any new file names here and in server-http.cpp.
-set(ASSETS
+# The HTTP server registers routes and public endpoints for every embedded asset.
+set(REQUIRED_ASSETS
     index.html
     loading.html
-    # PWA assets
-    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
-    # iOS splash screens
-    apple-splash-portrait-640x1136.png
-    apple-splash-landscape-1136x640.png
-    apple-splash-portrait-750x1334.png
-    apple-splash-landscape-1334x750.png
-    apple-splash-portrait-1170x2532.png
-    apple-splash-landscape-2532x1170.png
-    apple-splash-portrait-1179x2556.png
-    apple-splash-landscape-2556x1179.png
-    apple-splash-portrait-1206x2622.png
-    apple-splash-landscape-2622x1206.png
-    apple-splash-portrait-1284x2778.png
-    apple-splash-landscape-2778x1284.png
-    apple-splash-portrait-1290x2796.png
-    apple-splash-landscape-2796x1290.png
-    apple-splash-portrait-1320x2868.png
-    apple-splash-landscape-2868x1320.png
-    apple-splash-portrait-1488x2266.png
-    apple-splash-landscape-2266x1488.png
-    apple-splash-portrait-1640x2360.png
-    apple-splash-landscape-2360x1640.png
-    apple-splash-portrait-1668x2388.png
-    apple-splash-landscape-2388x1668.png
-    apple-splash-portrait-2048x2732.png
-    apple-splash-landscape-2732x2048.png
-    # iOS dark splash screens
-    apple-splash-portrait-dark-640x1136.png
-    apple-splash-landscape-dark-1136x640.png
-    apple-splash-portrait-dark-750x1334.png
-    apple-splash-landscape-dark-1334x750.png
-    apple-splash-portrait-dark-1170x2532.png
-    apple-splash-landscape-dark-2532x1170.png
-    apple-splash-portrait-dark-1179x2556.png
-    apple-splash-landscape-dark-2556x1179.png
-    apple-splash-portrait-dark-1206x2622.png
-    apple-splash-landscape-dark-2622x1206.png
-    apple-splash-portrait-dark-1284x2778.png
-    apple-splash-landscape-dark-2778x1284.png
-    apple-splash-portrait-dark-1290x2796.png
-    apple-splash-landscape-dark-2796x1290.png
-    apple-splash-portrait-dark-1320x2868.png
-    apple-splash-landscape-dark-2868x1320.png
-    apple-splash-portrait-dark-1640x2360.png
-    apple-splash-landscape-dark-2360x1640.png
-    apple-splash-portrait-dark-1668x2388.png
-    apple-splash-landscape-dark-2388x1668.png
-    apple-splash-portrait-dark-2048x2732.png
-    apple-splash-landscape-dark-2732x2048.png
     manifest.webmanifest
     sw.js
-    _app/version.json
     build.json
+    # post-build.js flattens and dehashes these to fixed names in the dist dir
+    bundle.js
+    bundle.css
+    workbox.js
+    version.json
 )
 
 set(DIST_DIR     "${UI_BINARY_DIR}/dist")
@@ -98,10 +40,10 @@ set(STAMP_FILE   "${UI_BINARY_DIR}/.ui-stamp")
 set(UI_CPP       "${UI_BINARY_DIR}/ui.cpp")
 set(UI_H         "${UI_BINARY_DIR}/ui.h")
 
-function(assets_present out_var)
+function(assets_present dir out_var)
     set(present TRUE)
-    foreach(asset ${ASSETS})
-        if(NOT EXISTS "${DIST_DIR}/${asset}")
+    foreach(asset ${REQUIRED_ASSETS})
+        if(NOT EXISTS "${dir}/${asset}")
             set(present FALSE)
             break()
         endif()
@@ -109,30 +51,10 @@ function(assets_present out_var)
     set(${out_var} ${present} PARENT_SCOPE)
 endfunction()
 
-function(copy_src_dist out_var)
-    set(${out_var} FALSE PARENT_SCOPE)
-
-    foreach(asset ${ASSETS})
-        if(NOT EXISTS "${SRC_DIST_DIR}/${asset}")
-            return()
-        endif()
-    endforeach()
-
-    file(MAKE_DIRECTORY "${DIST_DIR}")
-    message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}")
-    foreach(asset ${ASSETS})
-        execute_process(
-            COMMAND ${CMAKE_COMMAND} -E copy_if_different
-                "${SRC_DIST_DIR}/${asset}" "${DIST_DIR}/${asset}"
-        )
-    endforeach()
-    set(${out_var} TRUE PARENT_SCOPE)
-endfunction()
-
 function(npm_build_should_skip out_var)
     set(${out_var} FALSE PARENT_SCOPE)
 
-    assets_present(present)
+    assets_present("${DIST_DIR}" present)
     if(NOT present)
         return()
     endif()
@@ -240,7 +162,7 @@ function(npm_build out_var)
         return()
     endif()
 
-    assets_present(present)
+    assets_present("${DIST_DIR}" present)
     if(NOT present)
         message(STATUS "UI: npm build finished but assets missing in ${DIST_DIR}")
         return()
@@ -272,7 +194,7 @@ function(hf_download version out_var out_resolved)
     set(${out_var}      FALSE PARENT_SCOPE)
     set(${out_resolved} ""    PARENT_SCOPE)
 
-    file(MAKE_DIRECTORY "${DIST_DIR}")
+    set(archive "${UI_BINARY_DIR}/dist.tar.gz")
 
     set(candidates "")
     if(NOT "${version}" STREQUAL "")
@@ -281,97 +203,67 @@ function(hf_download version out_var out_resolved)
     list(APPEND candidates "latest")
 
     foreach(resolved ${candidates})
-        set(base "https://huggingface.co/buckets/ggml-org/${HF_BUCKET}/resolve/${resolved}")
-
-        message(STATUS "UI: downloading from ${resolved}: ${base}")
-
-        set(ok TRUE)
-        foreach(asset ${ASSETS})
-            file(DOWNLOAD "${base}/${asset}?download=true" "${DIST_DIR}/${asset}"
-                STATUS status TIMEOUT 60
-            )
-            list(GET status 0 rc)
-            if(NOT rc EQUAL 0)
-                list(GET status 1 errmsg)
-                message(STATUS "UI: download ${asset} from ${resolved} failed: ${errmsg}")
-                set(ok FALSE)
-                break()
-            endif()
-            message(STATUS "UI: downloaded ${asset}")
-        endforeach()
-
-        if(NOT ok)
+        set(base "https://huggingface.co/buckets/${HF_BUCKET}/resolve/${resolved}")
+
+        message(STATUS "UI: downloading from ${resolved}: ${base}/dist.tar.gz")
+
+        file(DOWNLOAD "${base}/dist.tar.gz?download=true" "${archive}"
+            STATUS status TIMEOUT 300
+        )
+        list(GET status 0 rc)
+        if(NOT rc EQUAL 0)
+            list(GET status 1 errmsg)
+            message(STATUS "UI: download dist.tar.gz from ${resolved} failed: ${errmsg}")
             continue()
         endif()
 
-        # Best-effort checksum verification
-        file(DOWNLOAD "${base}/checksums.txt?download=true" "${DIST_DIR}/checksums.txt"
-            STATUS cs_status TIMEOUT 30
+        file(DOWNLOAD "${base}/dist.tar.gz.sha256?download=true" "${archive}.sha256"
+            STATUS status TIMEOUT 30
         )
-        list(GET cs_status 0 cs_rc)
-        if(cs_rc EQUAL 0)
-            message(STATUS "UI: verifying checksums")
-            file(STRINGS "${DIST_DIR}/checksums.txt" cs_lines)
-            foreach(asset ${ASSETS})
-                file(SHA256 "${DIST_DIR}/${asset}" h)
-                string(TOLOWER "${h}" h)
-                string(REGEX MATCH "${h}[ \t]+${asset}" m "${cs_lines}")
-                if(NOT m)
-                    message(WARNING "UI: checksum verification failed for ${asset}")
-                    set(ok FALSE)
-                    break()
-                endif()
-            endforeach()
-            if(ok)
-                message(STATUS "UI: all checksums verified")
-            endif()
+        list(GET status 0 rc)
+        if(NOT rc EQUAL 0)
+            list(GET status 1 errmsg)
+            message(STATUS "UI: download dist.tar.gz.sha256 from ${resolved} failed: ${errmsg}")
+            continue()
         endif()
 
-        if(ok)
-            set(${out_var}      TRUE         PARENT_SCOPE)
-            set(${out_resolved} "${resolved}" PARENT_SCOPE)
-            return()
+        # Validate sha256 checkums
+        file(READ "${archive}.sha256" expected)
+        string(REGEX MATCH "^[0-9a-fA-F]+" expected "${expected}")
+        string(TOLOWER "${expected}" expected)
+        file(SHA256 "${archive}" actual)
+        if("${expected}" STREQUAL "" OR NOT "${actual}" STREQUAL "${expected}")
+            message(STATUS "UI: checksum mismatch for dist.tar.gz from ${resolved}")
+            continue()
+        endif()
+
+        # Clear DIST_DIR to remove stale files first
+        file(REMOVE_RECURSE "${DIST_DIR}")
+
+        file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${DIST_DIR}")
+
+        assets_present("${DIST_DIR}" present)
+        if(NOT present)
+            message(STATUS "UI: archive from ${resolved} is missing required assets")
+            continue()
         endif()
+
+        message(STATUS "UI: archive verified and extracted")
+        set(${out_var}      TRUE          PARENT_SCOPE)
+        set(${out_resolved} "${resolved}" PARENT_SCOPE)
+        return()
     endforeach()
 endfunction()
 
-function(emit_files)
-    assets_present(present)
+function(emit_files dist_dir)
+    assets_present("${dist_dir}" present)
 
     set(args "${UI_CPP}" "${UI_H}")
     if(present)
-        foreach(asset ${ASSETS})
-            list(APPEND args "${asset}" "${DIST_DIR}/${asset}")
-        endforeach()
-
-        # Bundle files live in _app/immutable/ — vanilla SvelteKit output, no plugin
-        # rewriting. Embedded names must match the exact _app/ paths that index.html
-        # and sw.js reference.
-        file(GLOB_RECURSE detected_bundle_js "${DIST_DIR}/_app/immutable/bundle.*.js")
-        file(GLOB_RECURSE detected_bundle_css "${DIST_DIR}/_app/immutable/assets/bundle.*.css")
-        file(GLOB_RECURSE detected_workbox "${DIST_DIR}/workbox-*.js")
-        # Compute relative path from DIST_DIR to each found file.
-        # e.g. /path/to/build/tools/ui/dist/_app/immutable/bundle.XXX.js
-        #      -> _app/immutable/bundle.XXX.js
-        foreach(f ${detected_bundle_js})
-            string(REPLACE "${DIST_DIR}/" "" rel "${f}")
-            list(APPEND args "${rel}" "${f}")
-        endforeach()
-        foreach(f ${detected_bundle_css})
-            string(REPLACE "${DIST_DIR}/" "" rel "${f}")
-            list(APPEND args "${rel}" "${f}")
-        endforeach()
-        foreach(f ${detected_workbox})
-            string(REPLACE "${DIST_DIR}/" "" rel "${f}")
-            list(APPEND args "${rel}" "${f}")
-        endforeach()
+        # llama-ui-embed embeds every top-level file in dist_dir
+        list(APPEND args "${dist_dir}")
     endif()
 
-    # Create build.json with the llama.cpp build number for UI version display.
-    # This is separate from SvelteKit's _app/version.json (used for SW cache invalidation).
-    # build.json is generated by the vite plugin (buildInfoPlugin) during npm build.
-    # CMake just embeds it from the dist that npm produced.
-
     execute_process(
         COMMAND "${LLAMA_UI_EMBED}" ${args}
         RESULT_VARIABLE rc
@@ -384,9 +276,10 @@ endfunction()
 # ---------------------------------------------------------------------------
 # 1. Priority 1: pre-built assets supplied in tools/ui/dist
 # ---------------------------------------------------------------------------
-copy_src_dist(SRC_OK)
+assets_present("${SRC_DIST_DIR}" SRC_OK)
 if(SRC_OK)
-    emit_files()
+    message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}")
+    emit_files("${SRC_DIST_DIR}")
     return()
 endif()
 
@@ -419,7 +312,7 @@ if(NOT provisioned AND HF_ENABLED)
         endif()
     endif()
 
-    assets_present(have_assets)
+    assets_present("${DIST_DIR}" have_assets)
     if(stamp_ok AND have_assets)
         message(STATUS "UI: HF stamp '${stamped}' matches version, skipping HF fetch")
         set(provisioned TRUE)
@@ -439,7 +332,7 @@ endif()
 # 4. Fallback: warn about stale or missing assets, then emit whatever we have
 # ---------------------------------------------------------------------------
 if(NOT provisioned)
-    assets_present(have_assets)
+    assets_present("${DIST_DIR}" have_assets)
     if(have_assets)
         message(WARNING "UI: provisioning failed; embedding stale assets from ${DIST_DIR}")
     else()
@@ -451,4 +344,4 @@ if(NOT provisioned)
     endif()
 endif()
 
-emit_files()
+emit_files("${DIST_DIR}")
index 272702ce57b552cc2059ea72e826deec114ad47d..bb63cc5985766e93ddd3f6dc6c8cb1b0a280889c 100644 (file)
@@ -26,6 +26,52 @@ server_http_context::server_http_context()
 
 server_http_context::~server_http_context() = default;
 
+// transform path --> asset name ; rules:
+// delete "_app/" prefix
+// delete hash, for ex: bundle.HCjcCZFH.css --> bundle.css
+//                      workbox-12bd46aa.js --> workbox.js
+static std::string asset_name_from_path(const std::string & path) {
+    // Strip leading slash
+    std::string s = (!path.empty() && path[0] == '/') ? path.substr(1) : path;
+    // Strip _app/ prefix
+    if (s.size() > 5 && s.compare(0, 5, "_app/") == 0) {
+        s = s.substr(5);
+    }
+    // Strip hash segment from filename:
+    //   bundle.HCjcCZFH.css -> bundle.css  (name.HASH.ext)
+    //   workbox-12bd46aa.js -> workbox.js  (name-HEXHASH.ext)
+    size_t slash = s.rfind('/');
+    std::string dir  = (slash != std::string::npos) ? s.substr(0, slash + 1) : "";
+    std::string file = (slash != std::string::npos) ? s.substr(slash + 1)    : s;
+
+    auto is_alnum_hash = [](const std::string & h) {
+        if (h.size() < 6 || h.size() > 16) return false;
+        for (char c : h) { if (!isalnum((unsigned char)c)) return false; }
+        return true;
+    };
+    auto is_hex_hash = [](const std::string & h) {
+        if (h.size() < 6 || h.size() > 16) return false;
+        for (char c : h) { if (!isxdigit((unsigned char)c)) return false; }
+        return true;
+    };
+
+    size_t dot1 = file.find('.');
+    if (dot1 != std::string::npos) {
+        size_t dot2 = file.find('.', dot1 + 1);
+        if (dot2 != std::string::npos && is_alnum_hash(file.substr(dot1 + 1, dot2 - dot1 - 1))) {
+            file = file.substr(0, dot1) + file.substr(dot2);
+        } else {
+            size_t dot = file.rfind('.');
+            size_t dash = file.rfind('-', dot);
+            if (dash != std::string::npos && is_hex_hash(file.substr(dash + 1, dot - dash - 1))) {
+                file = file.substr(0, dash) + file.substr(dot);
+            }
+        }
+    }
+
+    return dir + file;
+}
+
 static void log_server_request(const httplib::Request & req, const httplib::Response & res) {
     // skip logging requests that are regularly sent, to avoid log spam
     if (req.path == "/health"
@@ -169,91 +215,21 @@ bool server_http_context::init(const common_params & params) {
         SRV_INF("api_keys: %zu keys loaded\n", params.api_keys.size());
     }
 
-    //
-    // Helper: Generate iOS splash screen paths from device dimensions
-    // This centralizes PWA asset paths to avoid duplication across CMake, C++, and TypeScript.
-    // Source of truth: tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES)
-    //
-    auto generate_splash_endpoints = []() -> std::vector<std::string> {
-        // Apple device dimensions (width x height) with orientation and color scheme
-        // Format: "orientation-dimension1xdimension2.png" or "orientation-dark-dimension1xdimension2.png"
-        // Based on https://developer.apple.com/design/human-interface-guidelines/app-icons
-        static const std::vector<std::pair<std::string, std::string>> splash_specs = {
-            // Portrait screens (light)
-            {"portrait", "640x1136"},     {"portrait", "750x1334"},
-            {"portrait", "1170x2532"},    {"portrait", "1179x2556"},
-            {"portrait", "1206x2622"},    {"portrait", "1284x2778"},
-            {"portrait", "1290x2796"},    {"portrait", "1320x2868"},
-            {"portrait", "1488x2266"},    {"portrait", "1640x2360"},
-            {"portrait", "1668x2388"},    {"portrait", "2048x2732"},
-            // Landscape screens (light) - dimensions swapped
-            {"landscape", "1136x640"},    {"landscape", "1334x750"},
-            {"landscape", "2532x1170"},   {"landscape", "2556x1179"},
-            {"landscape", "2622x1206"},   {"landscape", "2778x1284"},
-            {"landscape", "2796x1290"},   {"landscape", "2868x1320"},
-            {"landscape", "2266x1488"},   {"landscape", "2360x1640"},
-            {"landscape", "2388x1668"},   {"landscape", "2732x2048"},
-            // Portrait screens (dark)
-            {"portrait-dark", "640x1136"}, {"portrait-dark", "750x1334"},
-            {"portrait-dark", "1170x2532"}, {"portrait-dark", "1179x2556"},
-            {"portrait-dark", "1206x2622"}, {"portrait-dark", "1284x2778"},
-            {"portrait-dark", "1290x2796"}, {"portrait-dark", "1320x2868"},
-            {"portrait-dark", "1488x2266"}, {"portrait-dark", "1640x2360"},
-            {"portrait-dark", "1668x2388"}, {"portrait-dark", "2048x2732"},
-            // Landscape screens (dark)
-            {"landscape-dark", "1136x640"}, {"landscape-dark", "1334x750"},
-            {"landscape-dark", "2532x1170"}, {"landscape-dark", "2556x1179"},
-            {"landscape-dark", "2622x1206"}, {"landscape-dark", "2778x1284"},
-            {"landscape-dark", "2796x1290"}, {"landscape-dark", "2868x1320"},
-            {"landscape-dark", "2266x1488"}, {"landscape-dark", "2360x1640"},
-            {"landscape-dark", "2388x1668"}, {"landscape-dark", "2732x2048"}
-        };
-
-        std::vector<std::string> endpoints;
-        endpoints.reserve(splash_specs.size());
-        for (const auto & [orientation, dimensions] : splash_specs) {
-            endpoints.push_back("/apple-splash-" + orientation + "-" + dimensions + ".png");
-        }
-        return endpoints;
-    };
-
     //
     // Middlewares
     //
 
-    // Public endpoints list - includes health, UI, and PWA assets
-    // Source of truth for splash screen paths: tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES)
-    static const std::unordered_set<std::string> get_public_endpoints = [generate_splash_endpoints]() {
+    // 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 {
             "/health",
             "/v1/health",
             "/models",
             "/v1/models",
             "/",
-            "/index.html",
-            // PWA assets
-            "/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",
-            // iOS splash screens (generated from APPLE_DEVICES in TypeScript)
-            // PWA runtime files
-            "/manifest.webmanifest",
-            "/sw.js",
-            "/version.json",
-            "/workbox-<hash>.js",
-            "/_app/version.json",
-            "/build.json"
         };
-        // Add all splash screen endpoints
-        auto splash = generate_splash_endpoints();
-        for (const auto & path : splash) {
-            endpoints.insert(path);
+        for (const llama_ui_asset & a : llama_ui_get_assets()) {
+            endpoints.insert("/" + a.name);
         }
         return endpoints;
     }();
@@ -264,13 +240,9 @@ bool server_http_context::init(const common_params & params) {
             return true;
         }
 
-        // If path is public or static file, skip validation
-        if (get_public_endpoints.find(req.path) != get_public_endpoints.end()) {
-            return true;
-        }
-        // Static assets (_app/ files, workbox runtime). These are embedded at build time
-        // so no API key is needed — browsers fetch them directly.
-        if (req.path.find("/_app/") == 0 || req.path.find("/workbox-") == 0) {
+        // If path is public or a UI asset (including hashed paths like /_app/bundle.XXX.js),
+        // skip validation
+        if (get_public_endpoints.count("/" + asset_name_from_path(req.path))) {
             return true;
         }
 
@@ -394,151 +366,71 @@ bool server_http_context::init(const common_params & params) {
             }
         } else {
 #if defined(LLAMA_UI_HAS_ASSETS)
-            // Embedded assets are immutable — cache aggressively for PWA/sw offline support.
-            // PWA runtime files (sw.js, manifest, version.json) use no-cache for revalidation.
-            // Bundle files use Vite content hashes (bundle.<hash>.js/css) so each build
-            // produces a different filename — browsers naturally get a fresh copy on upgrade.
-            auto serve_asset_cached = [](const std::string & name, const char * mime, bool with_isolation_headers) {
-                return [name, mime, with_isolation_headers](const httplib::Request & req, httplib::Response & res) {
-                    const llama_ui_asset * a = llama_ui_find_asset(name.c_str());
-                    if (!a) {
-                        res.status = 404;
-                        return false;
-                    }
+            auto serve_asset_cached = [](const std::string & name, bool isolation) {
+                return [name, isolation](const httplib::Request & req, httplib::Response & res) {
+                    const llama_ui_asset * a = llama_ui_find_asset(name);
+                    if (!a) { res.status = 404; return false; }
                     res.set_header("ETag", a->etag);
-                    // Check If-None-Match for conditional GET (304 Not Modified)
                     if (const std::string & inm = req.get_header_value("If-None-Match");
                         !inm.empty() && (inm == a->etag || inm == std::string("W/") + a->etag)) {
                         res.status = 304;
                         return false;
                     }
-                    if (with_isolation_headers) {
-                        // COEP and COOP headers, required by pyodide (python interpreter)
+                    if (isolation) {
                         res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
-                        res.set_header("Cross-Origin-Opener-Policy", "same-origin");
+                        res.set_header("Cross-Origin-Opener-Policy",   "same-origin");
                     }
                     res.set_header("Cache-Control", "public, max-age=31536000, immutable");
-                    res.set_content(reinterpret_cast<const char*>(a->data), a->size, mime);
+                    res.set_content(reinterpret_cast<const char*>(a->data), a->size, a->type.c_str());
                     return false;
                 };
             };
 
-            auto serve_asset_nocache = [](const std::string & name, const char * mime, bool with_isolation_headers) {
-                return [name, mime, with_isolation_headers](const httplib::Request & /*req*/, httplib::Response & res) {
-                    const llama_ui_asset * a = llama_ui_find_asset(name.c_str());
+            auto serve_asset_nocache = [](const std::string & name) {
+                return [name](const httplib::Request & /*req*/, httplib::Response & res) {
+                    const llama_ui_asset * a = llama_ui_find_asset(name);
                     if (!a) {
                         res.status = 404;
                         return false;
                     }
-                    if (with_isolation_headers) {
-                        res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
-                        res.set_header("Cross-Origin-Opener-Policy", "same-origin");
-                    }
                     res.set_header("Cache-Control", "no-cache");
-                    res.set_content(reinterpret_cast<const char*>(a->data), a->size, mime);
+                    res.set_content(reinterpret_cast<const char*>(a->data), a->size, a->type.c_str());
                     return false;
                 };
             };
 
-            // Bundle files in _app/immutable/ — SvelteKit outputs them here and index.html
-            // and sw.js reference them via these paths (vanilla build, no plugin).
-            auto serve_bundle = [serve_asset_cached](const httplib::Request & req, httplib::Response & res) {
-                std::string path = req.path;
-                std::string name;
-                const char * mime;
-                if (path.rfind("/_app/immutable/bundle.", 0) == 0 && path.size() > 22) {
-                    name = path.substr(1);  // strip leading /
-                    mime = "application/javascript; charset=utf-8";
-                } else if (path.rfind("/_app/immutable/assets/bundle.", 0) == 0 && path.size() > 30) {
-                    name = path.substr(1);  // strip leading /
-                    mime = "text/css; charset=utf-8";
-                } else {
-                    res.status = 404;
-                    return false;
-                }
-                return serve_asset_cached(name, mime, false)(req, res);
+            // Hashed routes: browser requests contain the build hash, assets are stored without.
+            auto serve_hashed = [serve_asset_cached](const std::string & name) {
+                return serve_asset_cached(name, false);
             };
+            srv->Get(params.api_prefix + R"(/_app/immutable/bundle\.[^/]+\.js)",         serve_hashed("bundle.js"));
+            srv->Get(params.api_prefix + R"(/_app/immutable/assets/bundle\.[^/]+\.css)", serve_hashed("bundle.css"));
+            srv->Get(params.api_prefix + R"(/workbox-[^/]+\.js)",                        serve_hashed("workbox.js"));
+
+            // SPA entry — also aliased at "/_app/version.json" (referenced by the service worker)
+            srv->Get(params.api_prefix + "/",                  serve_asset_cached ("index.html",   true));
+            srv->Get(params.api_prefix + "/_app/version.json", serve_asset_nocache("version.json"));
+
+            // All remaining assets registered directly from the embedded asset table.
+            // PWA revalidation files (sw.js, manifest, version.json) use no-cache;
+            // everything else is immutable.
+            static const std::unordered_set<std::string> no_cache_names = {
+                "sw.js",
+                "manifest.webmanifest",
+                "version.json",
+                "build.json"
+            };
+            // index.html also accessible at /index.html (with the same isolation headers as /)
+            srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", true));
 
-            // _app/ paths — vanilla SvelteKit output, index.html and sw.js reference
-            // bundles and version.json here directly.
-            srv->Get(params.api_prefix + R"(/_app/immutable/bundle\.[^/]+\.js)",  serve_bundle);
-            srv->Get(params.api_prefix + R"(/_app/immutable/assets/bundle\.[^/]+\.css)", serve_bundle);
-            srv->Get(params.api_prefix + "/_app/version.json",                    serve_asset_cached("_app/version.json", "application/json; charset=utf-8", false));
-
-            auto serve_workbox = [serve_asset_cached](const httplib::Request & req, httplib::Response & res) {
-                std::string name = req.path.substr(1);
-                if (name.rfind("workbox-", 0) == 0 && name.size() > 10) {
-                    return serve_asset_cached(name, "application/javascript; charset=utf-8", false)(req, res);
+            for (const auto & a : llama_ui_get_assets()) {
+                if (a.name == "index.html") continue;  // served at "/" and "/index.html" above
+                if (no_cache_names.count(a.name)) {
+                    srv->Get(params.api_prefix + "/" + a.name, serve_asset_nocache(a.name));
+                } else {
+                    srv->Get(params.api_prefix + "/" + a.name, serve_asset_cached(a.name, false));
                 }
-                res.status = 404;
-                return false;
-            };
-            srv->Get(params.api_prefix + R"(/workbox-[^/]+\.js)",               serve_workbox);
-            srv->Get(params.api_prefix + R"(/sw\.js)",                          serve_asset_cached("sw.js",               "application/javascript; charset=utf-8", false));
-            srv->Get(params.api_prefix + "/manifest.webmanifest",                serve_asset_cached("manifest.webmanifest", "application/manifest+json; charset=utf-8", false));
-            srv->Get(params.api_prefix + "/version.json",                        serve_asset_cached("_app/version.json",  "application/json; charset=utf-8",       false));
-            srv->Get(params.api_prefix + "/build.json",                          serve_asset_cached("build.json",         "application/json; charset=utf-8",       false));
-
-            // Finally serve index.html for all other routes (SPA fallback)
-            srv->Get(params.api_prefix + "/",                               serve_asset_cached("index.html",                   "text/html; charset=utf-8",                 true));
-            srv->Get(params.api_prefix + "/favicon.ico",                    serve_asset_cached("favicon.ico",                  "image/x-icon",                             false));
-            srv->Get(params.api_prefix + "/favicon-dark.ico",                serve_asset_cached("favicon-dark.ico",              "image/x-icon",                             false));
-            srv->Get(params.api_prefix + "/favicon.svg",                    serve_asset_cached("favicon.svg",                  "image/svg+xml",                            false));
-            srv->Get(params.api_prefix + "/favicon-dark.svg",              serve_asset_cached("favicon-dark.svg",            "image/svg+xml",                            false));
-            srv->Get(params.api_prefix + "/pwa-64x64.png",                  serve_asset_cached("pwa-64x64.png",                "image/png",                                false));
-            srv->Get(params.api_prefix + "/pwa-192x192.png",                serve_asset_cached("pwa-192x192.png",              "image/png",                                false));
-            srv->Get(params.api_prefix + "/pwa-512x512.png",                serve_asset_cached("pwa-512x512.png",              "image/png",                                false));
-            srv->Get(params.api_prefix + "/maskable-icon-512x512.png",      serve_asset_cached("maskable-icon-512x512.png",    "image/png",                                false));
-            srv->Get(params.api_prefix + "/apple-touch-icon-180x180.png",   serve_asset_cached("apple-touch-icon-180x180.png", "image/png",                                false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-640x1136.png",          serve_asset_cached("apple-splash-portrait-640x1136.png",          "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-1136x640.png",         serve_asset_cached("apple-splash-landscape-1136x640.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-750x1334.png",          serve_asset_cached("apple-splash-portrait-750x1334.png",          "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-1334x750.png",         serve_asset_cached("apple-splash-landscape-1334x750.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1170x2532.png",         serve_asset_cached("apple-splash-portrait-1170x2532.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2532x1170.png",        serve_asset_cached("apple-splash-landscape-2532x1170.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1179x2556.png",         serve_asset_cached("apple-splash-portrait-1179x2556.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2556x1179.png",        serve_asset_cached("apple-splash-landscape-2556x1179.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1206x2622.png",         serve_asset_cached("apple-splash-portrait-1206x2622.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2622x1206.png",        serve_asset_cached("apple-splash-landscape-2622x1206.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1284x2778.png",         serve_asset_cached("apple-splash-portrait-1284x2778.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2778x1284.png",        serve_asset_cached("apple-splash-landscape-2778x1284.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1290x2796.png",         serve_asset_cached("apple-splash-portrait-1290x2796.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2796x1290.png",        serve_asset_cached("apple-splash-landscape-2796x1290.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1320x2868.png",         serve_asset_cached("apple-splash-portrait-1320x2868.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2868x1320.png",        serve_asset_cached("apple-splash-landscape-2868x1320.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1488x2266.png",         serve_asset_cached("apple-splash-portrait-1488x2266.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2266x1488.png",        serve_asset_cached("apple-splash-landscape-2266x1488.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1640x2360.png",         serve_asset_cached("apple-splash-portrait-1640x2360.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2360x1640.png",        serve_asset_cached("apple-splash-landscape-2360x1640.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-1668x2388.png",         serve_asset_cached("apple-splash-portrait-1668x2388.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2388x1668.png",        serve_asset_cached("apple-splash-landscape-2388x1668.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-2048x2732.png",         serve_asset_cached("apple-splash-portrait-2048x2732.png",         "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-2732x2048.png",        serve_asset_cached("apple-splash-landscape-2732x2048.png",        "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-640x1136.png",     serve_asset_cached("apple-splash-portrait-dark-640x1136.png",     "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-1136x640.png",    serve_asset_cached("apple-splash-landscape-dark-1136x640.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-750x1334.png",     serve_asset_cached("apple-splash-portrait-dark-750x1334.png",     "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-1334x750.png",    serve_asset_cached("apple-splash-landscape-dark-1334x750.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1170x2532.png",    serve_asset_cached("apple-splash-portrait-dark-1170x2532.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2532x1170.png",   serve_asset_cached("apple-splash-landscape-dark-2532x1170.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1179x2556.png",    serve_asset_cached("apple-splash-portrait-dark-1179x2556.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2556x1179.png",   serve_asset_cached("apple-splash-landscape-dark-2556x1179.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1206x2622.png",    serve_asset_cached("apple-splash-portrait-dark-1206x2622.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2622x1206.png",   serve_asset_cached("apple-splash-landscape-dark-2622x1206.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1284x2778.png",    serve_asset_cached("apple-splash-portrait-dark-1284x2778.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2778x1284.png",   serve_asset_cached("apple-splash-landscape-dark-2778x1284.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1290x2796.png",    serve_asset_cached("apple-splash-portrait-dark-1290x2796.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2796x1290.png",   serve_asset_cached("apple-splash-landscape-dark-2796x1290.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1320x2868.png",    serve_asset_cached("apple-splash-portrait-dark-1320x2868.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2868x1320.png",   serve_asset_cached("apple-splash-landscape-dark-2868x1320.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1640x2360.png",    serve_asset_cached("apple-splash-portrait-dark-1640x2360.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2360x1640.png",   serve_asset_cached("apple-splash-landscape-dark-2360x1640.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-1668x2388.png",    serve_asset_cached("apple-splash-portrait-dark-1668x2388.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2388x1668.png",   serve_asset_cached("apple-splash-landscape-dark-2388x1668.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-portrait-dark-2048x2732.png",    serve_asset_cached("apple-splash-portrait-dark-2048x2732.png",    "image/png", false));
-            srv->Get(params.api_prefix + "/apple-splash-landscape-dark-2732x2048.png",   serve_asset_cached("apple-splash-landscape-dark-2732x2048.png",   "image/png", false));
-            srv->Get(params.api_prefix + "/manifest.webmanifest",           serve_asset_nocache("manifest.webmanifest",        "application/manifest+json",                false));
-            srv->Get(params.api_prefix + "/sw.js",                          serve_asset_nocache("sw.js",                       "application/javascript; charset=utf-8",    false));
-            srv->Get(params.api_prefix + "/version.json",                   serve_asset_nocache("version.json",                 "application/json",                         false));
+            }
 
 #endif
         }
index b17f85f3bec8f468639b20f6fdf1046e5ea9571f..abea132286197a7ae7024889f11ca05d33f1b496 100644 (file)
@@ -1,6 +1,6 @@
 set(TARGET llama-ui)
 
-set(LLAMA_UI_HF_BUCKET "llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt UI assets")
+set(LLAMA_UI_HF_BUCKET "ggml-org/llama-ui" CACHE STRING "Hugging Face bucket name for prebuilt UI assets")
 
 # Backward compat: forward old var to new one
 if(DEFINED LLAMA_BUILD_WEBUI)
index f603b8569c874ff358fed6458af7018ce64382b3..24617e05fc84ad4384695e8439375f59fc2ad4ad 100644 (file)
@@ -1,16 +1,42 @@
 // llama-ui-embed: generate ui.cpp / ui.h that embed UI assets as C arrays.
 //
 // Usage:
-//   llama-ui-embed <out_cpp> <out_h> [<asset_name> <asset_path>]...
+//   llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
+//
+// Embeds every regular file directly under <asset_dir> (non-recursive).
+// Without <asset_dir>, emits an empty asset table.
 
+#include <inttypes.h>
 #include <stdarg.h>
+#include <stdint.h>
 #include <stdio.h>
 #include <string.h>
+
+#include <algorithm>
+#include <filesystem>
 #include <fstream>
 #include <string>
 #include <vector>
-#include <cinttypes>
-#include <cstdint>
+
+
+static const char * mime_from_ext(const std::string & name) {
+    auto ext = name.rfind('.');
+    if (ext == std::string::npos) return "application/octet-stream";
+    std::string e = name.substr(ext + 1);
+    if (e == "html")        return "text/html; charset=utf-8";
+    if (e == "css")         return "text/css";
+    if (e == "js")          return "application/javascript";
+    if (e == "json")        return "application/json";
+    if (e == "webmanifest") return "application/manifest+json";
+    if (e == "svg")         return "image/svg+xml";
+    if (e == "png")         return "image/png";
+    if (e == "jpg" ||
+        e == "jpeg")        return "image/jpeg";
+    if (e == "ico")         return "image/x-icon";
+    if (e == "woff")        return "font/woff";
+    if (e == "woff2")       return "font/woff2";
+    return "application/octet-stream";
+}
 
 // Computes FNV-1a hash of the data
 static uint64_t fnv_hash(const uint8_t * data, size_t len) {
@@ -24,10 +50,10 @@ static uint64_t fnv_hash(const uint8_t * data, size_t len) {
     return hash;
 }
 
-static bool read_file(const std::string & path, std::vector<unsigned char> & out) {
+static bool read_file(const std::filesystem::path & path, std::vector<unsigned char> & out) {
     std::ifstream f(path, std::ios::binary | std::ios::ate);
     if (!f) {
-        fprintf(stderr, "embed: cannot open %s\n", path.c_str());
+        fprintf(stderr, "embed: cannot open %s\n", path.string().c_str());
         return false;
     }
     const auto sz = f.tellg();
@@ -89,70 +115,124 @@ static std::string fmt(const char * pattern, ...) {
     return (n > 0) ? std::string(tmp, static_cast<size_t>(n)) : std::string();
 }
 
+struct asset_entry {
+    std::string           name;
+    std::filesystem::path path;
+};
+
 int main(int argc, char ** argv) {
-    if (argc < 3 || ((argc - 3) % 2) != 0) {
-        fprintf(stderr, "usage: %s <out_cpp> <out_h> [<name> <path>]...\n", argv[0]);
+    if (argc < 3 || argc > 4) {
+        fprintf(stderr, "usage: %s <out_cpp> <out_h> [<asset_dir>]\n", argv[0]);
         return 1;
     }
 
     const std::string out_cpp = argv[1];
     const std::string out_h   = argv[2];
-    const int n_assets = (argc - 3) / 2;
+
+    std::vector<asset_entry> assets;
+    if (argc == 4) {
+        const std::filesystem::path dir = argv[3];
+
+        std::error_code ec;
+        std::filesystem::directory_iterator it(dir, ec);
+        if (ec) {
+            fprintf(stderr, "embed: cannot iterate %s: %s\n", argv[3], ec.message().c_str());
+            return 1;
+        }
+        for (const auto & entry : it) {
+            if (!entry.is_regular_file()) {
+                continue;
+            }
+            assets.push_back({ entry.path().filename().generic_string(), entry.path() });
+        }
+
+        // directory iteration order is unspecified; sort for reproducible output
+        std::sort(assets.begin(), assets.end(),
+                  [](const asset_entry & a, const asset_entry & b) { return a.name < b.name; });
+    }
+
+    const int n_assets = static_cast<int>(assets.size());
+
+    if (n_assets > 0) {
+        bool has_index = false, has_bundle_js = false, has_bundle_css = false, has_version = false;
+        for (const auto & a : assets) {
+            if (a.name == "index.html")   has_index      = true;
+            if (a.name == "bundle.js")    has_bundle_js  = true;
+            if (a.name == "bundle.css")   has_bundle_css = true;
+            if (a.name == "version.json") has_version    = true;
+        }
+        if (!has_index || !has_bundle_js || !has_bundle_css || !has_version) {
+            fprintf(stderr, "embed: missing required assets (need index.html, bundle.js, bundle.css, version.json); got:\n");
+            for (const auto & a : assets) {
+                fprintf(stderr, "  %s\n", a.name.c_str());
+            }
+            return 1;
+        }
+    }
 
     std::string h;
-    h += "#pragma once\n\n#include <stddef.h>\n\n";
+    h += "#pragma once\n\n#include <array>\n#include <string>\n\n";
     if (n_assets > 0) {
         h += "#define LLAMA_UI_HAS_ASSETS 1\n\n";
     }
     h +=
         "struct llama_ui_asset {\n"
-        "    const char *          name;\n"
+        "    std::string           name;\n"
         "    const unsigned char * data;\n"
-        "    size_t                size;\n"
-        "    const char *          etag;\n"
+        "    std::size_t           size;\n"
+        "    std::string           etag;\n"
+        "    std::string           type;\n"
         "};\n\n"
-        "const llama_ui_asset * llama_ui_find_asset(const char * name);\n";
+        "const llama_ui_asset * llama_ui_find_asset(const std::string & name);\n";
+    h += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets();\n", n_assets);
 
     std::string cpp;
-    cpp += "#include \"ui.h\"\n\n#include <string.h>\n\n";
+    cpp += "#include \"ui.h\"\n\n";
 
     if (n_assets > 0) {
         for (int i = 0; i < n_assets; i++) {
-            const char * path = argv[3 + i * 2 + 1];
             std::vector<unsigned char> bytes;
-            if (!read_file(path, bytes)) {
+            if (!read_file(assets[i].path, bytes)) {
                 return 1;
             }
             cpp += fmt("static const unsigned char asset_%d_data[] = {", i);
             append_bytes_hex(cpp, bytes);
             const auto hash = fnv_hash(bytes.data(), bytes.size());
 
-            cpp += fmt("};\nstatic const size_t        asset_%d_size = %zu;\n",
+            cpp += fmt("};\nstatic const std::size_t   asset_%d_size = %zu;\n",
                        i, bytes.size());
-            cpp += fmt("static const char        asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n",
+            cpp += fmt("static const char          asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n",
                        i, hash);
         }
 
-        cpp += "static const llama_ui_asset g_assets[] = {\n";
+        cpp += fmt("static const std::array<llama_ui_asset, %d> g_assets = {{\n", n_assets);
         for (int i = 0; i < n_assets; i++) {
-            cpp += fmt("    { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag },\n",
-                       argv[3 + i * 2], i, i, i);
+            const std::string & name = assets[i].name;
+            cpp += fmt("    { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag, \"%s\" },\n",
+                       name.c_str(), i, i, i, mime_from_ext(name));
         }
-        cpp += "};\n\n";
+        cpp += "}};\n\n";
 
         cpp +=
-            "const llama_ui_asset * llama_ui_find_asset(const char * name) {\n"
+            "const llama_ui_asset * llama_ui_find_asset(const std::string & name) {\n"
             "    for (const auto & a : g_assets) {\n"
-            "        if (strcmp(a.name, name) == 0) {\n"
+            "        if (a.name == name) {\n"
             "            return &a;\n"
             "        }\n"
             "    }\n"
             "    return nullptr;\n"
             "}\n";
+        cpp += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets() {\n", n_assets);
+        cpp +=     "    return g_assets;\n"
+                   "}\n";
     } else {
         cpp +=
-            "const llama_ui_asset * llama_ui_find_asset(const char *) {\n"
+            "const llama_ui_asset * llama_ui_find_asset(const std::string &) {\n"
             "    return nullptr;\n"
+            "}\n"
+            "const std::array<llama_ui_asset, 0> & llama_ui_get_assets() {\n"
+            "    static const std::array<llama_ui_asset, 0> empty{};\n"
+            "    return empty;\n"
             "}\n";
     }
 
index 6c750431fe6c8965250b3f9ccc8951384bbbe598..15dca7888692ee639050c7680e66c28c2897ffcf 100644 (file)
@@ -4,7 +4,7 @@
        "version": "1.0.0",
        "type": "module",
        "scripts": {
-               "build": "npm run build-pwa-assets && vite build",
+               "build": "npm run build-pwa-assets && vite build && node scripts/post-build.js",
                "build-pwa-assets": "npx @vite-pwa/assets-generator --root . --config pwa-assets.config.ts && npx @vite-pwa/assets-generator --root . --config pwa-assets-dark.config.ts && node scripts/make-icons-circular.js",
                "dev": "bash scripts/dev.sh",
                "preview": "vite preview",
diff --git a/tools/ui/scripts/post-build.js b/tools/ui/scripts/post-build.js
new file mode 100644 (file)
index 0000000..f2e72ce
--- /dev/null
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+// Post-build: copy hashed/nested assets to predictable flat names.
+// No file content is modified — the C++ server handles routing hashed URLs
+// to the correct stored asset at runtime.
+//
+// Copies:
+//   _app/immutable/bundle.HASH.js         -> bundle.js
+//   _app/immutable/assets/bundle.HASH.css -> bundle.css
+//   workbox-HEXHASH.js                    -> workbox.js
+//   _app/version.json                     -> version.json
+
+import fs from 'fs';
+import path from 'path';
+
+const outDir = process.env.LLAMA_UI_OUT_DIR ?? './dist';
+
+function findOne(dir, pattern) {
+       const files = fs.readdirSync(dir).filter((f) => pattern.test(f));
+       if (files.length === 0) throw new Error(`post-build: no file matching ${pattern} in ${dir}`);
+       return path.join(dir, files[0]);
+}
+
+function copyFlat(src, destName) {
+       const dest = path.join(outDir, destName);
+       fs.copyFileSync(src, dest);
+       console.log(`post-build: ${path.relative(outDir, src)} -> ${destName}`);
+}
+
+const bundleJs = findOne(path.join(outDir, '_app/immutable'), /^bundle\.[^.]+\.js$/);
+const bundleCss = findOne(path.join(outDir, '_app/immutable/assets'), /^bundle\.[^.]+\.css$/);
+const workbox = findOne(outDir, /^workbox-[0-9a-f]+\.js$/);
+
+copyFlat(bundleJs, 'bundle.js');
+copyFlat(bundleCss, 'bundle.css');
+copyFlat(workbox, 'workbox.js');
+
+const versionSrc = path.join(outDir, '_app/version.json');
+if (fs.existsSync(versionSrc)) {
+       copyFlat(versionSrc, 'version.json');
+}
index a2dc4acae903b0d61355a31bf888f20e2f68220a..972ba3b664c85586a1617191ee9bc9cdfd954a13 100644 (file)
@@ -23,8 +23,7 @@ export function buildInfoPlugin(): Plugin {
                                        if (processed) return;
                                        processed = true;
 
-                                       const buildNumber = process.env.LLAMA_BUILD_NUMBER;
-                                       if (!buildNumber) return;
+                                       const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000';
 
                                        const outDir = resolve(OUTPUT_DIR);
                                        const indexPath = resolve(outDir, 'index.html');