Commit c457e3bf7 for llama.cpp

commit c457e3bf7fa88a7ccfa31f1dbf358ac1ebd90e67
Author: Adrien Gallouët <angt@huggingface.co>
Date:   Sun Sep 6 07:49:39 2026 +0200

    ui : embed assets directly with CMake (#28445)

    Remove the build-time C++ helper and external gzip dependency,
    simplifying cross-compilation. Keep the generated C++ in templates for
    readability and preserve fully embedded UI assets.

    Signed-off-by: Adrien Gallouët <angt@huggingface.co>

diff --git a/scripts/ui-assets.cmake b/scripts/ui-assets.cmake
index 0c1c4de55..402f95bd4 100644
--- a/scripts/ui-assets.cmake
+++ b/scripts/ui-assets.cmake
@@ -15,7 +15,6 @@ set(HF_BUCKET         "" CACHE STRING "Hugging Face bucket name")
 set(HF_VERSION        "" CACHE STRING "Version to download (empty = resolve from git)")
 set(HF_ENABLED        "" CACHE STRING "Whether to allow HF Bucket download (ON/OFF)")
 set(BUILD_UI          "" CACHE STRING "Build UI via npm (ON/OFF)")
-set(LLAMA_UI_EMBED    "" CACHE STRING "Path to llama-ui-embed helper")
 set(LLAMA_UI_GZIP     "" CACHE STRING "Apply gzip compress to assets to save bandwidth")

 set(DIST_DIR     "${UI_BINARY_DIR}/dist")
@@ -25,6 +24,223 @@ 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(mime_from_ext name out_var)
+    string(FIND "${name}" "." ext REVERSE)
+    if(ext GREATER -1)
+        string(SUBSTRING "${name}" ${ext} -1 ext_full)
+        string(SUBSTRING "${ext_full}" 1 -1 ext_str)
+    else()
+        set(ext_str "")
+    endif()
+    if(ext_str STREQUAL "html")
+        set(m "text/html; charset=utf-8")
+    elseif(ext_str STREQUAL "css")
+        set(m "text/css")
+    elseif(ext_str STREQUAL "js")
+        set(m "application/javascript")
+    elseif(ext_str STREQUAL "json")
+        set(m "application/json")
+    elseif(ext_str STREQUAL "webmanifest")
+        set(m "application/manifest+json")
+    elseif(ext_str STREQUAL "svg")
+        set(m "image/svg+xml")
+    elseif(ext_str STREQUAL "png")
+        set(m "image/png")
+    elseif(ext_str STREQUAL "jpg" OR ext_str STREQUAL "jpeg")
+        set(m "image/jpeg")
+    elseif(ext_str STREQUAL "ico")
+        set(m "image/x-icon")
+    elseif(ext_str STREQUAL "woff")
+        set(m "font/woff")
+    elseif(ext_str STREQUAL "woff2")
+        set(m "font/woff2")
+    else()
+        set(m "application/octet-stream")
+    endif()
+    set(${out_var} "${m}" PARENT_SCOPE)
+endfunction()
+
+# Fail when a dist tree is present but is missing files the UI needs at
+# runtime; catches truncated/stale asset trees early with a useful message.
+function(ui_validate_assets files in_dir)
+    list(LENGTH files n_assets)
+    if(n_assets EQUAL 0)
+        return()
+    endif()
+
+    set(found_index FALSE)
+    set(found_manifest FALSE)
+    set(found_sw FALSE)
+    set(found_build_json FALSE)
+    set(found_version_json FALSE)
+    set(found_bundle_js FALSE)
+    set(found_bundle_css FALSE)
+    set(found_workbox_js FALSE)
+
+    foreach(f ${files})
+        get_filename_component(base "${f}" NAME)
+        if(base STREQUAL "index.html")
+            set(found_index TRUE)
+        elseif(base STREQUAL "manifest.webmanifest")
+            set(found_manifest TRUE)
+        elseif(base STREQUAL "sw.js")
+            set(found_sw TRUE)
+        elseif(base STREQUAL "build.json")
+            set(found_build_json TRUE)
+        elseif(base STREQUAL "version.json")
+            set(found_version_json TRUE)
+        elseif(base MATCHES "^bundle.*\\.js$")
+            set(found_bundle_js TRUE)
+        elseif(base MATCHES "^bundle.*\\.css$")
+            set(found_bundle_css TRUE)
+        elseif(base MATCHES "^workbox.*\\.js$")
+            set(found_workbox_js TRUE)
+        endif()
+    endforeach()
+
+    set(missing "")
+    if(NOT found_index)
+        list(APPEND missing "index.html")
+    endif()
+    if(NOT found_manifest)
+        list(APPEND missing "manifest.webmanifest")
+    endif()
+    if(NOT found_sw)
+        list(APPEND missing "sw.js")
+    endif()
+    if(NOT found_build_json)
+        list(APPEND missing "build.json")
+    endif()
+    if(NOT found_version_json)
+        list(APPEND missing "version.json")
+    endif()
+    if(NOT found_bundle_js)
+        list(APPEND missing "bundle[hash].js")
+    endif()
+    if(NOT found_bundle_css)
+        list(APPEND missing "bundle[hash].css")
+    endif()
+    if(NOT found_workbox_js)
+        list(APPEND missing "workbox[hash].js")
+    endif()
+
+    if(missing)
+        set(listing "")
+        foreach(f ${files})
+            string(APPEND listing "    ${f}\n")
+        endforeach()
+        set(missing_list "")
+        foreach(m ${missing})
+            string(APPEND missing_list "    ${m}\n")
+        endforeach()
+        message(FATAL_ERROR
+            "UI: current asset files:\n${listing}"
+            "UI: missing required asset(s):\n${missing_list}"
+            "UI: hint: try cleaning your build directory: ${in_dir}")
+    endif()
+endfunction()
+
+# Generate ui.cpp/ui.h embedding every file of ${dist_dir} (empty table when
+# it has no index.html). When LLAMA_UI_GZIP is enabled, assets are compressed
+# first and served pre-gzipped (llama_ui_use_gzip()).
+function(emit_files dist_dir)
+    set(embed_dir "${dist_dir}")
+    set(use_gzip FALSE)
+
+    if(EXISTS "${dist_dir}/index.html")
+        if(EXISTS "${dist_dir}/_gzip")
+            # a _gzip tree inside dist_dir can only be a leftover from an
+            # older version of this script that staged it there
+            file(REMOVE_RECURSE "${dist_dir}/_gzip")
+            message(STATUS "UI: removed stale gzip tree ${dist_dir}/_gzip")
+        endif()
+        if(LLAMA_UI_GZIP)
+            # Compress every asset into a parallel _gzip/ tree under the build
+            # directory (never write into the source or dist tree); the
+            # structure stays the same: /abc/def --> /_gzip/abc/def.
+            # FORMAT raw produces a bare gzip stream (no archive container)
+            # that can be served with Content-Encoding: gzip. SOURCE_DATE_EPOCH
+            # zeroes the header timestamp so identical inputs give identical
+            # bytes (and therefore stable ETags) on every machine.
+            if(NOT DEFINED ENV{SOURCE_DATE_EPOCH})
+                set(ENV{SOURCE_DATE_EPOCH} 0)
+            endif()
+            set(gzip_root "${UI_BINARY_DIR}/ui-gzip")
+            set(gzip_dir  "${gzip_root}/_gzip")
+            file(REMOVE_RECURSE "${gzip_root}")
+            file(GLOB_RECURSE all_files RELATIVE "${dist_dir}" "${dist_dir}/*")
+            list(FILTER all_files EXCLUDE REGEX "^_gzip/")
+            foreach(f ${all_files})
+                get_filename_component(asset_path "${dist_dir}/${f}" REALPATH)
+                get_filename_component(dst_dir "${gzip_dir}/${f}" DIRECTORY)
+                file(MAKE_DIRECTORY "${dst_dir}")
+                file(ARCHIVE_CREATE
+                    OUTPUT "${gzip_dir}/${f}"
+                    PATHS "${asset_path}"
+                    FORMAT raw
+                    COMPRESSION GZip
+                )
+            endforeach()
+            message(STATUS "UI: gzip compression applied (${gzip_dir})")
+            set(embed_dir "${gzip_dir}")
+            set(use_gzip TRUE)
+        endif()
+    endif()
+
+    set(assets "")
+    if(EXISTS "${embed_dir}/index.html")
+        file(GLOB_RECURSE assets RELATIVE "${embed_dir}" "${embed_dir}/*")
+        list(FILTER assets EXCLUDE REGEX "^_gzip/")
+        list(SORT assets)
+        ui_validate_assets("${assets}" "${embed_dir}")
+    endif()
+
+    list(LENGTH assets n_assets)
+
+    # Only the per-asset data arrays and table rows are built here; all
+    # static C++ lives in the ui.h.in / ui.cpp.in templates. configure_file
+    # rewrites an output only when its contents change, so the library is
+    # not recompiled needlessly. @ONLY keeps ${...} in the content literal;
+    # mime types come from a fixed list.
+    set(ASSET_ARRAYS "")
+    set(ASSET_TABLE "")
+    set(idx 0)
+
+    foreach(f IN LISTS assets)
+        file(READ "${embed_dir}/${f}" hex HEX)
+        if(hex STREQUAL "")
+            message(FATAL_ERROR "UI: empty file: ${embed_dir}/${f}")
+        endif()
+
+        string(REGEX REPLACE "(..)" "0x\\1," bytes "${hex}")
+        file(SHA256 "${embed_dir}/${f}" etag)
+        mime_from_ext("${f}" mime)
+
+        string(APPEND ASSET_ARRAYS
+            "static const unsigned char asset_${idx}[] = {${bytes}};\n")
+
+        string(APPEND ASSET_TABLE
+            "    { \"${f}\", asset_${idx}, sizeof(asset_${idx}), \"\\\"${etag}\\\"\", \"${mime}\" },\n")
+
+        math(EXPR idx "${idx} + 1")
+    endforeach()
+
+    set(LLAMA_UI_HAS_ASSETS 0)
+    if(n_assets GREATER 0)
+        set(LLAMA_UI_HAS_ASSETS 1)
+    endif()
+    set(N_ASSETS "${n_assets}")
+    set(USE_GZIP false)
+    if(use_gzip)
+        set(USE_GZIP true)
+    endif()
+
+    set(UI_TEMPLATE_DIR "${LLAMA_SOURCE_DIR}/tools/ui")
+    configure_file("${UI_TEMPLATE_DIR}/ui.h.in"   "${UI_H}"   @ONLY)
+    configure_file("${UI_TEMPLATE_DIR}/ui.cpp.in" "${UI_CPP}" @ONLY)
+    message(STATUS "UI: embedded ${n_assets} assets")
+endfunction()
+
 function(npm_build_should_skip out_var)
     set(${out_var} FALSE PARENT_SCOPE)

@@ -250,48 +466,6 @@ function(hf_download version out_var out_resolved)
     endforeach()
 endfunction()

-function(emit_files dist_dir)
-    # If gzip is requested, compress every asset into a parallel _gzip/ tree
-    # the structure stays the same; for ex: /abc/def --> /_gzip/abc/def
-    # embed.cpp will check for _gzip and will pick it up
-    if(LLAMA_UI_GZIP AND EXISTS "${dist_dir}/index.html")
-        find_program(GZIP_EXECUTABLE gzip)
-        if(NOT GZIP_EXECUTABLE)
-            message(WARNING "UI: LLAMA_UI_GZIP requested but gzip not found, embedding uncompressed")
-        else()
-            set(gzip_dir "${dist_dir}/_gzip")
-            file(REMOVE_RECURSE "${gzip_dir}")
-            file(GLOB_RECURSE all_files RELATIVE "${dist_dir}" "${dist_dir}/*")
-            foreach(f ${all_files})
-                get_filename_component(dst_dir "${gzip_dir}/${f}" DIRECTORY)
-                file(MAKE_DIRECTORY "${dst_dir}")
-                execute_process(
-                    COMMAND "${GZIP_EXECUTABLE}" -c "${dist_dir}/${f}"
-                    OUTPUT_FILE "${gzip_dir}/${f}"
-                    RESULT_VARIABLE gz_rc
-                )
-                if(NOT gz_rc EQUAL 0)
-                    message(FATAL_ERROR "UI: gzip failed for ${f}")
-                endif()
-            endforeach()
-            message(STATUS "UI: gzip compression applied (${gzip_dir})")
-        endif()
-    endif()
-
-    set(args "${UI_CPP}" "${UI_H}")
-    if(EXISTS "${dist_dir}/index.html")
-        list(APPEND args "${dist_dir}")
-    endif()
-
-    execute_process(
-        COMMAND "${LLAMA_UI_EMBED}" ${args}
-        RESULT_VARIABLE rc
-    )
-    if(NOT rc EQUAL 0)
-        message(FATAL_ERROR "UI: llama-ui-embed failed (${rc})")
-    endif()
-endfunction()
-
 # ---------------------------------------------------------------------------
 # 1. Priority 1: pre-built assets supplied in tools/ui/dist
 # ---------------------------------------------------------------------------
diff --git a/tools/server/CMakeLists.txt b/tools/server/CMakeLists.txt
index 280bd9e19..43c245633 100644
--- a/tools/server/CMakeLists.txt
+++ b/tools/server/CMakeLists.txt
@@ -50,6 +50,8 @@ target_include_directories(${TARGET} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
 target_include_directories(${TARGET} PRIVATE ../mtmd ${CMAKE_SOURCE_DIR})
 target_link_libraries(${TARGET} PUBLIC server-context llama-ui cpp-httplib ${CMAKE_THREAD_LIBS_INIT})

+add_dependencies(${TARGET} llama-ui-assets)
+
 if(LLAMA_TOOLS_INSTALL)
     install(TARGETS ${TARGET} LIBRARY)
 endif()
diff --git a/tools/ui/CMakeLists.txt b/tools/ui/CMakeLists.txt
index 208b46a5c..79ffe9fc1 100644
--- a/tools/ui/CMakeLists.txt
+++ b/tools/ui/CMakeLists.txt
@@ -36,60 +36,11 @@ endif()
 set(UI_CPP "${CMAKE_CURRENT_BINARY_DIR}/ui.cpp")
 set(UI_H   "${CMAKE_CURRENT_BINARY_DIR}/ui.h")

-if(CMAKE_CROSSCOMPILING)
-    find_program(HOST_CXX_COMPILER NAMES g++ clang++ NO_CMAKE_FIND_ROOT_PATH)
-    if(NOT HOST_CXX_COMPILER)
-        message(FATAL_ERROR "UI: no host C++ compiler (g++/clang++) found to build llama-ui-embed; set -DHOST_CXX_COMPILER=<path>")
-    endif()
-    message(STATUS "UI: building llama-ui-embed with host compiler ${HOST_CXX_COMPILER}")
-
-    if(CMAKE_HOST_WIN32)
-        set(LLAMA_UI_EMBED_EXE "${CMAKE_CURRENT_BINARY_DIR}/llama-ui-embed-host.exe")
-    else()
-        set(LLAMA_UI_EMBED_EXE "${CMAKE_CURRENT_BINARY_DIR}/llama-ui-embed-host")
-    endif()
-
-    add_custom_command(
-        OUTPUT  "${LLAMA_UI_EMBED_EXE}"
-        COMMAND "${HOST_CXX_COMPILER}" -O2 -std=c++17
-                -o "${LLAMA_UI_EMBED_EXE}" "${CMAKE_CURRENT_SOURCE_DIR}/embed.cpp"
-        DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/embed.cpp"
-        COMMENT "Building llama-ui-embed (host)"
-        VERBATIM
-    )
-
-    # phony target to tie it into the dependency graph
-    add_custom_target(llama-ui-embed DEPENDS "${LLAMA_UI_EMBED_EXE}")
-else()
-    # exclude llama-ui-embed from sanitizer flags,
-    # it's a build-time-only tool, no need to instrument it
-    # this is to fix TSan "memory layout is incompatible" error on CI
-    get_directory_property(_llama_ui_dir_co COMPILE_OPTIONS)
-    get_directory_property(_llama_ui_dir_ll LINK_LIBRARIES)
-    set(_llama_ui_embed_co ${_llama_ui_dir_co})
-    set(_llama_ui_embed_ll ${_llama_ui_dir_ll})
-    list(FILTER _llama_ui_embed_co EXCLUDE REGEX ".*-fsanitize=.*")
-    list(FILTER _llama_ui_embed_ll EXCLUDE REGEX ".*-fsanitize=.*")
-    set_directory_properties(PROPERTIES
-        COMPILE_OPTIONS "${_llama_ui_embed_co}"
-        LINK_LIBRARIES  "${_llama_ui_embed_ll}")
-
-    add_executable(llama-ui-embed embed.cpp)
-    target_compile_features(llama-ui-embed PRIVATE cxx_std_17)
-    set_target_properties(llama-ui-embed PROPERTIES
-        RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
-    )
-    set(LLAMA_UI_EMBED_EXE "$<TARGET_FILE:llama-ui-embed>")
-
-    # restore so the llama-ui library below keeps sanitizer instrumentation
-    set_directory_properties(PROPERTIES
-        COMPILE_OPTIONS "${_llama_ui_dir_co}"
-        LINK_LIBRARIES  "${_llama_ui_dir_ll}")
-endif()
-
-# Run the provisioning script every build so source changes in tools/ui/ are
-# always picked up. The script uses copy_if_different for ui.cpp/ui.h, so the
-# library only recompiles when contents actually change.
+# Provision assets and generate ui.cpp/ui.h natively in CMake at build time.
+# The generated sources are compiled by the regular target toolchain; no
+# build-time host executable is needed (works in any cross-compile setup).
+# The script uses copy_if_different semantics, so the library below only
+# recompiles when the generated contents actually change.
 add_custom_target(llama-ui-assets ALL
     BYPRODUCTS ${UI_CPP} ${UI_H}
     COMMAND ${CMAKE_COMMAND}
@@ -101,15 +52,12 @@ add_custom_target(llama-ui-assets ALL
         "-DHF_VERSION=${HF_UI_VERSION}"
         "-DHF_ENABLED=${LLAMA_USE_PREBUILT_UI}"
         "-DBUILD_UI=${LLAMA_BUILD_UI}"
-        "-DLLAMA_UI_EMBED=${LLAMA_UI_EMBED_EXE}"
         "-DLLAMA_UI_GZIP=${LLAMA_UI_GZIP}"
         -P "${PROJECT_SOURCE_DIR}/scripts/ui-assets.cmake"
     COMMENT "Provisioning UI assets"
     VERBATIM
 )

-add_dependencies(llama-ui-assets llama-ui-embed)
-
 set_source_files_properties(${UI_CPP} ${UI_H} PROPERTIES GENERATED TRUE)

 add_library(${TARGET} STATIC ${UI_CPP} ${UI_H})
diff --git a/tools/ui/embed.cpp b/tools/ui/embed.cpp
deleted file mode 100644
index b76c9047f..000000000
--- a/tools/ui/embed.cpp
+++ /dev/null
@@ -1,308 +0,0 @@
-// llama-ui-embed: generate ui.cpp / ui.h that embed UI assets as C arrays.
-//
-// Usage:
-//   llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
-//
-// Recursively embeds every regular file under <asset_dir>.
-// Asset names are relative paths from <asset_dir> (e.g. "_app/immutable/bundle.HASH.js").
-// 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 <functional>
-#include <string>
-#include <vector>
-
-
-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) {
-    const uint64_t fnv_prime = 0x100000001b3ULL;
-    uint64_t hash = 0xcbf29ce484222325ULL;
-
-    for (size_t i = 0; i < len; ++i) {
-        hash ^= data[i];
-        hash *= fnv_prime;
-    }
-    return hash;
-}
-
-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.string().c_str());
-        return false;
-    }
-    const auto sz = f.tellg();
-    if (sz < 0) {
-        return false;
-    }
-    f.seekg(0);
-    out.resize(static_cast<size_t>(sz));
-    if (sz > 0 && !f.read(reinterpret_cast<char *>(out.data()), sz)) {
-        return false;
-    }
-    return true;
-}
-
-static void append_bytes_hex(std::string & out, const std::vector<unsigned char> & bytes) {
-    static const char hex[] = "0123456789abcdef";
-    out.reserve(out.size() + bytes.size() * 5);
-    for (unsigned char b : bytes) {
-        out += '0';
-        out += 'x';
-        out += hex[b >> 4];
-        out += hex[b & 0xf];
-        out += ',';
-    }
-}
-
-static bool write_if_different(const std::string & path, const std::string & content) {
-    std::ifstream f(path, std::ios::binary | std::ios::ate);
-    if (f) {
-        const auto sz = f.tellg();
-        if (sz >= 0 && static_cast<size_t>(sz) == content.size()) {
-            std::string existing(static_cast<size_t>(sz), '\0');
-            f.seekg(0);
-            if (sz == 0 || f.read(existing.data(), sz)) {
-                if (existing == content) {
-                    return true;
-                }
-            }
-        }
-    }
-
-    std::ofstream out(path, std::ios::binary | std::ios::trunc);
-    if (!out) {
-        fprintf(stderr, "embed: cannot write %s\n", path.c_str());
-        return false;
-    }
-    if (!content.empty()) {
-        out.write(content.data(), static_cast<std::streamsize>(content.size()));
-    }
-    bool ok = out.good();
-    if (ok) {
-        printf("embed: write output file %s\n", path.c_str());
-    }
-    return ok;
-}
-
-static std::string path_basename(const std::string & name) {
-    const size_t p = name.rfind('/');
-    return p == std::string::npos ? name : name.substr(p + 1);
-}
-static bool str_starts_with(const std::string & s, const char * prefix) {
-    const size_t n = strlen(prefix);
-    return s.size() >= n && s.compare(0, n, prefix) == 0;
-}
-static bool str_ends_with(const std::string & s, const char * suffix) {
-    const size_t n = strlen(suffix);
-    return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
-}
-
-static std::string fmt(const char * pattern, ...) {
-    char tmp[512];
-    va_list ap;
-    va_start(ap, pattern);
-    const int n = vsnprintf(tmp, sizeof(tmp), pattern, ap);
-    va_end(ap);
-    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 > 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 std::string asset_dir = (argc >= 4) ? argv[3] : std::string();
-
-    const bool        use_gzip = !asset_dir.empty() && std::filesystem::exists(asset_dir + "/_gzip");
-    const std::string in_dir   = use_gzip ? (asset_dir + "/_gzip") : asset_dir;
-
-    std::vector<asset_entry> assets;
-    if (!in_dir.empty()) {
-        const std::filesystem::path dir = in_dir;
-
-        std::error_code ec;
-        std::filesystem::recursive_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;
-            }
-            // name is the relative path from dir, with forward slashes
-            const std::string name = entry.path().lexically_relative(dir).generic_string();
-            assets.push_back({ name, 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) {
-        using match_fn = std::function<bool(const std::string &)>;
-        auto exact = [](const char * name) -> match_fn {
-            return [name](const std::string & base) { return base == name; };
-        };
-
-        struct required_check { const char * label; match_fn match; bool found; };
-        required_check checks[] = {
-            { "index.html",           exact("index.html"),           false },
-            { "manifest.webmanifest", exact("manifest.webmanifest"), false },
-            { "sw.js",                exact("sw.js"),                false },
-            { "build.json",           exact("build.json"),           false },
-            { "version.json",         exact("version.json"),         false },
-            { "bundle[hash].js",      [](const std::string & b) {
-                return str_starts_with(b, "bundle")  && str_ends_with(b, ".js");
-            }, false },
-            { "bundle[hash].css",     [](const std::string & b) {
-                return str_starts_with(b, "bundle")  && str_ends_with(b, ".css");
-            }, false },
-            { "workbox[hash].js",     [](const std::string & b) {
-                return str_starts_with(b, "workbox") && str_ends_with(b, ".js");
-            }, false },
-        };
-
-        for (const auto & a : assets) {
-            const std::string base = path_basename(a.name);
-            for (auto & c : checks) {
-                if (!c.found) { c.found = c.match(base); }
-            }
-        }
-
-        std::vector<const char *> missing;
-        for (const auto & c : checks) {
-            if (!c.found) { missing.push_back(c.label); }
-        }
-        if (!missing.empty()) {
-            fprintf(stderr, "\ncurrent asset files:\n");
-            for (const auto & a : assets) {
-                fprintf(stderr, "    %s\n", a.name.c_str());
-            }
-            fprintf(stderr, "missing required asset(s):\n");
-            for (const char * m : missing) {
-                fprintf(stderr, "    %s\n", m);
-            }
-            fprintf(stderr, "hint: try cleaning your build directory: %s\n", in_dir.c_str());
-            return 1;
-        }
-    }
-
-    std::string h;
-    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"
-        "    std::string           name;\n"
-        "    const unsigned char * data;\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 std::string & name);\n"
-        "bool llama_ui_use_gzip();\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";
-
-    if (n_assets > 0) {
-        for (int i = 0; i < n_assets; i++) {
-            std::vector<unsigned char> bytes;
-            if (!read_file(assets[i].path, bytes)) {
-                return 1;
-            }
-            if (bytes.empty()) {
-                fprintf(stderr, "embed: empty file: %s\n", assets[i].path.generic_string().c_str());
-                return 1;
-            }
-            cpp += fmt("static const unsigned char asset_%d_data[] = {", i);
-            append_bytes_hex(cpp, bytes);
-
-            // note: this is a simple hash for cache busting, not a cryptographic hash; fnv is enough here
-            const auto hash = fnv_hash(bytes.data(), bytes.size());
-
-            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",
-                       i, hash);
-        }
-
-        cpp += fmt("static const std::array<llama_ui_asset, %d> g_assets = {{\n", n_assets);
-        for (int i = 0; i < n_assets; 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 +=
-            "const llama_ui_asset * llama_ui_find_asset(const std::string & name) {\n"
-            "    for (const auto & a : g_assets) {\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 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";
-    }
-    cpp += fmt("bool llama_ui_use_gzip() { return %s; }\n", use_gzip ? "true" : "false");
-
-    bool ok = true;
-    ok = write_if_different(out_h,   h)   && ok;
-    ok = write_if_different(out_cpp, cpp) && ok;
-    return ok ? 0 : 1;
-}
diff --git a/tools/ui/ui.cpp.in b/tools/ui/ui.cpp.in
new file mode 100644
index 000000000..7f91ef2a2
--- /dev/null
+++ b/tools/ui/ui.cpp.in
@@ -0,0 +1,36 @@
+// Generated by scripts/ui-assets.cmake - do not edit.
+
+#include "ui.h"
+
+@ASSET_ARRAYS@
+#if defined(LLAMA_UI_HAS_ASSETS)
+static const std::array<llama_ui_asset, @N_ASSETS@> g_assets = {{
+@ASSET_TABLE@
+}};
+#endif
+
+const llama_ui_asset * llama_ui_find_asset(const std::string & name) {
+#if defined(LLAMA_UI_HAS_ASSETS)
+    for (const auto & a : g_assets) {
+        if (a.name == name) {
+            return &a;
+        }
+    }
+#else
+    (void) name;
+#endif
+    return nullptr;
+}
+
+const std::array<llama_ui_asset, @N_ASSETS@> & llama_ui_get_assets() {
+#if defined(LLAMA_UI_HAS_ASSETS)
+    return g_assets;
+#else
+    static const std::array<llama_ui_asset, 0> empty{};
+    return empty;
+#endif
+}
+
+bool llama_ui_use_gzip() {
+    return @USE_GZIP@;
+}
diff --git a/tools/ui/ui.h.in b/tools/ui/ui.h.in
new file mode 100644
index 000000000..4555b0dd5
--- /dev/null
+++ b/tools/ui/ui.h.in
@@ -0,0 +1,21 @@
+// Generated by scripts/ui-assets.cmake - do not edit.
+
+#pragma once
+
+#include <array>
+#include <string>
+
+// Defined as 1 only when assets were embedded (tools/server checks defined()).
+#cmakedefine LLAMA_UI_HAS_ASSETS 1
+
+struct llama_ui_asset {
+    std::string           name;
+    const unsigned char * data;
+    std::size_t           size;
+    std::string           etag;
+    std::string           type;
+};
+
+const llama_ui_asset * llama_ui_find_asset(const std::string & name);
+bool llama_ui_use_gzip();
+const std::array<llama_ui_asset, @N_ASSETS@> & llama_ui_get_assets();