Compare commits

...

1 Commits

Author SHA1 Message Date
Xuan-Son Nguyen
597b6672e8 ui: keep original file name and path (#24568)
* ui: keep original file name and path

* fix nocache
2026-06-13 14:31:41 +02:00
5 changed files with 92 additions and 162 deletions

View File

@@ -17,45 +17,16 @@ 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:
# - tools/ui/src/lib/constants/pwa.ts (APPLE_DEVICES, PWA_MANIFEST)
#
# The HTTP server registers routes and public endpoints for every embedded asset.
set(REQUIRED_ASSETS
index.html
loading.html
manifest.webmanifest
sw.js
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")
set(SRC_DIST_DIR "${UI_SOURCE_DIR}/dist")
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 dir out_var)
set(present TRUE)
foreach(asset ${REQUIRED_ASSETS})
if(NOT EXISTS "${dir}/${asset}")
set(present FALSE)
break()
endif()
endforeach()
set(${out_var} ${present} PARENT_SCOPE)
endfunction()
function(npm_build_should_skip out_var)
set(${out_var} FALSE PARENT_SCOPE)
assets_present("${DIST_DIR}" present)
if(NOT present)
if(NOT EXISTS "${DIST_DIR}/index.html")
return()
endif()
@@ -162,8 +133,7 @@ function(npm_build out_var)
return()
endif()
assets_present("${DIST_DIR}" present)
if(NOT present)
if(NOT EXISTS "${DIST_DIR}/index.html")
message(STATUS "UI: npm build finished but assets missing in ${DIST_DIR}")
return()
endif()
@@ -242,8 +212,7 @@ function(hf_download version out_var out_resolved)
file(ARCHIVE_EXTRACT INPUT "${archive}" DESTINATION "${DIST_DIR}")
assets_present("${DIST_DIR}" present)
if(NOT present)
if(NOT EXISTS "${DIST_DIR}/index.html")
message(STATUS "UI: archive from ${resolved} is missing required assets")
continue()
endif()
@@ -256,11 +225,8 @@ function(hf_download version out_var out_resolved)
endfunction()
function(emit_files dist_dir)
assets_present("${dist_dir}" present)
set(args "${UI_CPP}" "${UI_H}")
if(present)
# llama-ui-embed embeds every top-level file in dist_dir
if(EXISTS "${dist_dir}/index.html")
list(APPEND args "${dist_dir}")
endif()
@@ -276,8 +242,7 @@ endfunction()
# ---------------------------------------------------------------------------
# 1. Priority 1: pre-built assets supplied in tools/ui/dist
# ---------------------------------------------------------------------------
assets_present("${SRC_DIST_DIR}" SRC_OK)
if(SRC_OK)
if(EXISTS "${SRC_DIST_DIR}/index.html")
message(STATUS "UI: using pre-built assets from ${SRC_DIST_DIR}")
emit_files("${SRC_DIST_DIR}")
return()
@@ -312,7 +277,10 @@ if(NOT provisioned AND HF_ENABLED)
endif()
endif()
assets_present("${DIST_DIR}" have_assets)
set(have_assets FALSE)
if(EXISTS "${DIST_DIR}/index.html")
set(have_assets TRUE)
endif()
if(stamp_ok AND have_assets)
message(STATUS "UI: HF stamp '${stamped}' matches version, skipping HF fetch")
set(provisioned TRUE)
@@ -332,8 +300,7 @@ endif()
# 4. Fallback: warn about stale or missing assets, then emit whatever we have
# ---------------------------------------------------------------------------
if(NOT provisioned)
assets_present("${DIST_DIR}" have_assets)
if(have_assets)
if(EXISTS "${DIST_DIR}/index.html")
message(WARNING "UI: provisioning failed; embedding stale assets from ${DIST_DIR}")
else()
message(WARNING "UI: no assets available - building without an embedded UI. "

View File

@@ -26,52 +26,6 @@ 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"
@@ -240,9 +194,8 @@ bool server_http_context::init(const common_params & params) {
return true;
}
// 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))) {
// If path is public or a UI asset, skip validation
if (get_public_endpoints.count(req.path)) {
return true;
}
@@ -399,17 +352,9 @@ bool server_http_context::init(const common_params & params) {
};
};
// 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"));
// main index file
srv->Get(params.api_prefix + "/", serve_asset_cached("index.html", true));
srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", true));
// All remaining assets registered directly from the embedded asset table.
// PWA revalidation files (sw.js, manifest, version.json) use no-cache;
@@ -417,15 +362,14 @@ bool server_http_context::init(const common_params & params) {
static const std::unordered_set<std::string> no_cache_names = {
"sw.js",
"manifest.webmanifest",
"version.json",
"_app/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));
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_DBG("serve nocache for %s\n", a.name.c_str());
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));

View File

@@ -3,7 +3,8 @@
// Usage:
// llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
//
// Embeds every regular file directly under <asset_dir> (non-recursive).
// 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>
@@ -15,6 +16,7 @@
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <vector>
@@ -103,7 +105,24 @@ static bool write_if_different(const std::string & path, const std::string & con
if (!content.empty()) {
out.write(content.data(), static_cast<std::streamsize>(content.size()));
}
return out.good();
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, ...) {
@@ -128,13 +147,14 @@ int main(int argc, char ** argv) {
const std::string out_cpp = argv[1];
const std::string out_h = argv[2];
const std::string in_dir = argv[3];
std::vector<asset_entry> assets;
if (argc == 4) {
const std::filesystem::path dir = argv[3];
const std::filesystem::path dir = in_dir;
std::error_code ec;
std::filesystem::directory_iterator it(dir, 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;
@@ -143,7 +163,9 @@ int main(int argc, char ** argv) {
if (!entry.is_regular_file()) {
continue;
}
assets.push_back({ entry.path().filename().generic_string(), entry.path() });
// 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
@@ -154,18 +176,51 @@ int main(int argc, char ** argv) {
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;
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 },
{ "loading.html", exact("loading.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) {
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());
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;
}
}
@@ -195,6 +250,10 @@ int main(int argc, char ** argv) {
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);
const auto hash = fnv_hash(bytes.data(), bytes.size());

View File

@@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "npm run build-pwa-assets && vite build && node scripts/post-build.js",
"build": "npm run build-pwa-assets && vite build",
"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",

View File

@@ -1,40 +0,0 @@
#!/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');
}