From e62c1cee8a0b1d7e08270ed39ade1dcd3a43d10a Mon Sep 17 00:00:00 2001 From: mirek190 Date: Tue, 4 Aug 2026 08:29:51 +0100 Subject: [PATCH 1/4] Embed initial native Svelte WebUI in audiocpp_server Build the SvelteKit/TypeScript frontend as one static HTML artifact and convert it to a generated C++ byte array at CMake configure time. audiocpp_server serves that compiled asset at /, so normal UI and inference use require only the server binary and its selected backend libraries; Node.js is a frontend build dependency, not a runtime dependency, and Gradio is not involved. Cover TTS and voice cloning, ASR and near-live transcription, music generation, voice conversion, source separation, audio analysis, and voice design. Add reusable browser audio handling, long-text splitting and merge, microphone capture, an IndexedDB voice library, model catalog and settings, generic task results, timing, and multiple-output playback. Add --ui, --no-ui, and --ui-management server controls plus guarded APIs for dynamic model load/unload, path inspection, uploads, and asynchronous package installation. Pure UI inference remains Python-free; optional model download or legacy conversion delegates to the packaged Python model-manager helpers when requested. Teach portable Windows packaging to include the optional model-management resources, document native and legacy WebUI operation, and add focused configuration and installer tests. Verified Svelte checks/build, CPU server build/tests, and embedded UI smoke tests on CPU and CUDA. --- .gitignore | 4 + CMakeLists.txt | 30 + README.md | 19 +- app/server/audiocpp_ui_asset.h.in | 12 + app/server/config.cpp | 9 +- app/server/config.h | 2 + app/server/http.cpp | 6 + app/server/main.cpp | 34 +- app/server/model_installer.cpp | 314 ++++ app/server/model_installer.h | 35 + app/server/runtime.cpp | 338 +++- app/server/runtime.h | 19 +- app/server/ui_assets.cpp | 14 + app/server/ui_assets.h | 9 + scripts/package_windows_prebuilt.ps1 | 43 + tests/unittests/test_server_config.cpp | 37 + .../unittests/test_server_model_installer.cpp | 78 + webui/README.md | 75 +- webui/native/dist/index.html | 115 ++ webui/native/package-lock.json | 1673 +++++++++++++++++ webui/native/package.json | 20 + webui/native/src/app.css | 181 ++ webui/native/src/app.html | 13 + webui/native/src/lib/api.ts | 132 ++ webui/native/src/lib/audio.ts | 88 + webui/native/src/lib/catalog.ts | 26 + webui/native/src/lib/text.ts | 61 + webui/native/src/lib/types.ts | 57 + webui/native/src/lib/voices.ts | 53 + webui/native/src/routes/+page.svelte | 965 ++++++++++ webui/native/src/routes/+page.ts | 2 + webui/native/svelte.config.js | 21 + webui/native/tsconfig.json | 14 + webui/native/vite.config.ts | 15 + 34 files changed, 4481 insertions(+), 33 deletions(-) create mode 100644 app/server/audiocpp_ui_asset.h.in create mode 100644 app/server/model_installer.cpp create mode 100644 app/server/model_installer.h create mode 100644 app/server/ui_assets.cpp create mode 100644 app/server/ui_assets.h create mode 100644 tests/unittests/test_server_model_installer.cpp create mode 100644 webui/native/dist/index.html create mode 100644 webui/native/package-lock.json create mode 100644 webui/native/package.json create mode 100644 webui/native/src/app.css create mode 100644 webui/native/src/app.html create mode 100644 webui/native/src/lib/api.ts create mode 100644 webui/native/src/lib/audio.ts create mode 100644 webui/native/src/lib/catalog.ts create mode 100644 webui/native/src/lib/text.ts create mode 100644 webui/native/src/lib/types.ts create mode 100644 webui/native/src/lib/voices.ts create mode 100644 webui/native/src/routes/+page.svelte create mode 100644 webui/native/src/routes/+page.ts create mode 100644 webui/native/svelte.config.js create mode 100644 webui/native/tsconfig.json create mode 100644 webui/native/vite.config.ts diff --git a/.gitignore b/.gitignore index 803a7bd4..1d575eca 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ __pycache__/ !/webui/logs/.gitkeep /webui/third_party/ /webui/llm_api_key.txt +/webui/native/node_modules/ +/webui/native/.svelte-kit/ +/webui/native/dist/* +!/webui/native/dist/index.html # written by the in-UI language picker; per-machine, not a project setting /webui/configs/ui_language.json # personal voice recording — stays local, repo is public diff --git a/CMakeLists.txt b/CMakeLists.txt index d26be649..7e9f0bac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1229,12 +1229,29 @@ if (ENGINE_ENABLE_OPENMP) target_link_libraries(audiocpp_cli PRIVATE OpenMP::OpenMP_CXX) endif() +set(AUDIOCPP_UI_DIST "${CMAKE_CURRENT_SOURCE_DIR}/webui/native/dist/index.html") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${AUDIOCPP_UI_DIST}") +if (EXISTS "${AUDIOCPP_UI_DIST}") + file(READ "${AUDIOCPP_UI_DIST}" AUDIOCPP_UI_HEX HEX) +else() + string(HEX "audio.cpp

Embedded WebUI assets are unavailable.

" AUDIOCPP_UI_HEX) +endif() +string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," AUDIOCPP_UI_BYTES "${AUDIOCPP_UI_HEX}") +string(REPLACE "," ",\n" AUDIOCPP_UI_BYTES "${AUDIOCPP_UI_BYTES}") +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/app/server/audiocpp_ui_asset.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/generated/audiocpp_ui_asset.h" + @ONLY +) + add_executable(audiocpp_server app/server/main.cpp app/server/config.cpp app/server/http.cpp + app/server/model_installer.cpp app/server/multipart.cpp app/server/runtime.cpp + app/server/ui_assets.cpp app/cli/args.cpp app/cli/request.cpp app/streaming/pcm_source.cpp @@ -1242,6 +1259,7 @@ add_executable(audiocpp_server ) target_link_libraries(audiocpp_server PRIVATE engine_runtime ggml) +target_include_directories(audiocpp_server PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") if (WIN32) target_link_libraries(audiocpp_server PRIVATE ws2_32) endif() @@ -2028,6 +2046,18 @@ if (ENGINE_BUILD_TESTS) if (ENGINE_ENABLE_OPENMP) target_link_libraries(parakeet_parity_dump PRIVATE OpenMP::OpenMP_CXX) endif() + + add_executable(server_model_installer_test + tests/unittests/test_server_model_installer.cpp + app/server/model_installer.cpp + ) + target_include_directories(server_model_installer_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/app/server) + target_link_libraries(server_model_installer_test PRIVATE Threads::Threads) + + add_test( + NAME server_model_installer_test + COMMAND server_model_installer_test + ) endif() if (ENGINE_BUILD_EXAMPLES) diff --git a/README.md b/README.md index 9237a334..dd4b9666 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,24 @@ package notes. ## WebUI ![Maintained by contributors](https://img.shields.io/badge/maintained%20by-contributors-brightgreen) -audio.cpp includes a Gradio WebUI for trying local models from the browser, managing downloads, and running common TTS/ASR/audio workflows without writing CLI commands. +`audiocpp_server` includes an embedded SvelteKit/TypeScript WebUI for running local TTS, cloning, ASR, +generation, conversion, separation, VAD, diarization, and alignment workflows. The production UI is compiled +into the server binary, so using it requires neither Python nor separate frontend files: -The WebUI lives in [webui/](webui/). See [webui/README.md](webui/README.md) for setup, launch commands, and model-download notes. +```bash +audiocpp_server --ui --backend cuda +``` + +Open `http://127.0.0.1:8080`. Starting with `--ui` and no server config enables on-demand model +load/unload and temporary browser uploads. Existing static server configurations also expose the UI by default; +add `--ui-management` when that instance should permit model switching. + +The native UI also exposes background model download/preparation, long-text split-and-merge synthesis, a +browser-local saved voice library, microphone recording, and near-live ASR input. Some model preparation jobs invoke +the repository's Python model manager because those packages require Hugging Face download or checkpoint conversion; +model inference and the embedded UI remain Python-free. The previous Python/Gradio interface remains available for +compatibility. See [webui/README.md](webui/README.md) for native and legacy launch commands, model notes, and frontend +development instructions. Huge thanks to [@kigner](https://github.com/kigner) for the original [audio.cpp-webui](https://github.com/kigner/audio.cpp-webui), and to [@patrickjchen](https://github.com/patrickjchen) for porting and integrating it into audio.cpp. diff --git a/app/server/audiocpp_ui_asset.h.in b/app/server/audiocpp_ui_asset.h.in new file mode 100644 index 00000000..5bf252ac --- /dev/null +++ b/app/server/audiocpp_ui_asset.h.in @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace minitts::server { + +inline constexpr unsigned char kAudioCppUiHtml[] = { +@AUDIOCPP_UI_BYTES@ +}; +inline constexpr std::size_t kAudioCppUiHtmlSize = sizeof(kAudioCppUiHtml); + +} // namespace minitts::server diff --git a/app/server/config.cpp b/app/server/config.cpp index 1feeadae..fcda0f6f 100644 --- a/app/server/config.cpp +++ b/app/server/config.cpp @@ -223,6 +223,8 @@ ServerConfig load_server_config(const std::filesystem::path & path) { config.host = engine::io::json::optional_string(root, "host", config.host); config.port = engine::io::json::optional_i32(root, "port", config.port); config.cors_origins = engine::io::json::optional_string(root, "cors_origins", config.cors_origins); + config.ui_enabled = engine::io::json::optional_bool(root, "ui", config.ui_enabled); + config.ui_management = engine::io::json::optional_bool(root, "ui_management", config.ui_management); config.backend = parse_server_backend(engine::io::json::optional_string(root, "backend", "cuda")); config.device = engine::io::json::optional_i32(root, "device", config.device); config.threads = engine::io::json::optional_i32(root, "threads", config.threads); @@ -249,8 +251,11 @@ ServerConfig load_server_config(const std::filesystem::path & path) { } const auto * models = root.find("models"); - if (models == nullptr || !models->is_array() || models->as_array().empty()) { - throw std::runtime_error("server config requires a non-empty models array"); + if (models == nullptr || !models->is_array()) { + throw std::runtime_error("server config requires a models array"); + } + if (models->as_array().empty() && !config.ui_management) { + throw std::runtime_error("server config requires a non-empty models array unless ui_management is enabled"); } for (const auto & item : models->as_array()) { ServerModelConfig model; diff --git a/app/server/config.h b/app/server/config.h index be67c55d..5bff4730 100644 --- a/app/server/config.h +++ b/app/server/config.h @@ -66,6 +66,8 @@ struct ServerConfig { std::string host = "127.0.0.1"; int port = 8080; std::string cors_origins = ""; + bool ui_enabled = true; + bool ui_management = false; engine::core::BackendType backend = engine::core::BackendType::Cuda; int device = 0; int threads = 1; diff --git a/app/server/http.cpp b/app/server/http.cpp index 304864f8..fe2b1c92 100644 --- a/app/server/http.cpp +++ b/app/server/http.cpp @@ -65,12 +65,18 @@ const char * status_text(int status) noexcept { switch (status) { case 200: return "OK"; + case 204: + return "No Content"; case 400: return "Bad Request"; + case 403: + return "Forbidden"; case 404: return "Not Found"; case 405: return "Method Not Allowed"; + case 413: + return "Payload Too Large"; case 500: return "Internal Server Error"; case 503: diff --git a/app/server/main.cpp b/app/server/main.cpp index 87ff25e2..8daf7449 100644 --- a/app/server/main.cpp +++ b/app/server/main.cpp @@ -43,11 +43,15 @@ bool has_arg(int argc, char ** argv, const std::string & name) { void print_help() { std::cout - << "audiocpp_server --config [--host ] [--port ] [--backend ]\n" + << "audiocpp_server [--config ] [--ui] [--host ] [--port ] [--backend ]\n" << " [--device ] [--threads ] [--busy-timeout-ms ]\n" << " [--model-spec-override ]\n" << " [--log] [--log-file ]\n" << " [--cors-origins ]\n" + << " --ui serve the embedded WebUI; without --config, start\n" + << " as a native model-management host\n" + << " --no-ui disable the embedded WebUI\n" + << " --ui-management allow WebUI model load/unload and temporary uploads\n" << " --backend cpu|cuda|hip|rocm|vulkan|metal default cuda (rocm is an alias for hip)\n" << " --busy-timeout-ms fail a request with 503 when the model has been\n" << " busy this long; default 300000, 0 disables\n" @@ -55,8 +59,14 @@ void print_help() { << " requests from any origin for trusted local demos only\n" << "\n" << "Endpoints:\n" + << " GET / embedded WebUI (enabled by default with a config)\n" << " GET /health\n" << " GET /v1/models\n" + << " POST /v1/models/load available with --ui-management\n" + << " POST /v1/models/unload available with --ui-management\n" + << " POST /v1/ui/upload available with --ui-management\n" + << " POST /v1/ui/models/install background package download/preparation\n" + << " GET /v1/ui/models/install-status[?id=]\n" << " GET /v1/audio/voices?model=\n" << " POST /v1/audio/speech\n" << " POST /v1/audio/transcriptions\n" @@ -75,8 +85,9 @@ int main(int argc, char ** argv) { return 0; } const auto config_path = arg_value(argc, argv, "--config"); - if (!config_path.has_value()) { - throw std::runtime_error("missing required --config argument"); + const bool ui_requested = has_arg(argc, argv, "--ui"); + if (!config_path.has_value() && !ui_requested) { + throw std::runtime_error("missing required --config argument (or use --ui for the native WebUI)"); } const auto log_file = arg_value(argc, argv, "--log-file"); engine::debug::configure_logging(engine::debug::LoggingConfig{ @@ -94,7 +105,22 @@ int main(int argc, char ** argv) { std::signal(SIGPIPE, SIG_IGN); #endif - auto config = minitts::server::load_server_config(*config_path); + auto config = config_path.has_value() + ? minitts::server::load_server_config(*config_path) + : minitts::server::ServerConfig{}; + if (!config_path.has_value()) { + config.ui_management = true; + config.lazy_load = true; + } + if (ui_requested) { + config.ui_enabled = true; + } + if (has_arg(argc, argv, "--no-ui")) { + config.ui_enabled = false; + } + if (has_arg(argc, argv, "--ui-management")) { + config.ui_management = true; + } if (const auto host = arg_value(argc, argv, "--host")) { config.host = *host; } diff --git a/app/server/model_installer.cpp b/app/server/model_installer.cpp new file mode 100644 index 00000000..3fd7aa02 --- /dev/null +++ b/app/server/model_installer.cpp @@ -0,0 +1,314 @@ +#include "model_installer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace minitts::server { +namespace { + +std::string json_quote(std::string_view value) { + std::string result; + result.reserve(value.size() + 2); + result.push_back('"'); + for (const unsigned char ch : value) { + switch (ch) { + case '"': result += "\\\""; break; + case '\\': result += "\\\\"; break; + case '\b': result += "\\b"; break; + case '\f': result += "\\f"; break; + case '\n': result += "\\n"; break; + case '\r': result += "\\r"; break; + case '\t': result += "\\t"; break; + default: + if (ch < 0x20) { + constexpr char hex[] = "0123456789abcdef"; + result += "\\u00"; + result.push_back(hex[(ch >> 4) & 0xf]); + result.push_back(hex[ch & 0xf]); + } else { + result.push_back(static_cast(ch)); + } + } + } + result.push_back('"'); + return result; +} + +bool valid_package_id(const std::string & value) { + if (value.empty() || value.size() > 128) { + return false; + } + for (const unsigned char ch : value) { + if (!(std::isalnum(ch) || ch == '_' || ch == '-')) { + return false; + } + } + return true; +} + +void validate_argument(const std::string & value, const char * name) { + if (value.find_first_of("\r\n") != std::string::npos) { + throw std::runtime_error(std::string(name) + " contains an invalid newline"); + } +#ifdef _WIN32 + if (value.find_first_of("\"&|<>^") != std::string::npos) { + throw std::runtime_error(std::string(name) + " contains an unsupported command character"); + } +#endif +} + +std::string shell_quote(const std::string & value) { +#ifdef _WIN32 + return "\"" + value + "\""; +#else + std::string result = "'"; + for (const char ch : value) { + if (ch == '\'') { + result += "'\\''"; + } else { + result.push_back(ch); + } + } + return result + "'"; +#endif +} + +std::string python_command() { + if (const char * configured = std::getenv("AUDIOCPP_PYTHON")) { + const std::string value(configured); + validate_argument(value, "AUDIOCPP_PYTHON"); + return shell_quote(value); + } +#ifdef _WIN32 + return "python"; +#else + return "python3"; +#endif +} + +std::string read_log_tail(const std::filesystem::path & path) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return {}; + } + input.seekg(0, std::ios::end); + const auto size = input.tellg(); + constexpr std::streamoff kTailBytes = 8192; + if (size > kTailBytes) { + input.seekg(size - kTailBytes); + } else { + input.seekg(0); + } + std::ostringstream content; + content << input.rdbuf(); + std::string text = content.str(); + while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) { + text.pop_back(); + } + const auto newline = text.find_last_of("\r\n"); + return newline == std::string::npos ? text : text.substr(newline + 1); +} + +int64_t now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +} // namespace + +struct ModelInstaller::State { + struct Job { + std::string package_id; + std::string state = "queued"; + std::string message; + std::filesystem::path log_path; + int exit_code = -1; + int64_t started_at_ms = 0; + int64_t finished_at_ms = 0; + }; + + std::filesystem::path repository_root; + std::filesystem::path models_root; + std::filesystem::path job_root; + mutable std::mutex mutex; + std::map jobs; +}; + +ModelInstaller::ModelInstaller( + std::filesystem::path repository_root, + std::filesystem::path models_root) + : state_(std::make_shared()) { + state_->repository_root = std::filesystem::absolute(std::move(repository_root)).lexically_normal(); + state_->models_root = std::filesystem::absolute(std::move(models_root)).lexically_normal(); + state_->job_root = std::filesystem::temp_directory_path() / "audiocpp-model-installer"; + std::filesystem::create_directories(state_->job_root); + std::filesystem::create_directories(state_->models_root); +} + +ModelInstaller::~ModelInstaller() = default; + +std::string ModelInstaller::start( + const std::string & package_id, + const std::string & source_file, + const std::string & output_file, + const std::string & source_directory, + const std::string & variant, + bool overwrite) { + if (!valid_package_id(package_id)) { + throw std::runtime_error("invalid model-manager package id"); + } + validate_argument(source_directory, "source directory"); + validate_argument(source_file, "source file"); + validate_argument(output_file, "output file"); + validate_argument(variant, "variant"); + + const bool legacy_conversion = + !source_directory.empty() || !source_file.empty() || !output_file.empty() || !variant.empty(); + const auto script = state_->repository_root / "tools" / + (legacy_conversion ? "model_manager_deprecated.py" : "model_manager_v2.py"); + if (!std::filesystem::is_regular_file(script)) { + throw std::runtime_error( + "model preparation helper was not found at " + script.string() + + "; run the server from an updated audio.cpp source or portable bundle root"); + } + + std::filesystem::path source_path; + if (!source_directory.empty()) { + source_path = std::filesystem::path(source_directory); + if (source_path.is_relative()) { + source_path = state_->repository_root / source_path; + } + source_path = std::filesystem::absolute(source_path).lexically_normal(); + } + auto resolve_optional_path = [this](const std::string & value) { + if (value.empty()) { + return std::filesystem::path{}; + } + auto path = std::filesystem::path(value); + if (path.is_relative()) { + path = state_->repository_root / path; + } + return std::filesystem::absolute(path).lexically_normal(); + }; + const auto source_file_path = resolve_optional_path(source_file); + const auto output_file_path = resolve_optional_path(output_file); + + const auto log_path = state_->job_root / (package_id + ".log"); + { + std::lock_guard lock(state_->mutex); + const auto existing = state_->jobs.find(package_id); + if (existing != state_->jobs.end() && + (existing->second.state == "queued" || existing->second.state == "running")) { + throw std::runtime_error("installation is already running for " + package_id); + } + State::Job job; + job.package_id = package_id; + job.message = "Waiting for model preparation worker"; + job.log_path = log_path; + state_->jobs[package_id] = std::move(job); + } + + const auto shared = state_; + std::thread([shared, package_id, source_file_path, output_file_path, source_path, variant, overwrite, + legacy_conversion, script, log_path]() { + try { + { + std::lock_guard lock(shared->mutex); + auto & job = shared->jobs.at(package_id); + job.state = "running"; + job.message = "Downloading and preparing model files"; + job.started_at_ms = now_ms(); + } + + std::string command = python_command() + " " + shell_quote(script.string()) + + " install " + shell_quote(package_id) + + " --models-root " + shell_quote(shared->models_root.string()); + if (overwrite) { + command += " --overwrite"; + } + if (legacy_conversion && !source_path.empty()) { + command += " --source-dir " + shell_quote(source_path.string()); + } + if (legacy_conversion && !source_file_path.empty()) { + command += " --source-file " + shell_quote(source_file_path.string()); + } + if (legacy_conversion && !output_file_path.empty()) { + command += " --output-file " + shell_quote(output_file_path.string()); + } + if (legacy_conversion && !variant.empty()) { + command += " --variant " + shell_quote(variant); + } + command += " > " + shell_quote(log_path.string()) + " 2>&1"; + + const int result = std::system(command.c_str()); + const std::string last_line = read_log_tail(log_path); + std::lock_guard lock(shared->mutex); + auto & job = shared->jobs.at(package_id); + job.exit_code = result; + job.finished_at_ms = now_ms(); + job.state = result == 0 ? "complete" : "failed"; + job.message = !last_line.empty() + ? last_line + : (result == 0 ? "Model installation completed" : "Model installation failed"); + } catch (const std::exception & error) { + std::lock_guard lock(shared->mutex); + auto & job = shared->jobs.at(package_id); + job.state = "failed"; + job.message = error.what(); + job.exit_code = -1; + job.finished_at_ms = now_ms(); + } + }).detach(); + + return status(package_id); +} + +std::string ModelInstaller::status(const std::string & package_id) const { + std::lock_guard lock(state_->mutex); + auto job_json = [](const State::Job & job) { + std::string message = job.message; + if (job.state == "running") { + const auto last_line = read_log_tail(job.log_path); + if (!last_line.empty()) { + message = last_line; + } + } + return std::string("{\"id\":") + json_quote(job.package_id) + + ",\"state\":" + json_quote(job.state) + + ",\"message\":" + json_quote(message) + + ",\"exit_code\":" + std::to_string(job.exit_code) + + ",\"started_at_ms\":" + std::to_string(job.started_at_ms) + + ",\"finished_at_ms\":" + std::to_string(job.finished_at_ms) + "}"; + }; + + if (!package_id.empty()) { + const auto found = state_->jobs.find(package_id); + if (found == state_->jobs.end()) { + return "{\"id\":" + json_quote(package_id) + + ",\"state\":\"idle\",\"message\":\"Not started\",\"exit_code\":-1," + "\"started_at_ms\":0,\"finished_at_ms\":0}"; + } + return job_json(found->second); + } + + std::string result = "{\"data\":["; + bool first = true; + for (const auto & item : state_->jobs) { + if (!first) { + result += ","; + } + first = false; + result += job_json(item.second); + } + return result + "]}"; +} + +} // namespace minitts::server diff --git a/app/server/model_installer.h b/app/server/model_installer.h new file mode 100644 index 00000000..c7bb92b5 --- /dev/null +++ b/app/server/model_installer.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +namespace minitts::server { + +// Runs the repository model manager outside the request thread. The native UI +// owns the workflow and status display. Normal packages use model_manager_v2; +// requests with converter inputs use the deprecated manager until those legacy +// preparation workflows have migrated to package specs. +class ModelInstaller { +public: + ModelInstaller(std::filesystem::path repository_root, std::filesystem::path models_root); + ~ModelInstaller(); + + ModelInstaller(const ModelInstaller &) = delete; + ModelInstaller & operator=(const ModelInstaller &) = delete; + + std::string start( + const std::string & package_id, + const std::string & source_file, + const std::string & output_file, + const std::string & source_directory, + const std::string & variant, + bool overwrite); + std::string status(const std::string & package_id = {}) const; + +private: + struct State; + std::shared_ptr state_; +}; + +} // namespace minitts::server diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 33027e97..0bc6a409 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -1,6 +1,7 @@ #include "runtime.h" #include "multipart.h" +#include "ui_assets.h" #include "../cli/request.h" #include "../streaming/pcm_source.h" @@ -24,6 +25,7 @@ #include #include #include +#include #include #include @@ -57,6 +59,47 @@ std::filesystem::path resolve_path(const std::filesystem::path & base, const std return path.is_absolute() ? path : base / path; } +std::unordered_map options_from_object(const Value * value); + +std::string safe_upload_name(std::string value) { + value = std::filesystem::path(value).filename().string(); + for (char & ch : value) { + const auto uch = static_cast(ch); + if (!(std::isalnum(uch) || ch == '.' || ch == '-' || ch == '_')) { + ch = '_'; + } + } + if (value.empty() || value == "." || value == "..") { + value = "audio.wav"; + } + return value; +} + +ServerModelConfig model_config_from_json( + const Value & body, + const std::filesystem::path & request_base, + bool lazy) { + ServerModelConfig model; + model.id = engine::io::json::require_string(body, "id"); + model.path = resolve_path(request_base, engine::io::json::require_string(body, "path")); + model.family = engine::io::json::require_string(body, "family"); + model.task = engine::io::json::optional_string(body, "task", model.task); + model.mode = engine::io::json::optional_string(body, "mode", model.mode); + model.lazy = lazy; + if (const auto * value = body.find("model_spec_override")) { + model.model_spec_override = resolve_path(request_base, value->as_string()); + } + if (const auto * value = body.find("config")) { + model.config_id = value->as_string(); + } + if (const auto * value = body.find("weight")) { + model.weight_id = value->as_string(); + } + model.load_options = options_from_object(body.find("load_options")); + model.session_options = options_from_object(body.find("session_options")); + return model; +} + // Minimal application/x-www-form-urlencoded query string lookup, e.g. // query_param("model=pocket-tts&foo=bar", "model") -> "pocket-tts". std::string query_param(const std::string & query, const std::string & key) { @@ -640,26 +683,57 @@ ServerState::ServerState(ServerConfig config, std::filesystem::path request_base << backend_name(config_.backend) << " server backend is intended for portability and testing, but performance and model coverage may be lower than CUDA.\n"; } + if (config_.ui_management) { + upload_root_ = std::filesystem::temp_directory_path() / + ("audiocpp-ui-" + std::to_string( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count())); + std::filesystem::create_directories(upload_root_); + model_installer_ = std::make_unique( + request_base_, + request_base_ / "models"); + } load_models(); } +ServerState::~ServerState() { + if (!upload_root_.empty()) { + std::error_code ec; + std::filesystem::remove_all(upload_root_, ec); + } +} + HttpResponse ServerState::handle(const HttpRequest & request) { HttpResponse response; const std::string allowed_origin = get_allowed_origin(request); try { log_request_body_if_enabled(config_, request); - if (request.method == "OPTIONS" && !allowed_origin.empty()) { + if (request.method == "OPTIONS" && (!allowed_origin.empty() || config_.ui_enabled)) { response.status = 204; response.content_type = "text/plain"; response.headers["Access-Control-Allow-Headers"] = "*"; response.headers["Access-Control-Allow-Methods"] = "GET, POST"; } + else if (request.method == "GET" && (request.path == "/" || request.path == "/index.html")) { + response = handle_ui_asset(); + } + else if (request.method == "GET" && request.path == "/favicon.ico") { + response.status = 204; + response.content_type = "image/x-icon"; + } else if (request.method == "GET" && request.path == "/health") { + size_t model_count = 0; + { + std::lock_guard lock(models_mutex_); + model_count = models_.size(); + } response = json_response( "{\"status\":\"ok\",\"backend\":\"" + std::string(backend_name(config_.backend)) + "\",\"models\":" + - std::to_string(models_.size()) + + std::to_string(model_count) + + ",\"ui\":" + (config_.ui_enabled ? "true" : "false") + + ",\"ui_management\":" + (config_.ui_management ? "true" : "false") + "}"); } else if (request.method == "GET" && request.path == "/v1/models") { @@ -668,6 +742,24 @@ HttpResponse ServerState::handle(const HttpRequest & request) { else if (request.method == "GET" && request.path == "/v1/audio/voices") { response = handle_voices(request); } + else if (request.method == "POST" && request.path == "/v1/models/load") { + response = handle_model_load(request.body); + } + else if (request.method == "POST" && request.path == "/v1/models/unload") { + response = handle_model_unload(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/path-status") { + response = handle_path_status(request.body); + } + else if (request.method == "POST" && request.path == "/v1/ui/upload") { + response = handle_ui_upload(request); + } + else if (request.method == "POST" && request.path == "/v1/ui/models/install") { + response = handle_model_install(request.body); + } + else if (request.method == "GET" && request.path == "/v1/ui/models/install-status") { + response = handle_model_install_status(request); + } else if (request.method == "POST" && request.path == "/v1/audio/speech") { response = handle_speech(request.body); } @@ -703,16 +795,10 @@ HttpResponse ServerState::handle(const HttpRequest & request) { void ServerState::load_models() { for (auto & config : config_.models) { - auto loaded = std::make_unique(); - loaded->config = std::move(config); - loaded->task = engine::runtime::TaskSpec{ - engine::runtime::parse_voice_task_kind(loaded->config.task), - engine::runtime::parse_run_mode(loaded->config.mode), - }; + auto loaded = make_model(std::move(config)); if (!model_index_.emplace(loaded->config.id, models_.size()).second) { throw std::runtime_error("duplicate server model id: " + loaded->config.id); } - load_voice_presets(*loaded); if (!loaded->config.lazy) { ensure_model_loaded_locked(*loaded); } @@ -720,6 +806,198 @@ void ServerState::load_models() { } } +std::unique_ptr ServerState::make_model(ServerModelConfig config) { + auto loaded = std::make_unique(); + loaded->config = std::move(config); + loaded->task = engine::runtime::TaskSpec{ + engine::runtime::parse_voice_task_kind(loaded->config.task), + engine::runtime::parse_run_mode(loaded->config.mode), + }; + load_voice_presets(*loaded); + return loaded; +} + +HttpResponse ServerState::handle_model_load(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "dynamic model management is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + auto requested = model_config_from_json(body, request_base_, false); + + LoadedModel * existing = nullptr; + { + std::lock_guard state_lock(models_mutex_); + const auto found = model_index_.find(requested.id); + if (found != model_index_.end()) { + existing = models_.at(found->second).get(); + } + } + + if (existing != nullptr) { + BusyGuard::Lock run_lock = acquire_model_run(*existing, std::nullopt); + std::unique_lock metadata_lock(existing->metadata_mutex); + const bool changed = + existing->config.path != requested.path || + existing->config.family != requested.family || + existing->config.task != requested.task || + existing->config.mode != requested.mode || + existing->config.load_options != requested.load_options || + existing->config.session_options != requested.session_options || + existing->config.model_spec_override != requested.model_spec_override; + if (changed) { + existing->streaming = nullptr; + existing->offline = nullptr; + existing->session.reset(); + existing->model.reset(); + existing->loaded.store(false); + existing->voice_presets.clear(); + existing->default_voice_preset.reset(); + existing->config = std::move(requested); + existing->task = engine::runtime::TaskSpec{ + engine::runtime::parse_voice_task_kind(existing->config.task), + engine::runtime::parse_run_mode(existing->config.mode), + }; + load_voice_presets(*existing); + } + ensure_model_loaded_locked(*existing); + return json_response( + "{\"id\":" + json_quote(existing->config.id) + + ",\"loaded\":true,\"reconfigured\":" + (changed ? "true" : "false") + "}"); + } + + auto loaded = make_model(std::move(requested)); + ensure_model_loaded_locked(*loaded); + const std::string id = loaded->config.id; + { + std::lock_guard state_lock(models_mutex_); + if (model_index_.find(id) != model_index_.end()) { + throw std::runtime_error("model id was registered concurrently: " + id); + } + model_index_.emplace(id, models_.size()); + models_.push_back(std::move(loaded)); + } + return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":true,\"reconfigured\":false}"); +} + +HttpResponse ServerState::handle_model_unload(const std::string & body_text) { + if (!config_.ui_management) { + return error_response(403, "dynamic model management is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const std::string id = engine::io::json::require_string(body, "id"); + LoadedModel * model = nullptr; + { + std::lock_guard state_lock(models_mutex_); + const auto found = model_index_.find(id); + if (found == model_index_.end()) { + return error_response(404, "unknown model id: " + id, "not_found"); + } + model = models_.at(found->second).get(); + } + BusyGuard::Lock run_lock = acquire_model_run(*model, std::nullopt); + model->streaming = nullptr; + model->offline = nullptr; + model->session.reset(); + model->model.reset(); + model->loaded.store(false); + return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":false}"); +} + +HttpResponse ServerState::handle_path_status(const std::string & body_text) const { + if (!config_.ui_management) { + return error_response(403, "UI path inspection is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const auto path = resolve_path(request_base_, engine::io::json::require_string(body, "path")); + std::error_code ec; + const bool exists = std::filesystem::exists(path, ec); + const bool directory = exists && std::filesystem::is_directory(path, ec); + const bool regular_file = exists && std::filesystem::is_regular_file(path, ec); + return json_response( + "{\"path\":" + json_quote(path.string()) + + ",\"exists\":" + (exists ? "true" : "false") + + ",\"directory\":" + (directory ? "true" : "false") + + ",\"file\":" + (regular_file ? "true" : "false") + "}"); +} + +HttpResponse ServerState::handle_ui_upload(const HttpRequest & request) { + if (!config_.ui_management) { + return error_response(403, "UI uploads are disabled", "forbidden"); + } + if (request.body.empty()) { + return error_response(400, "upload body is empty", "invalid_request_error"); + } + constexpr size_t kMaxUploadBytes = size_t{2} * 1024 * 1024 * 1024; + if (request.body.size() > kMaxUploadBytes) { + return error_response(413, "upload exceeds the 2 GiB limit", "invalid_request_error"); + } + std::string filename = "audio.wav"; + if (const auto it = request.headers.find("x-audiocpp-filename"); it != request.headers.end()) { + filename = safe_upload_name(it->second); + } + const auto id = next_upload_id_.fetch_add(1); + const auto path = upload_root_ / (std::to_string(id) + "-" + filename); + std::ofstream out(path, std::ios::binary); + if (!out) { + throw std::runtime_error("could not create temporary upload: " + path.string()); + } + out.write(request.body.data(), static_cast(request.body.size())); + if (!out) { + throw std::runtime_error("could not write temporary upload: " + path.string()); + } + return json_response( + "{\"path\":" + json_quote(path.string()) + + ",\"bytes\":" + std::to_string(request.body.size()) + "}"); +} + +HttpResponse ServerState::handle_model_install(const std::string & body_text) { + if (!config_.ui_management || !model_installer_) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const std::string package_id = engine::io::json::require_string(body, "id"); + const std::string source_directory = + engine::io::json::optional_string(body, "source_directory", ""); + const std::string source_file = + engine::io::json::optional_string(body, "source_file", ""); + const std::string output_file = + engine::io::json::optional_string(body, "output_file", ""); + const std::string variant = + engine::io::json::optional_string(body, "variant", ""); + const bool overwrite = + engine::io::json::optional_bool(body, "overwrite", false); + return json_response(model_installer_->start( + package_id, + source_file, + output_file, + source_directory, + variant, + overwrite)); +} + +HttpResponse ServerState::handle_model_install_status(const HttpRequest & request) const { + if (!config_.ui_management || !model_installer_) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + return json_response(model_installer_->status(query_param(request.query, "id"))); +} + +HttpResponse ServerState::handle_ui_asset() const { + if (!config_.ui_enabled) { + return error_response(404, "WebUI is disabled", "not_found"); + } + HttpResponse response; + response.status = 200; + response.content_type = "text/html; charset=utf-8"; + const auto html = embedded_ui_html(); + response.body.assign(html.data(), html.size()); + response.headers["Cache-Control"] = "no-cache"; + response.headers["Content-Security-Policy"] = + "default-src 'self' 'unsafe-inline' blob: data:; connect-src 'self'; media-src 'self' blob: data:"; + response.headers["X-Content-Type-Options"] = "nosniff"; + return response; +} + ServerState::LoadedModel::RuntimeVoicePreset ServerState::load_runtime_voice_preset( const ServerModelConfig::VoicePreset & preset) const { LoadedModel::RuntimeVoicePreset out; @@ -807,6 +1085,7 @@ void ServerState::ensure_model_loaded_locked(LoadedModel & model) { model.session = std::move(session); model.offline = offline; model.streaming = streaming; + model.loaded.store(true); } LiveIngestLimits ServerState::live_ingest_limits(const HttpRequest & request) const { @@ -823,6 +1102,7 @@ LiveIngestLimits ServerState::live_ingest_limits(const HttpRequest & request) co ServerState::LoadedModel & ServerState::require_model(const Value & body) { const std::string id = engine::io::json::require_string(body, "model"); + std::lock_guard state_lock(models_mutex_); const auto it = model_index_.find(id); if (it == model_index_.end()) { throw std::runtime_error("unknown model id: " + id); @@ -850,6 +1130,7 @@ const ServerState::LoadedModel::RuntimeVoicePreset * ServerState::select_voice_p } engine::runtime::TaskRequest ServerState::build_speech_request(const LoadedModel & model, const Value & body) const { + std::shared_lock metadata_lock(model.metadata_mutex); engine::runtime::TaskRequest request; request.text_input = engine::runtime::Transcript{ engine::io::json::require_string(body, "input"), @@ -921,16 +1202,24 @@ struct ServerState::TimedTaskResult { std::optional ttft_ms; }; -int ServerState::model_busy_timeout_ceiling(const LoadedModel & model) const { - return model.config.busy_timeout_ms.value_or(config_.busy_timeout_ms); +engine::runtime::RunMode ServerState::model_run_mode(const LoadedModel & model) const { + std::shared_lock metadata_lock(model.metadata_mutex); + return model.task.mode; } BusyGuard::Lock ServerState::acquire_model_run( LoadedModel & model, std::optional request_timeout_ms) { - const int timeout_ms = - resolve_busy_timeout_ms(model_busy_timeout_ceiling(model), request_timeout_ms); - return model.busy.acquire(timeout_ms, model.config.id); + int timeout_ms = 0; + std::string model_id; + { + std::shared_lock metadata_lock(model.metadata_mutex); + timeout_ms = resolve_busy_timeout_ms( + model.config.busy_timeout_ms.value_or(config_.busy_timeout_ms), + request_timeout_ms); + model_id = model.config.id; + } + return model.busy.acquire(timeout_ms, model_id); } ServerState::TimedTaskResult ServerState::run_model( @@ -1010,7 +1299,7 @@ HttpResponse ServerState::handle_speech(const std::string & body_text) { return handle_speech_stream(model, request, body); } const auto busy_timeout_ms = parse_busy_timeout_override(body); - const auto timed_result = model.task.mode == engine::runtime::RunMode::Streaming + const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) : run_model(model, request, busy_timeout_ms); const auto & audio = select_audio_output(timed_result.result); @@ -1033,7 +1322,7 @@ HttpResponse ServerState::handle_speech_stream( LoadedModel & model, const engine::runtime::TaskRequest & request, const Value & body) { - if (model.task.mode != engine::runtime::RunMode::Streaming) { + if (model_run_mode(model) != engine::runtime::RunMode::Streaming) { throw std::runtime_error("speech streaming requires a model configured with mode=streaming"); } const auto stream_format = engine::io::json::optional_string(body, "stream_format", "sse"); @@ -1201,7 +1490,7 @@ HttpResponse ServerState::run_transcription( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms) { - const auto timed_result = model.task.mode == engine::runtime::RunMode::Streaming + const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) : run_model(model, request, busy_timeout_ms); const auto & result = timed_result.result; @@ -1220,7 +1509,7 @@ HttpResponse ServerState::run_transcription_stream( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms) { - if (model.task.mode != engine::runtime::RunMode::Streaming) { + if (model_run_mode(model) != engine::runtime::RunMode::Streaming) { throw std::runtime_error("transcription stream=true requires a model configured with mode=streaming"); } LoadedModel * model_ptr = &model; @@ -1409,7 +1698,7 @@ HttpResponse ServerState::handle_generic_run(const std::string & body_text) { request_json != nullptr ? *request_json : body, request_base_); const auto busy_timeout_ms = parse_busy_timeout_override(body); - const auto timed_result = model.task.mode == engine::runtime::RunMode::Streaming + const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) : run_model(model, request, busy_timeout_ms); return json_response(task_result_json(timed_result.result, timed_result.wall_ms)); @@ -1449,6 +1738,7 @@ HttpResponse ServerState::handle_generic_stream(const std::string & body_text) { // potentially Open WebUI) that call GET /v1/audio/voices?model= to populate a voice picker // instead of guessing generic names like "alloy"/"nova". HttpResponse ServerState::handle_voices(const HttpRequest & request) const { + std::lock_guard state_lock(models_mutex_); const std::string model_id = query_param(request.query, "model"); std::vector voices; @@ -1462,11 +1752,13 @@ HttpResponse ServerState::handle_voices(const HttpRequest & request) const { model_idx = 0; } if (model_idx != SIZE_MAX) { - for (const auto & [name, preset] : models_.at(model_idx)->voice_presets) { + const auto & model = *models_.at(model_idx); + std::shared_lock metadata_lock(model.metadata_mutex); + for (const auto & [name, preset] : model.voice_presets) { (void) preset; voices.push_back(name); } - const auto embeddings_dir = models_.at(model_idx)->config.path / "embeddings"; + const auto embeddings_dir = model.config.path / "embeddings"; std::error_code ec; if (std::filesystem::is_directory(embeddings_dir, ec)) { for (const auto & entry : std::filesystem::directory_iterator(embeddings_dir, ec)) { @@ -1492,6 +1784,7 @@ HttpResponse ServerState::handle_voices(const HttpRequest & request) const { } std::string ServerState::models_json() const { + std::lock_guard state_lock(models_mutex_); std::ostringstream out; out << "{\"object\":\"list\",\"data\":["; for (size_t i = 0; i < models_.size(); ++i) { @@ -1499,12 +1792,15 @@ std::string ServerState::models_json() const { out << ","; } const auto & model = *models_[i]; + std::shared_lock metadata_lock(model.metadata_mutex); out << "{\"id\":" << json_quote(model.config.id) << ",\"object\":\"model\"" << ",\"owned_by\":\"engine\"" << ",\"family\":" << json_quote(model.config.family) << ",\"task\":" << json_quote(engine::runtime::to_string(model.task.task)) << ",\"mode\":" << json_quote(engine::runtime::to_string(model.task.mode)) + << ",\"loaded\":" << (model.loaded.load() ? "true" : "false") + << ",\"path\":" << json_quote(model.config.path.string()) << "}"; } out << "]}"; diff --git a/app/server/runtime.h b/app/server/runtime.h index 2bca82ae..3f743771 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -3,6 +3,7 @@ #include "busy_guard.h" #include "config.h" #include "http.h" +#include "model_installer.h" #include "../streaming/streaming.h" @@ -16,6 +17,7 @@ #include #include #include +#include #include #include @@ -24,6 +26,7 @@ namespace minitts::server { class ServerState final : public IHttpHandler { public: ServerState(ServerConfig config, std::filesystem::path request_base); + ~ServerState() override; HttpResponse handle(const HttpRequest & request) override; @@ -47,6 +50,8 @@ class ServerState final : public IHttpHandler { std::unique_ptr session; engine::runtime::IOfflineVoiceTaskSession * offline = nullptr; engine::runtime::IStreamingVoiceTaskSession * streaming = nullptr; + std::atomic loaded{false}; + mutable std::shared_mutex metadata_mutex; std::unordered_map voice_presets; std::optional default_voice_preset; // Serializes runs on this model and bounds how long a caller waits for its @@ -61,9 +66,17 @@ class ServerState final : public IHttpHandler { // Server policy for this model: its own busy_timeout_ms if set, else the // top-level config value. - int model_busy_timeout_ceiling(const LoadedModel & model) const; + engine::runtime::RunMode model_run_mode(const LoadedModel & model) const; void load_models(); + std::unique_ptr make_model(ServerModelConfig config); + HttpResponse handle_model_load(const std::string & body_text); + HttpResponse handle_model_unload(const std::string & body_text); + HttpResponse handle_path_status(const std::string & body_text) const; + HttpResponse handle_ui_upload(const HttpRequest & request); + HttpResponse handle_model_install(const std::string & body_text); + HttpResponse handle_model_install_status(const HttpRequest & request) const; + HttpResponse handle_ui_asset() const; LoadedModel::RuntimeVoicePreset load_runtime_voice_preset(const ServerModelConfig::VoicePreset & preset) const; void load_voice_presets(LoadedModel & model) const; void ensure_model_loaded_locked(LoadedModel & model); @@ -129,6 +142,10 @@ class ServerState final : public IHttpHandler { std::filesystem::path request_base_; std::vector> models_; std::unordered_map model_index_; + mutable std::mutex models_mutex_; + std::filesystem::path upload_root_; + std::unique_ptr model_installer_; + std::atomic next_upload_id_{1}; }; } // namespace minitts::server diff --git a/app/server/ui_assets.cpp b/app/server/ui_assets.cpp new file mode 100644 index 00000000..8ba18ae3 --- /dev/null +++ b/app/server/ui_assets.cpp @@ -0,0 +1,14 @@ +#include "ui_assets.h" + +#include "audiocpp_ui_asset.h" + +namespace minitts::server { + +std::string_view embedded_ui_html() noexcept { + return { + reinterpret_cast(kAudioCppUiHtml), + kAudioCppUiHtmlSize, + }; +} + +} // namespace minitts::server diff --git a/app/server/ui_assets.h b/app/server/ui_assets.h new file mode 100644 index 00000000..3773f358 --- /dev/null +++ b/app/server/ui_assets.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace minitts::server { + +std::string_view embedded_ui_html() noexcept; + +} // namespace minitts::server diff --git a/scripts/package_windows_prebuilt.ps1 b/scripts/package_windows_prebuilt.ps1 index eac939bb..c2f5d293 100644 --- a/scripts/package_windows_prebuilt.ps1 +++ b/scripts/package_windows_prebuilt.ps1 @@ -155,6 +155,19 @@ Server: .\audiocpp_server.exe --config C:\path\to\server.json ``` +Native WebUI (no Python required): + +```powershell +.\audiocpp_server.exe --ui --backend cuda +``` + +Then open `http://127.0.0.1:8080`. + +Inference and the WebUI do not require Python. The optional **Install / prepare** +button uses the bundled `tools\model_manager_v2.py` for normal downloads and +`tools\model_manager_deprecated.py` for legacy conversion workflows; install Python dependencies +only when downloading or converting packages through that button. + ## Notes - Models are not bundled. @@ -195,6 +208,19 @@ Server: .\audiocpp_server.exe --config C:\path\to\server.json ``` +Native WebUI (no Python required): + +```powershell +.\audiocpp_server.exe --ui --backend cpu +``` + +Then open `http://127.0.0.1:8080`. + +Inference and the WebUI do not require Python. The optional **Install / prepare** +button uses the bundled `tools\model_manager_v2.py` for normal downloads and +`tools\model_manager_deprecated.py` for legacy conversion workflows; install Python dependencies +only when downloading or converting packages through that button. + ## Notes - Models are not bundled. @@ -282,6 +308,23 @@ function New-PrebuiltPackage { $stageDir = Join-Path $OutputDir $packageName Copy-TreeContents $sourceBin $stageDir + # The embedded UI runs entirely from audiocpp_server.exe. Keep the small + # preparation helpers and package specs beside portable builds so the Models + # page can install current packages and retain legacy converter workflows. + $stageTools = Join-Path $stageDir "tools" + New-Item -ItemType Directory -Force -Path $stageTools | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot "tools\model_manager_v2.py") -Destination $stageTools -Force + Copy-Item -LiteralPath (Join-Path $repoRoot "tools\model_manager_deprecated.py") -Destination $stageTools -Force + $communityTools = Join-Path $repoRoot "tools\community_models" + if (Test-Path -LiteralPath $communityTools) { + Copy-TreeContents $communityTools (Join-Path $stageTools "community_models") + } + Copy-TreeContents (Join-Path $repoRoot "model_specs") (Join-Path $stageDir "model_specs") + $modelManagerAssets = Join-Path $repoRoot "assets\model_manager" + if (Test-Path -LiteralPath $modelManagerAssets) { + Copy-TreeContents $modelManagerAssets (Join-Path $stageDir "assets\model_manager") + } + $crtDir = Find-VcRedistDir "Microsoft.VC143.CRT" $ompDir = Find-VcRedistDir "Microsoft.VC143.OpenMP" Copy-RequiredDll "MSVCP140.dll" @($crtDir) $stageDir | Out-Null diff --git a/tests/unittests/test_server_config.cpp b/tests/unittests/test_server_config.cpp index 47f99b0a..27896674 100644 --- a/tests/unittests/test_server_config.cpp +++ b/tests/unittests/test_server_config.cpp @@ -309,6 +309,41 @@ void test_negative_per_model_busy_timeout_is_rejected() { require(rejected, "negative per-model busy_timeout_ms is rejected, naming the model"); } +void test_ui_configuration() { + const auto root = make_temp_root(); + const auto config_path = write_config( + root, + "ui.json", + R"JSON({ + "ui": false, + "ui_management": true, + "models": [] +})JSON"); + + const auto config = minitts::server::load_server_config(config_path); + require(!config.ui_enabled, "ui=false disables the embedded WebUI"); + require(config.ui_management, "ui_management=true enables dynamic model management"); + require(config.models.empty(), "management hosts may start without configured models"); +} + +void test_empty_models_require_ui_management() { + const auto root = make_temp_root(); + const auto config_path = write_config( + root, + "empty_models.json", + R"JSON({ + "models": [] +})JSON"); + + bool rejected = false; + try { + (void) minitts::server::load_server_config(config_path); + } catch (const std::runtime_error & error) { + rejected = std::string(error.what()).find("ui_management") != std::string::npos; + } + require(rejected, "an empty static server config requires ui_management"); +} + // A request may shorten its own wait but must never lengthen it past server policy, // otherwise a client could reintroduce the unbounded hang the guard prevents. void test_request_timeout_is_clamped_to_policy() { @@ -360,6 +395,8 @@ int main() { test_negative_busy_timeout_is_rejected(); test_per_model_busy_timeout(); test_negative_per_model_busy_timeout_is_rejected(); + test_ui_configuration(); + test_empty_models_require_ui_management(); test_request_timeout_is_clamped_to_policy(); test_model_run_overrun_predicate(); } catch (const std::exception & error) { diff --git a/tests/unittests/test_server_model_installer.cpp b/tests/unittests/test_server_model_installer.cpp new file mode 100644 index 00000000..97a688b3 --- /dev/null +++ b/tests/unittests/test_server_model_installer.cpp @@ -0,0 +1,78 @@ +#include "model_installer.h" + +#include +#include +#include +#include +#include + +namespace { + +void require(bool condition, const std::string & message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +std::filesystem::path make_root() { + const auto suffix = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + const auto root = std::filesystem::temp_directory_path() / + ("audiocpp-model-installer-test-" + std::to_string(suffix)); + std::filesystem::create_directories(root); + return root; +} + +void test_idle_status_and_validation() { + const auto root = make_root(); + try { + minitts::server::ModelInstaller installer(root, root / "models"); + const auto idle = installer.status("qwen3_asr_0_6b"); + require(idle.find("\"state\":\"idle\"") != std::string::npos, "unknown jobs report idle"); + + bool invalid_rejected = false; + try { + (void) installer.start("bad & package", "", "", "", "", false); + } catch (const std::runtime_error &) { + invalid_rejected = true; + } + require(invalid_rejected, "unsafe package ids are rejected"); + + bool missing_helper_reported = false; + try { + (void) installer.start("qwen3_asr_0_6b", "", "", "", "", false); + } catch (const std::runtime_error & error) { + missing_helper_reported = + std::string(error.what()).find("model_manager_v2.py") != std::string::npos; + } + require(missing_helper_reported, "a missing v2 preparation helper has a useful error"); + + bool missing_legacy_helper_reported = false; + try { + (void) installer.start("qwen3_asr_0_6b", "checkpoint.bin", "", "", "", false); + } catch (const std::runtime_error & error) { + missing_legacy_helper_reported = + std::string(error.what()).find("model_manager_deprecated.py") != std::string::npos; + } + require(missing_legacy_helper_reported, "converter inputs select the legacy preparation helper"); + } catch (...) { + std::error_code ec; + std::filesystem::remove_all(root, ec); + throw; + } + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +} // namespace + +int main() { + try { + test_idle_status_and_validation(); + } catch (const std::exception & error) { + std::cerr << error.what() << '\n'; + return 1; + } + std::cout << "server_model_installer_test passed\n"; + return 0; +} diff --git a/webui/README.md b/webui/README.md index bf6d17d3..b9bb5060 100644 --- a/webui/README.md +++ b/webui/README.md @@ -1,9 +1,78 @@ -# audio.cpp WebUI Launcher Guide +# audio.cpp WebUI Guide > **语言 / Language:** **English** · [中文](README.zh.md) -The `webui/` directory holds the Python dependencies, launch scripts, and model-download wrappers needed to run the WebUI. -The launch scripts can be **double-clicked** or invoked from a command line / PowerShell. +audio.cpp now has two browser interfaces: + +- **Native WebUI (recommended):** a SvelteKit/TypeScript single-page app embedded directly in + `audiocpp_server`. It needs no Python or frontend files at runtime. +- **Legacy Gradio WebUI:** the original Python interface and helper workflows in this directory. + +## Native embedded WebUI + +Build `audiocpp_server` normally, then start the native WebUI host: + +```powershell +.\build\windows-cuda-release\bin\audiocpp_server.exe --ui --backend cuda +``` + +```bash +./build/bin/audiocpp_server --ui --backend cuda +``` + +Open **http://127.0.0.1:8080**. With no `--config`, `--ui` enables on-demand model load/unload and +temporary audio uploads automatically. Relative model paths are resolved from the server's current working +directory, so start it from the bundle or repository root when the catalog uses `models/...`. + +The same UI can front an existing server config: + +```bash +audiocpp_server --config server.json +``` + +Configured models keep their existing eager/lazy behavior. Add `--ui-management` to allow the UI to load, +switch, and unload catalog models. Use `--no-ui` to retain an API-only server. + +The native UI supports the shared catalog, model-specific controls, file decoding in the browser, TTS and voice +cloning, transcription, generic audio tasks, multiple separation outputs, structured results, and request timing. +It also includes: + +- model download/preparation jobs with status reporting on the Models page; +- sentence-aware long-text synthesis and browser-side WAV merging; +- microphone capture for source and reference audio; +- a saved voice library backed by browser IndexedDB; +- four-second near-live microphone transcription for streaming-capable ASR models. + +Uploaded request files are placed in a per-process temporary directory and deleted when the server exits. Saved +voices stay in the browser profile and are not uploaded until selected for a request. + +The server and embedded interface need no Python at runtime. The **Install / prepare** action invokes +`tools/model_manager_v2.py` for normal spec-backed downloads. When source/output/variant converter inputs are supplied, +it falls back to `tools/model_manager_deprecated.py` for legacy preparation workflows that have not migrated yet. +Set `AUDIOCPP_PYTHON` when the desired interpreter is not `python` on Windows or `python3` on Unix. Pure inference, +loading an existing folder, and standalone GGUF operation do not use either helper. The Models page exposes optional +source directory, source checkpoint, output, variant, and overwrite inputs for specialized preparation workflows. + +### Frontend development + +Node.js is only needed to modify the frontend, never to run the compiled server: + +```bash +cd webui/native +npm ci +npm run check +npm run build +``` + +The build creates the single-file `webui/native/dist/index.html`. CMake converts that file to an embedded byte +array when configuring `audiocpp_server`; rerun CMake/build after changing it. For live frontend development, +run `npm run dev`; Vite proxies `/health` and `/v1` to a server on port 8080. + +## Legacy Gradio WebUI + +The Gradio interface remains for compatibility and translated legacy workflows. It is no longer required for the +model installer, long-text synthesis, saved voices, or microphone input described above. The remaining sections +describe that interface. Its launch scripts can be **double-clicked** or invoked from a command line or PowerShell. | Script | Purpose | Typical command | |---|---|---| diff --git a/webui/native/dist/index.html b/webui/native/dist/index.html new file mode 100644 index 00000000..e70f4978 --- /dev/null +++ b/webui/native/dist/index.html @@ -0,0 +1,115 @@ + + + + + + + + + + + +
+ +
+ + diff --git a/webui/native/package-lock.json b/webui/native/package-lock.json new file mode 100644 index 00000000..4a315649 --- /dev/null +++ b/webui/native/package-lock.json @@ -0,0 +1,1673 @@ +{ + "name": "audiocpp-native-webui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "audiocpp-native-webui", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.37.0", + "@sveltejs/vite-plugin-svelte": "^6.1.0", + "svelte": "^5.38.0", + "svelte-check": "^4.3.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/webui/native/package.json b/webui/native/package.json new file mode 100644 index 00000000..8fc8ff4f --- /dev/null +++ b/webui/native/package.json @@ -0,0 +1,20 @@ +{ + "name": "audiocpp-native-webui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.9", + "@sveltejs/kit": "^2.37.0", + "@sveltejs/vite-plugin-svelte": "^6.1.0", + "svelte": "^5.38.0", + "svelte-check": "^4.3.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } +} diff --git a/webui/native/src/app.css b/webui/native/src/app.css new file mode 100644 index 00000000..9f0877d1 --- /dev/null +++ b/webui/native/src/app.css @@ -0,0 +1,181 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #dce8fa; + background: #07101f; + font-synthesis: none; + --bg: #07101f; + --panel: #0d192b; + --panel-2: #101f34; + --line: #233652; + --muted: #8ca3c3; + --cyan: #42e8d5; + --blue: #6aa8ff; + --danger: #ff778b; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: + radial-gradient(circle at 82% -10%, rgba(45, 107, 189, .2), transparent 30rem), + var(--bg); } +button, input, textarea, select { font: inherit; } +button { color: inherit; } + +.topbar { + height: 58px; padding: 0 max(20px, calc((100vw - 1600px) / 2)); + display: flex; align-items: center; gap: 26px; border-bottom: 1px solid var(--line); + background: rgba(7, 16, 31, .88); backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; +} +.brand { display: flex; align-items: center; gap: 9px; min-width: 190px; } +.brand .mark { width: 30px; height: 30px; display: grid; place-items: center; font-weight: 900; + color: #06141a; background: linear-gradient(135deg, var(--cyan), var(--blue)); border-radius: 8px; } +.brand strong { display: block; font-size: 15px; letter-spacing: -.02em; } +.brand span { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .13em; } +nav { display: flex; height: 100%; } +nav button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 0 16px; color: var(--muted); cursor: pointer; } +nav button:hover, nav button.active { color: white; } +nav button.active { border-color: var(--cyan); } +.server-pill { margin-left: auto; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 11px; text-transform: uppercase; } +.server-pill i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #64748b; margin-right: 7px; } +.server-pill.online i { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); } + +main { max-width: 1600px; margin: auto; padding: 18px 20px 36px; } +.workflow-tabs { + display: grid; grid-template-columns: repeat(7, minmax(126px, 1fr)); gap: 6px; + height: auto; margin: 0 0 16px; overflow-x: auto; scrollbar-width: thin; +} +.workflow-tabs button { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + min-width: 126px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; + background: rgba(13,25,43,.8); color: var(--muted); font-size: 11px; text-align: left; +} +.workflow-tabs button:hover, .workflow-tabs button.active { + color: #f4f8ff; border-color: #3d718f; background: rgba(20,48,78,.95); +} +.workflow-tabs button.active { box-shadow: inset 0 -2px 0 var(--cyan); } +.workflow-tabs small { + display: grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; + border-radius: 999px; color: #9fc0e7; background: #071426; font-size: 10px; +} +.hero { display: flex; justify-content: space-between; align-items: center; gap: 22px; padding: 1px 4px 16px; } +.eyebrow { color: var(--cyan)!important; font-size: 11px!important; font-weight: 800; letter-spacing: .18em; margin: 0 0 9px!important; } +h1 { font-size: clamp(28px, 3vw, 40px); line-height: 1; letter-spacing: -.04em; margin: 0 0 8px; color: #f4f8ff; } +.hero p, .page-head p { margin: 0; max-width: 720px; color: var(--muted); font-size: 13px; } +.hero-stat { width: min(360px, 32vw); padding: 11px 14px; background: linear-gradient(135deg, rgba(66,232,213,.1), rgba(106,168,255,.05)); border: 1px solid var(--line); border-radius: 11px; } +.hero-stat span, .hero-stat small { display: block; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; } +.hero-stat strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 3px 0 5px; font-size: 14px; } +.hero-stat small.ready { color: var(--cyan); } + +.studio-grid { display: grid; grid-template-columns: 255px minmax(390px, 1.05fr) minmax(340px, .85fr); gap: 11px; align-items: start; } +.panel { background: linear-gradient(180deg, rgba(16,31,52,.98), rgba(11,23,40,.98)); border: 1px solid var(--line); border-radius: 11px; box-shadow: 0 10px 32px rgba(0,0,0,.16); } +.model-rail, .controls, .output { padding: 14px; } +.output { position: sticky; top: 69px; min-height: 470px; } +label { display: block; color: #b8cae4; font-size: 11px; font-weight: 650; margin: 11px 0 5px; } +label:first-child { margin-top: 0; } +label span { color: var(--muted); font-weight: 400; } +input, textarea, select { width: 100%; color: #edf5ff; background: #081426; border: 1px solid #2a405f; border-radius: 7px; padding: 7px 9px; outline: none; transition: border .15s, box-shadow .15s; font-size: 13px; } +textarea { resize: vertical; line-height: 1.4; } +input:focus, textarea:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(106,168,255,.12); } +input.file { padding: 6px; color: var(--muted); } +input.file::file-selector-button { border: 0; border-radius: 5px; color: #07101f; background: #a8c8f6; padding: 5px 8px; margin-right: 8px; cursor: pointer; font-weight: 700; } +.path-state { display: flex; justify-content: space-between; gap: 5px; color: var(--muted); font-size: 10px; padding: 6px 2px 1px; } +.path-state .good { color: var(--cyan); }.path-state .bad { color: var(--danger); } +.button-row { display: flex; gap: 7px; margin-top: 11px; } +button { border: 1px solid var(--line); border-radius: 7px; background: #14243b; padding: 7px 10px; cursor: pointer; transition: transform .12s, border-color .12s, background .12s; font-size: 13px; } +button:hover:not(:disabled) { transform: translateY(-1px); border-color: #4f719d; background: #1a304e; } +button:disabled { opacity: .45; cursor: not-allowed; } +button.primary, button.run { color: #041619; background: linear-gradient(135deg, var(--cyan), #5ac7ff); border: 0; font-weight: 800; } +.button-row button { flex: 1; } +.hint { margin-top: 12px; padding: 9px; border-left: 2px solid var(--blue); background: rgba(106,168,255,.07); color: var(--muted); font-size: 11px; line-height: 1.45; white-space: pre-wrap; } +.section-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.section-title span { color: var(--cyan); font-size: 10px; letter-spacing: .14em; font-weight: 800; } +.section-title h2 { font-size: 17px; margin: 2px 0 0; color: #f3f7ff; } +.task-chip { border: 1px solid var(--line); border-radius: 999px; padding: 5px 8px; color: var(--muted)!important; letter-spacing: .08em!important; text-transform: uppercase; } +.field-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 12px; } +.long-text-row { display: grid; grid-template-columns: 1fr 150px; gap: 10px; align-items: end; margin-top: 2px; } +.long-text-row > .toggle { margin: 0 0 8px; } +.media-actions { display: flex; align-items: center; gap: 7px; margin-top: 6px; } +.media-actions button { padding: 7px 10px; font-size: 11px; } +.media-actions > span { min-width: 0; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +button.danger { color: #ffd8df; border-color: rgba(255,119,139,.45); background: rgba(127,30,50,.25); } +.recording-dot::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: var(--danger); box-shadow: 0 0 9px var(--danger); } +.live-card { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 8px; padding: 9px; border: 1px solid rgba(66,232,213,.24); border-radius: 8px; background: rgba(66,232,213,.05); } +.live-card strong, .live-card small { display: block; } +.live-card strong { font-size: 12px; } +.live-card small { max-width: 380px; margin-top: 3px; color: var(--muted); font-size: 10px; line-height: 1.4; } +.voice-library { display: grid; grid-template-columns: 1fr 1fr auto; gap: 7px; align-items: end; margin-top: 2px; } +.library-actions { display: flex; gap: 6px; padding-bottom: 1px; } +.library-actions button { white-space: nowrap; } +details { margin-top: 11px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +summary { cursor: pointer; padding: 8px 10px; color: #b8cae4; font-size: 11px; font-weight: 700; background: rgba(5,13,26,.35); } +summary span { float: right; color: var(--muted); font-weight: 500; } +.parameter-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px 10px; padding: 0 10px 10px; } +.parameter-grid .wide { grid-column: 1/-1; } +.parameter-grid small { display: block; color: var(--muted); font-size: 10px; line-height: 1.4; margin-top: 5px; } +.range { display: grid; grid-template-columns: 1fr 46px; gap: 8px; align-items: center; } +.range input { padding: 0; accent-color: var(--cyan); } +.range output { font: 12px ui-monospace, monospace; text-align: right; color: var(--cyan); } +.toggle { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-weight: 500; } +.toggle input { position: absolute; opacity: 0; width: 1px; } +.toggle span { width: 34px; height: 19px; border-radius: 20px; background: #263b57; position: relative; } +.toggle span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 13px; height: 13px; border-radius: 50%; background: #b8cae4; transition: .15s; } +.toggle input:checked + span { background: #197f7c; }.toggle input:checked + span::after { transform: translateX(15px); background: white; } +.code, pre { font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; } +details > .code { border: 0; border-radius: 0; } +.runbar { display: grid; grid-template-columns: 120px 74px 1fr; gap: 7px; align-items: center; margin-top: 13px; } +.run { display: flex; align-items: center; justify-content: space-between; padding: 9px 11px; } +kbd { font: 9px ui-monospace, monospace; padding: 2px 4px; border: 1px solid rgba(0,0,0,.25); border-radius: 4px; } +.status { color: var(--muted); font-size: 12px; padding-left: 8px; overflow-wrap: anywhere; } +.status.busy { color: var(--cyan); } +.empty-output { min-height: 315px; display: grid; place-content: center; text-align: center; color: var(--muted); } +.empty-output .wave { font-size: 62px; line-height: .7; color: #24446b; } +.audio-list { display: grid; gap: 12px; } +.audio-list article { padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: #081426; } +.audio-list article > div { display: flex; justify-content: space-between; margin-bottom: 9px; font-size: 12px; } +.audio-list a { color: var(--cyan); text-decoration: none; } +audio { width: 100%; height: 38px; } +.transcript { margin-top: 14px; } +pre { background: #071220; border: 1px solid var(--line); border-radius: 8px; padding: 10px; overflow: auto; max-height: 250px; color: #9fbbdc; white-space: pre-wrap; overflow-wrap: anywhere; } + +.page-head { padding: 18px 4px 28px; }.page-head h1 { font-size: 42px; } +.installer-options { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(180px, .4fr) auto; gap: 12px; align-items: end; padding: 15px; margin-bottom: 13px; } +.installer-options label { margin-top: 0; } +.overwrite-toggle { margin: 0 4px 11px!important; white-space: nowrap; } +.model-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } +.model-grid article { display: grid; grid-template-columns: 58px 1fr auto; gap: 14px; align-items: center; padding: 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--panel); } +.model-grid article.selected { border-color: #3b718c; box-shadow: inset 0 0 0 1px rgba(66,232,213,.18); } +.model-icon { display: grid; place-items: center; height: 50px; border-radius: 10px; background: #122b45; color: var(--cyan); font-size: 10px; font-weight: 900; letter-spacing: .1em; } +.model-copy span, .model-actions small { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } +.model-copy h3 { font-size: 15px; margin: 4px 0; }.model-copy p { color: var(--muted); font: 11px ui-monospace, monospace; margin: 0; overflow-wrap: anywhere; } +.model-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; min-width: 210px; }.model-actions small { grid-column: 1/-1; text-align: right; } +.model-actions button { padding: 7px; font-size: 11px; } +.install-status { grid-column: 1/-1; max-width: 250px; color: var(--muted); font-size: 10px; line-height: 1.35; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.install-status strong { color: var(--blue); text-transform: uppercase; }.install-status.complete strong { color: var(--cyan); }.install-status.failed strong { color: var(--danger); } +.log-panel { padding: 20px; }.runtime-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; } +.runtime-cards div { padding: 16px; background: #081426; border: 1px solid var(--line); border-radius: 10px; } +.runtime-cards span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; }.runtime-cards strong { display: block; margin-top: 6px; font-size: 21px; text-transform: capitalize; } +.logs { min-height: 480px; max-height: 70vh; } +footer { display: flex; justify-content: space-between; max-width: 1600px; margin: auto; padding: 14px 20px 20px; border-top: 1px solid var(--line); color: #607a9d; font-size: 10px; } + +@media (max-width: 1200px) { + .workflow-tabs { grid-template-columns: repeat(7, minmax(150px, 1fr)); } + .studio-grid { grid-template-columns: 270px 1fr; } + .output { grid-column: 1/-1; position: static; min-height: 300px; } + .empty-output { min-height: 200px; } +} +@media (max-width: 820px) { + .topbar { padding: 0 14px; gap: 8px; }.brand { min-width: auto; }.brand span { display: none; } + nav { margin-left: auto; } nav button { padding: 0 10px; }.server-pill { display: none; } + main { padding: 22px 12px 40px; }.workflow-tabs { margin-bottom: 22px; }.hero { align-items: start; flex-direction: column; }.hero-stat { width: 100%; } + .studio-grid { grid-template-columns: 1fr; }.output { grid-column: auto; }.field-grid, .parameter-grid { grid-template-columns: 1fr; } + .voice-library { grid-template-columns: 1fr 1fr; }.library-actions { grid-column: 1/-1; } + .runbar { grid-template-columns: 1fr 90px; }.status { grid-column: 1/-1; } + .model-grid { grid-template-columns: 1fr; }.runtime-cards { grid-template-columns: repeat(2, 1fr); } + .installer-options { grid-template-columns: 1fr 1fr; }.overwrite-toggle { grid-column: 1/-1; } +} +@media (max-width: 520px) { + .brand strong { font-size: 14px; } nav button { font-size: 12px; padding: 0 7px; } + .model-grid article { grid-template-columns: 45px 1fr; }.model-actions { grid-column: 1/-1; min-width: 0; } + .installer-options { grid-template-columns: 1fr; } + .long-text-row, .voice-library { grid-template-columns: 1fr; }.long-text-row > .toggle { margin-top: 12px; } + footer { display: block; } footer span { display: block; margin-top: 5px; } +} diff --git a/webui/native/src/app.html b/webui/native/src/app.html new file mode 100644 index 00000000..ce24aa8f --- /dev/null +++ b/webui/native/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/webui/native/src/lib/api.ts b/webui/native/src/lib/api.ts new file mode 100644 index 00000000..5aa33dd5 --- /dev/null +++ b/webui/native/src/lib/api.ts @@ -0,0 +1,132 @@ +import type { LoadedModel, ServerHealth } from './types'; + +async function errorFrom(response: Response): Promise { + let message = `${response.status} ${response.statusText}`; + try { + const body = await response.json(); + message = body?.error?.message || body?.message || message; + } catch { + const text = await response.text(); + if (text) message = text; + } + return new Error(message); +} + +export async function jsonRequest( + path: string, + init: RequestInit = {}, + signal?: AbortSignal +): Promise { + const headers = new Headers(init.headers); + if (init.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + const response = await fetch(path, { ...init, headers, signal }); + if (!response.ok) throw await errorFrom(response); + return response.json() as Promise; +} + +export async function health(): Promise { + return jsonRequest('/health'); +} + +export async function models(): Promise { + const response = await jsonRequest<{ data: LoadedModel[] }>('/v1/models'); + return response.data; +} + +export async function loadModel(body: Record): Promise { + await jsonRequest('/v1/models/load', { method: 'POST', body: JSON.stringify(body) }); +} + +export async function unloadModel(id: string): Promise { + await jsonRequest('/v1/models/unload', { + method: 'POST', + body: JSON.stringify({ id }) + }); +} + +export async function pathStatus(path: string): Promise<{ exists: boolean; directory: boolean; file: boolean }> { + return jsonRequest('/v1/ui/path-status', { + method: 'POST', + body: JSON.stringify({ path }) + }); +} + +export interface ModelInstallJob { + id: string; + state: 'idle' | 'queued' | 'running' | 'complete' | 'failed'; + message: string; + exit_code: number; + started_at_ms: number; + finished_at_ms: number; +} + +export async function installModelPackage(body: { + id: string; + source_file?: string; + output_file?: string; + source_directory?: string; + variant?: string; + overwrite?: boolean; +}): Promise { + return jsonRequest('/v1/ui/models/install', { + method: 'POST', + body: JSON.stringify(body) + }); +} + +export async function modelInstallJobs(): Promise { + const response = await jsonRequest<{ data: ModelInstallJob[] }>('/v1/ui/models/install-status'); + return response.data; +} + +export async function uploadWav(blob: Blob, filename: string, signal?: AbortSignal): Promise { + const response = await jsonRequest<{ path: string }>('/v1/ui/upload', { + method: 'POST', + headers: { + 'Content-Type': 'audio/wav', + 'X-AudioCPP-Filename': filename + }, + body: blob + }, signal); + return response.path; +} + +export async function speech(body: Record, signal?: AbortSignal) { + const response = await fetch('/v1/audio/speech', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw await errorFrom(response); + return { + blob: await response.blob(), + wallMs: response.headers.get('X-AudioCPP-Wall-Ms'), + rtf: response.headers.get('X-AudioCPP-RTF') + }; +} + +export async function transcription(body: Record, signal?: AbortSignal) { + return jsonRequest>('/v1/audio/transcriptions', { + method: 'POST', + body: JSON.stringify(body) + }, signal); +} + +export async function runTask(body: Record, signal?: AbortSignal) { + return jsonRequest>('/v1/tasks/run', { + method: 'POST', + body: JSON.stringify(body) + }, signal); +} + +export function base64AudioUrl(data: string): string { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' })); +} diff --git a/webui/native/src/lib/audio.ts b/webui/native/src/lib/audio.ts new file mode 100644 index 00000000..9af714a3 --- /dev/null +++ b/webui/native/src/lib/audio.ts @@ -0,0 +1,88 @@ +function writeAscii(view: DataView, offset: number, text: string) { + for (let index = 0; index < text.length; index += 1) { + view.setUint8(offset + index, text.charCodeAt(index)); + } +} + +export function encodePcm16Wav(buffer: AudioBuffer): Blob { + const channels = buffer.numberOfChannels; + const frames = buffer.length; + const bytes = new ArrayBuffer(44 + frames * channels * 2); + const view = new DataView(bytes); + writeAscii(view, 0, 'RIFF'); + view.setUint32(4, 36 + frames * channels * 2, true); + writeAscii(view, 8, 'WAVE'); + writeAscii(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, buffer.sampleRate, true); + view.setUint32(28, buffer.sampleRate * channels * 2, true); + view.setUint16(32, channels * 2, true); + view.setUint16(34, 16, true); + writeAscii(view, 36, 'data'); + view.setUint32(40, frames * channels * 2, true); + + const samples = Array.from({ length: channels }, (_, channel) => buffer.getChannelData(channel)); + let offset = 44; + for (let frame = 0; frame < frames; frame += 1) { + for (let channel = 0; channel < channels; channel += 1) { + const sample = Math.max(-1, Math.min(1, samples[channel][frame])); + view.setInt16(offset, sample < 0 ? sample * 32768 : sample * 32767, true); + offset += 2; + } + } + return new Blob([bytes], { type: 'audio/wav' }); +} + +export async function concatenateAudioBlobs(blobs: Blob[]): Promise { + if (!blobs.length) throw new Error('No audio chunks were generated.'); + if (blobs.length === 1) return blobs[0]; + + const context = new AudioContext(); + try { + const decoded = await Promise.all(blobs.map(async (blob) => + context.decodeAudioData(await blob.arrayBuffer()))); + const sampleRate = decoded[0].sampleRate; + const channels = decoded[0].numberOfChannels; + for (const chunk of decoded) { + if (chunk.sampleRate !== sampleRate || chunk.numberOfChannels !== channels) { + throw new Error('Generated chunks use different audio formats and cannot be joined.'); + } + } + const totalFrames = decoded.reduce((sum, chunk) => sum + chunk.length, 0); + const merged = context.createBuffer(channels, totalFrames, sampleRate); + let offset = 0; + for (const chunk of decoded) { + for (let channel = 0; channel < channels; channel += 1) { + merged.copyToChannel(chunk.getChannelData(channel), channel, offset); + } + offset += chunk.length; + } + return encodePcm16Wav(merged); + } finally { + await context.close(); + } +} + +export async function browserDecodeToWav(file: File, targetSampleRate?: number): Promise { + if (!targetSampleRate && (file.type === 'audio/wav' || file.name.toLowerCase().endsWith('.wav'))) { + return file; + } + const context = new AudioContext(); + try { + const decoded = await context.decodeAudioData(await file.arrayBuffer()); + if (!targetSampleRate || decoded.sampleRate === targetSampleRate) { + return encodePcm16Wav(decoded); + } + const frames = Math.max(1, Math.ceil(decoded.duration * targetSampleRate)); + const offline = new OfflineAudioContext(decoded.numberOfChannels, frames, targetSampleRate); + const source = offline.createBufferSource(); + source.buffer = decoded; + source.connect(offline.destination); + source.start(); + return encodePcm16Wav(await offline.startRendering()); + } finally { + await context.close(); + } +} diff --git a/webui/native/src/lib/catalog.ts b/webui/native/src/lib/catalog.ts new file mode 100644 index 00000000..e1fdea26 --- /dev/null +++ b/webui/native/src/lib/catalog.ts @@ -0,0 +1,26 @@ +import rawCatalog from '../../../configs/models_catalog.json'; +import rawParams from '../../../configs/model_params.json'; +import type { CatalogEntry, ParamSpec } from './types'; + +export const catalog = (rawCatalog.models as CatalogEntry[]).map((entry) => ({ + ...entry, + display_name: entry.display_name_en || entry.display_name +})); + +export const parameterCatalog = rawParams as unknown as Record; + +export const taskLabels: Record = { + tts: 'Text to speech', + clon: 'Voice cloning', + asr: 'Transcription', + gen: 'Music & sound', + vc: 'Voice conversion', + svc: 'Singing conversion', + s2s: 'Speech editing', + sep: 'Source separation', + vad: 'Voice activity', + diar: 'Speaker diarization', + align: 'Forced alignment', + vdes: 'Voice design', + spk: 'Speaker analysis' +}; diff --git a/webui/native/src/lib/text.ts b/webui/native/src/lib/text.ts new file mode 100644 index 00000000..038a715a --- /dev/null +++ b/webui/native/src/lib/text.ts @@ -0,0 +1,61 @@ +const speakerLine = /^\s*(Speaker\s+\d+\s*:)\s*(.*)$/i; +const sentenceParts = /[^。!?!?;;…]*[。!?!?;;…]+|[^。!?!?;;…]+$/g; + +function splitLongLine(line: string, budget: number): string[] { + const match = speakerLine.exec(line); + const prefix = match ? `${match[1]} ` : ''; + const body = match ? match[2] : line.trim(); + const sentences = body.match(sentenceParts) || [body]; + const chunks: string[] = []; + let current = ''; + + for (const sentence of sentences) { + if (current && current.length + sentence.length > budget) { + chunks.push(prefix + current); + current = ''; + } + if (sentence.length <= budget) { + current += sentence; + continue; + } + if (current) { + chunks.push(prefix + current); + current = ''; + } + for (let offset = 0; offset < sentence.length; offset += budget) { + chunks.push(prefix + sentence.slice(offset, offset + budget)); + } + } + if (current) chunks.push(prefix + current); + return chunks.length ? chunks : [line]; +} + +export function splitTtsChunks(text: string, budget: number): string[] { + const units: string[] = []; + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + units.push(...(line.length > budget ? splitLongLine(line, budget) : [line])); + } + + const chunks: string[] = []; + let current: string[] = []; + let currentLength = 0; + for (const unit of units) { + const separator = current.length ? 1 : 0; + if (current.length && currentLength + separator + unit.length > budget) { + chunks.push(current.join('\n')); + current = []; + currentLength = 0; + } + current.push(unit); + currentLength += (current.length > 1 ? 1 : 0) + unit.length; + } + if (current.length) chunks.push(current.join('\n')); + return chunks.length ? chunks : text.trim() ? [text] : []; +} + +export function defaultChunkBudget(family: string): number { + if (family === 'vibevoice') return 600; + if (family === 'voxcpm2') return 60; + return 1000; +} diff --git a/webui/native/src/lib/types.ts b/webui/native/src/lib/types.ts new file mode 100644 index 00000000..53d67b63 --- /dev/null +++ b/webui/native/src/lib/types.ts @@ -0,0 +1,57 @@ +export type StringMap = Record; + +export interface CatalogEntry { + id: string; + display_name: string; + display_name_en?: string; + family: string; + path: string; + task: string; + mode: string; + download_id?: string; + min_vram_gb?: number; + input_hint?: string; + input_hint_en?: string; + default_options?: Record; + load_options?: StringMap; + session_options?: StringMap; +} + +export interface ParamSpec { + name: string; + type: 'slider' | 'number' | 'bool' | 'text' | 'choice'; + label: string; + label_en?: string; + info?: string; + info_en?: string; + default?: unknown; + minimum?: number; + maximum?: number; + step?: number; + choices?: Array; + placeholder?: string; + placeholder_en?: string; + lines?: number; +} + +export interface LoadedModel { + id: string; + family: string; + task: string; + mode: string; + path: string; + loaded: boolean; +} + +export interface ServerHealth { + status: string; + backend: string; + models: number; + ui: boolean; + ui_management: boolean; +} + +export interface AudioOutput { + id: string; + url: string; +} diff --git a/webui/native/src/lib/voices.ts b/webui/native/src/lib/voices.ts new file mode 100644 index 00000000..a71d9aff --- /dev/null +++ b/webui/native/src/lib/voices.ts @@ -0,0 +1,53 @@ +export interface SavedVoice { + id: string; + name: string; + transcript: string; + audio: Blob; + createdAt: number; +} + +const databaseName = 'audiocpp-native-studio'; +const storeName = 'voices'; + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(storeName)) { + request.result.createObjectStore(storeName, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Could not open the voice library.')); + }); +} + +function transaction( + mode: IDBTransactionMode, + operation: (store: IDBObjectStore) => IDBRequest +): Promise { + return openDatabase().then((database) => new Promise((resolve, reject) => { + const tx = database.transaction(storeName, mode); + const request = operation(tx.objectStore(storeName)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Voice library operation failed.')); + tx.oncomplete = () => database.close(); + tx.onerror = () => { + database.close(); + reject(tx.error || new Error('Voice library transaction failed.')); + }; + })); +} + +export async function listVoices(): Promise { + const voices = await transaction('readonly', (store) => store.getAll()); + return voices.sort((left, right) => right.createdAt - left.createdAt); +} + +export function saveVoice(voice: SavedVoice): Promise { + return transaction('readwrite', (store) => store.put(voice)); +} + +export function deleteVoice(id: string): Promise { + return transaction('readwrite', (store) => store.delete(id)); +} diff --git a/webui/native/src/routes/+page.svelte b/webui/native/src/routes/+page.svelte new file mode 100644 index 00000000..c9c3dfb7 --- /dev/null +++ b/webui/native/src/routes/+page.svelte @@ -0,0 +1,965 @@ + + +audio.cpp · Native Studio + + +
+
+
A
+
+ audio.cpp + Native Studio +
+
+ +
+ {server?.backend || 'offline'} +
+
+ +
+ {#if tab === 'studio'} + + +
+
+

LOCAL AUDIO INTELLIGENCE

+

{taskLabels[selected?.task] || 'Audio studio'}

+

One native server, one embedded interface, no Python between your browser and the model.

+
+
+ Model + {selected?.display_name} + {isLoaded ? 'Resident' : installed === false ? 'Not installed' : 'Available'} +
+
+ +
+ + +
+
+
REQUEST

Input & controls

+ {selected?.task} +
+ + {#if showsText} + + + {/if} + + {#if ['tts', 'clon'].includes(selected.task)} +
+ +
+ + +
+
+ {/if} + + {#if selected.task === 'gen'} + + + {/if} + + {#if selected.task === 'asr'} + + + {/if} + + {#if selected.task === 'vdes'} + + + {/if} + +
+
+ + +
+
+ + +
+
+ + +
+ {#if selected.task === 'gen'} +
+ + +
+ {/if} +
+ + {#if acceptsSource} + + sourceFile = event.currentTarget.files?.[0] || null} /> +
+ {#if recordingTarget === 'source'} + + Recording microphone + {:else} + + {#if sourceFile}{sourceFile.name}{/if} + {/if} +
+ {#if supportsLiveAsr} +
+
+ Live microphone transcription + Processes consecutive four-second requests using the model's streaming mode. +
+ {#if liveRecording} + + {:else} + + {/if} +
+ {/if} + {/if} + + {#if needsVoice} + + voiceFile = event.currentTarget.files?.[0] || null} /> +
+ {#if recordingTarget === 'voice'} + + Recording voice reference + {:else} + + {#if voiceFile}{voiceFile.name}{/if} + {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + {#if paramSpecs.length} +
+ Model parameters {paramSpecs.length} +
+ {#each paramSpecs as spec} +
+ + {#if spec.type === 'bool'} + + {:else if spec.type === 'choice'} + + {:else if spec.type === 'slider'} +
+ advancedValues = {...advancedValues, [spec.name]: event.currentTarget.valueAsNumber}} /> + {String(advancedValues[spec.name])} +
+ {:else} + advancedValues = {...advancedValues, + [spec.name]: spec.type === 'number' ? event.currentTarget.valueAsNumber : event.currentTarget.value}} /> + {/if} + {#if spec.info_en || spec.info}{spec.info_en || spec.info}{/if} +
+ {/each} +
+
+ {/if} + +
+ Additional options JSON + +
+ +
+ + +
{status}
+
+
+ +
+
+
RESULT

Output

+ {#if outputAudio.length}{outputAudio.length} track{outputAudio.length === 1 ? '' : 's'}{/if} +
+ {#if outputAudio.length} +
+ {#each outputAudio as output} + + {/each} +
+ {:else} +

Generated audio and structured results appear here.

+ {/if} + {#if outputText}{/if} + {#if outputJson}
{outputJson}
{/if} +
+
+ {:else if tab === 'models'} +
+

MODEL LIBRARY

Local packages

+

Download and prepare packages without leaving the native interface. Normal downloads use model_manager_v2; specialized converter inputs use the deprecated manager until those workflows migrate.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ {#each catalog as entry} + {@const installJob = entry.download_id ? installJobs[entry.download_id] : undefined} +
+
{entry.task.toUpperCase()}
+
+ {taskLabels[entry.task] || entry.task} +

{entry.display_name}

+

{entry.path}

+
+
+ {entry.min_vram_gb || '?'} GB + + {#if entry.download_id} + + {#if installJob} +
+ {installJob.state} {installJob.message} +
+ {/if} + {/if} +
+
+ {/each} +
+ {:else} +

RUNTIME

Session log

Browser-side lifecycle and request events.

+
+
+
Status{server?.status || 'offline'}
+
Backend{server?.backend || '—'}
+
Registered{loadedModels.length}
+
Resident{loadedModels.filter((model) => model.loaded).length}
+
+
{logs.length ? logs.join('\n') : 'No events yet.'}
+
+ {/if} +
+ +
audio.cpp native WebUISvelteKit · embedded in audiocpp_server
diff --git a/webui/native/src/routes/+page.ts b/webui/native/src/routes/+page.ts new file mode 100644 index 00000000..ceccaaf6 --- /dev/null +++ b/webui/native/src/routes/+page.ts @@ -0,0 +1,2 @@ +export const prerender = true; +export const ssr = false; diff --git a/webui/native/svelte.config.js b/webui/native/svelte.config.js new file mode 100644 index 00000000..303b3035 --- /dev/null +++ b/webui/native/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + pages: 'dist', + assets: 'dist', + fallback: 'index.html', + strict: true + }), + output: { + bundleStrategy: 'inline' + }, + paths: { + relative: true + } + } +}; + +export default config; diff --git a/webui/native/tsconfig.json b/webui/native/tsconfig.json new file mode 100644 index 00000000..43447105 --- /dev/null +++ b/webui/native/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/webui/native/vite.config.ts b/webui/native/vite.config.ts new file mode 100644 index 00000000..a300180c --- /dev/null +++ b/webui/native/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + proxy: { + '/health': 'http://127.0.0.1:8080', + '/v1': 'http://127.0.0.1:8080' + } + }, + build: { + target: 'es2022' + } +}); From 684832bc3720e39f9c9b42441c720a07c31a9a83 Mon Sep 17 00:00:00 2001 From: mirek190 Date: Wed, 5 Aug 2026 09:01:22 +0100 Subject: [PATCH 2/4] Improve native WebUI model package management --- app/server/main.cpp | 26 +- app/server/model_installer.cpp | 254 ++++++++++++++- app/server/model_installer.h | 2 + app/server/runtime.cpp | 108 ++++++- app/server/runtime.h | 7 +- model_specs/pocket_tts.json | 3 +- .../unittests/test_server_model_installer.cpp | 75 +++++ tools/model_manager_v2.py | 196 ++++++++++- webui/README.md | 15 +- webui/model_manager_webui.py | 196 ++++++++++- webui/native/dist/index.html | 22 +- webui/native/src/app.css | 30 +- webui/native/src/lib/api.ts | 28 ++ webui/native/src/lib/catalog.ts | 136 +++++++- webui/native/src/lib/types.ts | 9 + webui/native/src/routes/+page.svelte | 303 ++++++++++++++++-- webui/test_model_manager_webui.py | 106 +++++- 17 files changed, 1419 insertions(+), 97 deletions(-) diff --git a/app/server/main.cpp b/app/server/main.cpp index 8daf7449..a995ce25 100644 --- a/app/server/main.cpp +++ b/app/server/main.cpp @@ -41,6 +41,22 @@ bool has_arg(int argc, char ** argv, const std::string & name) { return false; } +std::filesystem::path executable_directory(const char * argv0) { + if (argv0 == nullptr || *argv0 == '\0') { + return std::filesystem::current_path(); + } + std::error_code ec; + auto path = std::filesystem::absolute(std::filesystem::path(argv0), ec); + if (ec) { + return std::filesystem::current_path(); + } + path = path.lexically_normal(); + if (std::filesystem::is_regular_file(path, ec)) { + return path.parent_path(); + } + return std::filesystem::current_path(); +} + void print_help() { std::cout << "audiocpp_server [--config ] [--ui] [--host ] [--port ] [--backend ]\n" @@ -66,7 +82,9 @@ void print_help() { << " POST /v1/models/unload available with --ui-management\n" << " POST /v1/ui/upload available with --ui-management\n" << " POST /v1/ui/models/install background package download/preparation\n" + << " POST /v1/ui/models/delete remove one installed package precision\n" << " GET /v1/ui/models/install-status[?id=]\n" + << " GET /v1/ui/models/package-sizes package sizes from metadata-only checks\n" << " GET /v1/audio/voices?model=\n" << " POST /v1/audio/speech\n" << " POST /v1/audio/transcriptions\n" @@ -155,7 +173,13 @@ int main(int argc, char ** argv) { throw std::runtime_error("--busy-timeout-ms must be >= 0 (0 disables the guard)"); } - minitts::server::ServerState state(config, std::filesystem::current_path()); + const auto ui_resource_anchor = !config_path.has_value() + ? executable_directory(argc > 0 ? argv[0] : nullptr) + : std::filesystem::path{}; + minitts::server::ServerState state( + config, + std::filesystem::current_path(), + ui_resource_anchor); minitts::server::serve_http(config.host, config.port, state, shutdown_requested, config.max_request_body_bytes); return 0; } catch (const std::exception & ex) { diff --git a/app/server/model_installer.cpp b/app/server/model_installer.cpp index 3fd7aa02..8f272b6f 100644 --- a/app/server/model_installer.cpp +++ b/app/server/model_installer.cpp @@ -1,5 +1,6 @@ #include "model_installer.h" +#include #include #include #include @@ -94,14 +95,14 @@ std::string python_command() { #endif } -std::string read_log_tail(const std::filesystem::path & path) { +std::string read_log_tail_text(const std::filesystem::path & path) { std::ifstream input(path, std::ios::binary); if (!input) { return {}; } input.seekg(0, std::ios::end); const auto size = input.tellg(); - constexpr std::streamoff kTailBytes = 8192; + constexpr std::streamoff kTailBytes = 65536; if (size > kTailBytes) { input.seekg(size - kTailBytes); } else { @@ -109,12 +110,63 @@ std::string read_log_tail(const std::filesystem::path & path) { } std::ostringstream content; content << input.rdbuf(); - std::string text = content.str(); - while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) { - text.pop_back(); + return content.str(); +} + +struct LogSnapshot { + std::string message; + uint64_t downloaded_bytes = 0; + uint64_t total_bytes = 0; + bool has_progress = false; +}; + +LogSnapshot inspect_log(const std::filesystem::path & path) { + LogSnapshot snapshot; + std::istringstream lines(read_log_tail_text(path)); + std::string line; + constexpr std::string_view marker = "AUDIOCPP_PROGRESS "; + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (line.empty()) { + continue; + } + if (line.rfind(marker.data(), 0) != 0) { + snapshot.message = line; + continue; + } + uint64_t downloaded = 0; + uint64_t total = 0; + bool parsed_downloaded = false; + std::istringstream fields(line.substr(marker.size())); + std::string field; + while (fields >> field) { + const auto separator = field.find('='); + if (separator == std::string::npos) { + continue; + } + try { + const auto value = std::stoull(field.substr(separator + 1)); + const auto name = field.substr(0, separator); + if (name == "downloaded") { + downloaded = value; + parsed_downloaded = true; + } else if (name == "total") { + total = value; + } + } catch (const std::exception &) { + // A partially written progress line is expected while the worker is + // appending to the log. Keep the previous complete marker. + } + } + if (parsed_downloaded) { + snapshot.downloaded_bytes = downloaded; + snapshot.total_bytes = total; + snapshot.has_progress = true; + } } - const auto newline = text.find_last_of("\r\n"); - return newline == std::string::npos ? text : text.substr(newline + 1); + return snapshot; } int64_t now_ms() { @@ -133,6 +185,8 @@ struct ModelInstaller::State { int exit_code = -1; int64_t started_at_ms = 0; int64_t finished_at_ms = 0; + uint64_t downloaded_bytes = 0; + uint64_t total_bytes = 0; }; std::filesystem::path repository_root; @@ -140,6 +194,12 @@ struct ModelInstaller::State { std::filesystem::path job_root; mutable std::mutex mutex; std::map jobs; + std::string size_state = "idle"; + std::string size_message = "Package sizes have not been checked"; + std::filesystem::path size_output_path; + std::filesystem::path size_error_path; + std::filesystem::path installed_output_path; + uint64_t size_generation = 0; }; ModelInstaller::ModelInstaller( @@ -151,6 +211,9 @@ ModelInstaller::ModelInstaller( state_->job_root = std::filesystem::temp_directory_path() / "audiocpp-model-installer"; std::filesystem::create_directories(state_->job_root); std::filesystem::create_directories(state_->models_root); + state_->size_output_path = state_->job_root / "package-sizes.json"; + state_->size_error_path = state_->job_root / "package-sizes.log"; + state_->installed_output_path = state_->job_root / "installed-packages.json"; } ModelInstaller::~ModelInstaller() = default; @@ -228,9 +291,12 @@ std::string ModelInstaller::start( job.started_at_ms = now_ms(); } - std::string command = python_command() + " " + shell_quote(script.string()) + + std::string command = python_command() + " -u " + shell_quote(script.string()) + " install " + shell_quote(package_id) + " --models-root " + shell_quote(shared->models_root.string()); + if (!legacy_conversion) { + command += " --progress"; + } if (overwrite) { command += " --overwrite"; } @@ -249,14 +315,16 @@ std::string ModelInstaller::start( command += " > " + shell_quote(log_path.string()) + " 2>&1"; const int result = std::system(command.c_str()); - const std::string last_line = read_log_tail(log_path); + const auto log = inspect_log(log_path); std::lock_guard lock(shared->mutex); auto & job = shared->jobs.at(package_id); job.exit_code = result; job.finished_at_ms = now_ms(); job.state = result == 0 ? "complete" : "failed"; - job.message = !last_line.empty() - ? last_line + job.downloaded_bytes = log.downloaded_bytes; + job.total_bytes = log.total_bytes; + job.message = !log.message.empty() + ? log.message : (result == 0 ? "Model installation completed" : "Model installation failed"); } catch (const std::exception & error) { std::lock_guard lock(shared->mutex); @@ -275,16 +343,33 @@ std::string ModelInstaller::status(const std::string & package_id) const { std::lock_guard lock(state_->mutex); auto job_json = [](const State::Job & job) { std::string message = job.message; + uint64_t downloaded_bytes = job.downloaded_bytes; + uint64_t total_bytes = job.total_bytes; if (job.state == "running") { - const auto last_line = read_log_tail(job.log_path); - if (!last_line.empty()) { - message = last_line; + const auto log = inspect_log(job.log_path); + if (!log.message.empty()) { + message = log.message; } + if (log.has_progress) { + downloaded_bytes = log.downloaded_bytes; + total_bytes = log.total_bytes; + } + } + int progress_percent = -1; + if (job.state == "queued") { + progress_percent = 0; + } else if (job.state == "complete") { + progress_percent = 100; + } else if (total_bytes > 0) { + progress_percent = static_cast(std::min(100, downloaded_bytes * 100 / total_bytes)); } return std::string("{\"id\":") + json_quote(job.package_id) + ",\"state\":" + json_quote(job.state) + ",\"message\":" + json_quote(message) + ",\"exit_code\":" + std::to_string(job.exit_code) + + ",\"downloaded_bytes\":" + std::to_string(downloaded_bytes) + + ",\"total_bytes\":" + std::to_string(total_bytes) + + ",\"progress_percent\":" + std::to_string(progress_percent) + ",\"started_at_ms\":" + std::to_string(job.started_at_ms) + ",\"finished_at_ms\":" + std::to_string(job.finished_at_ms) + "}"; }; @@ -294,6 +379,7 @@ std::string ModelInstaller::status(const std::string & package_id) const { if (found == state_->jobs.end()) { return "{\"id\":" + json_quote(package_id) + ",\"state\":\"idle\",\"message\":\"Not started\",\"exit_code\":-1," + "\"downloaded_bytes\":0,\"total_bytes\":0,\"progress_percent\":-1," "\"started_at_ms\":0,\"finished_at_ms\":0}"; } return job_json(found->second); @@ -311,4 +397,144 @@ std::string ModelInstaller::status(const std::string & package_id) const { return result + "]}"; } +std::string ModelInstaller::remove(const std::string & package_id) { + if (!valid_package_id(package_id)) { + throw std::runtime_error("invalid model-manager package id"); + } + const auto script = state_->repository_root / "tools" / "model_manager_v2.py"; + if (!std::filesystem::is_regular_file(script)) { + throw std::runtime_error("model removal helper was not found at " + script.string()); + } + { + std::lock_guard lock(state_->mutex); + const auto job = state_->jobs.find(package_id); + if (job != state_->jobs.end() && + (job->second.state == "queued" || job->second.state == "running")) { + throw std::runtime_error("installation is still running for " + package_id); + } + } + + const auto log_path = state_->job_root / (package_id + "-uninstall.log"); + std::string command = python_command() + " -u " + shell_quote(script.string()) + + " uninstall " + shell_quote(package_id) + + " --models-root " + shell_quote(state_->models_root.string()) + + " > " + shell_quote(log_path.string()) + " 2>&1"; + const int result = std::system(command.c_str()); + std::string message = read_log_tail_text(log_path); + while (!message.empty() && std::isspace(static_cast(message.back()))) { + message.pop_back(); + } + if (result != 0) { + throw std::runtime_error(message.empty() ? "model package removal failed" : message); + } + { + std::lock_guard lock(state_->mutex); + state_->jobs.erase(package_id); + ++state_->size_generation; + state_->size_state = "idle"; + state_->size_message = "Package inventory will be refreshed"; + } + return "{\"id\":" + json_quote(package_id) + + ",\"removed\":true,\"message\":" + + json_quote(message.empty() ? "Model package removed" : message) + "}"; +} + +std::string ModelInstaller::package_sizes() { + bool start_scan = false; + uint64_t scan_generation = 0; + std::filesystem::path size_output_path; + std::filesystem::path size_error_path; + std::filesystem::path installed_output_path; + { + std::lock_guard lock(state_->mutex); + if (state_->size_state == "idle") { + scan_generation = ++state_->size_generation; + const auto suffix = std::to_string(scan_generation); + state_->size_output_path = state_->job_root / ("package-sizes-" + suffix + ".json"); + state_->size_error_path = state_->job_root / ("package-sizes-" + suffix + ".log"); + state_->installed_output_path = state_->job_root / ("installed-packages-" + suffix + ".json"); + size_output_path = state_->size_output_path; + size_error_path = state_->size_error_path; + installed_output_path = state_->installed_output_path; + state_->size_state = "running"; + state_->size_message = "Checking package sizes"; + start_scan = true; + std::ofstream(state_->size_output_path, std::ios::trunc); + std::ofstream(state_->size_error_path, std::ios::trunc); + std::ofstream(state_->installed_output_path, std::ios::trunc); + } + } + + if (start_scan) { + const auto script = state_->repository_root / "tools" / "model_manager_v2.py"; + if (!std::filesystem::is_regular_file(script)) { + std::lock_guard lock(state_->mutex); + if (state_->size_generation == scan_generation) { + state_->size_state = "failed"; + state_->size_message = "Package size helper was not found at " + script.string(); + } + } else { + const auto shared = state_; + std::thread([shared, script, scan_generation, size_output_path, size_error_path, + installed_output_path]() { + try { + std::string installed_command = python_command() + " -u " + shell_quote(script.string()) + + " installed --json --models-root " + shell_quote(shared->models_root.string()) + + " > " + shell_quote(installed_output_path.string()) + + " 2>> " + shell_quote(size_error_path.string()); + (void) std::system(installed_command.c_str()); + std::string command = python_command() + " -u " + shell_quote(script.string()) + + " sizes --json --models-root " + shell_quote(shared->models_root.string()) + + " > " + shell_quote(size_output_path.string()) + + " 2> " + shell_quote(size_error_path.string()); + const int result = std::system(command.c_str()); + std::string output = read_log_tail_text(size_output_path); + while (!output.empty() && std::isspace(static_cast(output.back()))) { + output.pop_back(); + } + const bool valid_output = !output.empty() && output.front() == '[' && output.back() == ']'; + std::lock_guard lock(shared->mutex); + if (shared->size_generation != scan_generation) { + return; + } + if (result == 0 && valid_output) { + shared->size_state = "complete"; + shared->size_message = "Package sizes are ready"; + } else { + shared->size_state = "failed"; + shared->size_message = read_log_tail_text(size_error_path); + if (shared->size_message.empty()) { + shared->size_message = "Package size check failed"; + } + } + } catch (const std::exception & error) { + std::lock_guard lock(shared->mutex); + if (shared->size_generation == scan_generation) { + shared->size_state = "failed"; + shared->size_message = error.what(); + } + } + }).detach(); + } + } + + std::lock_guard lock(state_->mutex); + std::string data = "[]"; + if (state_->size_state == "complete" || state_->size_state == "running") { + const auto & source = state_->size_state == "complete" + ? state_->size_output_path + : state_->installed_output_path; + data = read_log_tail_text(source); + while (!data.empty() && std::isspace(static_cast(data.back()))) { + data.pop_back(); + } + if (data.empty() || data.front() != '[' || data.back() != ']') { + data = "[]"; + } + } + return "{\"state\":" + json_quote(state_->size_state) + + ",\"message\":" + json_quote(state_->size_message) + + ",\"data\":" + data + "}"; +} + } // namespace minitts::server diff --git a/app/server/model_installer.h b/app/server/model_installer.h index c7bb92b5..edd2ad5c 100644 --- a/app/server/model_installer.h +++ b/app/server/model_installer.h @@ -26,6 +26,8 @@ class ModelInstaller { const std::string & variant, bool overwrite); std::string status(const std::string & package_id = {}) const; + std::string package_sizes(); + std::string remove(const std::string & package_id); private: struct State; diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 0bc6a409..11563634 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -672,11 +672,79 @@ engine::runtime::TaskRequest build_openai_transcription_request( return request; } +template +std::optional find_ancestor( + std::filesystem::path start, + Predicate predicate) { + std::error_code ec; + start = std::filesystem::absolute(std::move(start), ec).lexically_normal(); + if (ec) { + return std::nullopt; + } + for (;;) { + if (predicate(start)) { + return start; + } + const auto parent = start.parent_path(); + if (parent.empty() || parent == start) { + return std::nullopt; + } + start = parent; + } +} + +template +std::optional find_from_roots( + const std::filesystem::path & request_base, + const std::filesystem::path & resource_anchor, + Predicate predicate) { + if (auto found = find_ancestor(request_base, predicate)) { + return found; + } + if (!resource_anchor.empty()) { + return find_ancestor(resource_anchor, predicate); + } + return std::nullopt; +} + +std::optional find_model_base( + const std::filesystem::path & request_base, + const std::filesystem::path & resource_anchor) { + std::optional empty_fallback; + for (const auto & seed : {request_base, resource_anchor}) { + if (seed.empty()) { + continue; + } + auto cursor = std::filesystem::absolute(seed).lexically_normal(); + for (;;) { + const auto models = cursor / "models"; + std::error_code ec; + if (std::filesystem::is_directory(models, ec)) { + if (!std::filesystem::is_empty(models, ec)) { + return cursor; + } + if (!empty_fallback.has_value()) { + empty_fallback = cursor; + } + } + const auto parent = cursor.parent_path(); + if (parent.empty() || parent == cursor) { + break; + } + cursor = parent; + } + } + return empty_fallback; +} + } // namespace -ServerState::ServerState(ServerConfig config, std::filesystem::path request_base) +ServerState::ServerState( + ServerConfig config, + std::filesystem::path request_base, + std::filesystem::path ui_resource_anchor) : config_(std::move(config)), - request_base_(std::move(request_base)) { + request_base_(std::filesystem::absolute(std::move(request_base)).lexically_normal()) { if (config_.backend != engine::core::BackendType::Cuda) { std::cerr << "audio.cpp is optimized for CUDA. The " @@ -684,13 +752,25 @@ ServerState::ServerState(ServerConfig config, std::filesystem::path request_base << " server backend is intended for portability and testing, but performance and model coverage may be lower than CUDA.\n"; } if (config_.ui_management) { + const auto repository_root = find_from_roots( + request_base_, + ui_resource_anchor, + [](const std::filesystem::path & root) { + return std::filesystem::is_regular_file(root / "tools" / "model_manager_v2.py") && + std::filesystem::is_directory(root / "model_specs"); + }).value_or(request_base_); + const auto model_base = find_model_base(request_base_, ui_resource_anchor).value_or(repository_root); + request_base_ = model_base; + std::cerr + << "native WebUI model root: " << (request_base_ / "models") << "\n" + << "native WebUI package resources: " << repository_root << "\n"; upload_root_ = std::filesystem::temp_directory_path() / ("audiocpp-ui-" + std::to_string( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count())); std::filesystem::create_directories(upload_root_); model_installer_ = std::make_unique( - request_base_, + repository_root, request_base_ / "models"); } load_models(); @@ -757,9 +837,15 @@ HttpResponse ServerState::handle(const HttpRequest & request) { else if (request.method == "POST" && request.path == "/v1/ui/models/install") { response = handle_model_install(request.body); } + else if (request.method == "POST" && request.path == "/v1/ui/models/delete") { + response = handle_model_remove(request.body); + } else if (request.method == "GET" && request.path == "/v1/ui/models/install-status") { response = handle_model_install_status(request); } + else if (request.method == "GET" && request.path == "/v1/ui/models/package-sizes") { + response = handle_model_package_sizes(); + } else if (request.method == "POST" && request.path == "/v1/audio/speech") { response = handle_speech(request.body); } @@ -975,6 +1061,15 @@ HttpResponse ServerState::handle_model_install(const std::string & body_text) { overwrite)); } +HttpResponse ServerState::handle_model_remove(const std::string & body_text) { + if (!config_.ui_management || !model_installer_) { + return error_response(403, "UI model removal is disabled", "forbidden"); + } + const auto body = engine::io::json::parse(body_text); + const std::string package_id = engine::io::json::require_string(body, "id"); + return json_response(model_installer_->remove(package_id)); +} + HttpResponse ServerState::handle_model_install_status(const HttpRequest & request) const { if (!config_.ui_management || !model_installer_) { return error_response(403, "UI model installation is disabled", "forbidden"); @@ -982,6 +1077,13 @@ HttpResponse ServerState::handle_model_install_status(const HttpRequest & reques return json_response(model_installer_->status(query_param(request.query, "id"))); } +HttpResponse ServerState::handle_model_package_sizes() { + if (!config_.ui_management || !model_installer_) { + return error_response(403, "UI model installation is disabled", "forbidden"); + } + return json_response(model_installer_->package_sizes()); +} + HttpResponse ServerState::handle_ui_asset() const { if (!config_.ui_enabled) { return error_response(404, "WebUI is disabled", "not_found"); diff --git a/app/server/runtime.h b/app/server/runtime.h index 3f743771..88409b8e 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -25,7 +25,10 @@ namespace minitts::server { class ServerState final : public IHttpHandler { public: - ServerState(ServerConfig config, std::filesystem::path request_base); + ServerState( + ServerConfig config, + std::filesystem::path request_base, + std::filesystem::path ui_resource_anchor = {}); ~ServerState() override; HttpResponse handle(const HttpRequest & request) override; @@ -75,7 +78,9 @@ class ServerState final : public IHttpHandler { HttpResponse handle_path_status(const std::string & body_text) const; HttpResponse handle_ui_upload(const HttpRequest & request); HttpResponse handle_model_install(const std::string & body_text); + HttpResponse handle_model_remove(const std::string & body_text); HttpResponse handle_model_install_status(const HttpRequest & request) const; + HttpResponse handle_model_package_sizes(); HttpResponse handle_ui_asset() const; LoadedModel::RuntimeVoicePreset load_runtime_voice_preset(const ServerModelConfig::VoicePreset & preset) const; void load_voice_presets(LoadedModel & model) const; diff --git a/model_specs/pocket_tts.json b/model_specs/pocket_tts.json index 65f66d06..305fcaef 100644 --- a/model_specs/pocket_tts.json +++ b/model_specs/pocket_tts.json @@ -198,7 +198,8 @@ ], "download": { "kind": "huggingface_snapshot", - "repo": "kyutai/pocket-tts" + "repo": "kyutai/pocket-tts", + "gated": true } } ], diff --git a/tests/unittests/test_server_model_installer.cpp b/tests/unittests/test_server_model_installer.cpp index 97a688b3..fa2583fc 100644 --- a/tests/unittests/test_server_model_installer.cpp +++ b/tests/unittests/test_server_model_installer.cpp @@ -2,9 +2,11 @@ #include #include +#include #include #include #include +#include namespace { @@ -29,6 +31,7 @@ void test_idle_status_and_validation() { minitts::server::ModelInstaller installer(root, root / "models"); const auto idle = installer.status("qwen3_asr_0_6b"); require(idle.find("\"state\":\"idle\"") != std::string::npos, "unknown jobs report idle"); + require(idle.find("\"progress_percent\":-1") != std::string::npos, "idle jobs have no progress"); bool invalid_rejected = false; try { @@ -47,6 +50,78 @@ void test_idle_status_and_validation() { } require(missing_helper_reported, "a missing v2 preparation helper has a useful error"); + std::filesystem::create_directories(root / "tools"); + { + std::ofstream script(root / "tools" / "model_manager_v2.py", std::ios::binary); + script + << "import sys, time\n" + << "if len(sys.argv) > 1 and sys.argv[1] == 'sizes':\n" + << " time.sleep(0.15)\n" + << " print('[{\\\"id\\\":\\\"demo_q8_0\\\",\\\"size_bytes\\\":25,\\\"state\\\":\\\"ok\\\",\\\"message\\\":\\\"\\\",\\\"installed\\\":true}]', flush=True)\n" + << " raise SystemExit(0)\n" + << "if len(sys.argv) > 1 and sys.argv[1] == 'installed':\n" + << " print('[{\\\"id\\\":\\\"demo_q8_0\\\",\\\"size_bytes\\\":null,\\\"state\\\":\\\"pending\\\",\\\"message\\\":\\\"\\\",\\\"installed\\\":true}]', flush=True)\n" + << " raise SystemExit(0)\n" + << "if len(sys.argv) > 1 and sys.argv[1] == 'uninstall':\n" + << " print('removed test package', flush=True)\n" + << " raise SystemExit(0)\n" + << "print('preparing package', flush=True)\n" + << "print('AUDIOCPP_PROGRESS downloaded=25 total=100', flush=True)\n" + << "time.sleep(0.35)\n" + << "print('AUDIOCPP_PROGRESS downloaded=100 total=100', flush=True)\n" + << "print('installed test package', flush=True)\n"; + } + const auto started = installer.start("qwen3_asr_0_6b", "", "", "", "", false); + require(started.find("\"state\":\"queued\"") != std::string::npos || + started.find("\"state\":\"running\"") != std::string::npos, + "a valid install starts in the background"); + bool observed_progress = false; + bool completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto current = installer.status("qwen3_asr_0_6b"); + observed_progress = observed_progress || + current.find("\"downloaded_bytes\":25") != std::string::npos; + if (current.find("\"state\":\"complete\"") != std::string::npos) { + require(current.find("\"downloaded_bytes\":100") != std::string::npos, + "completed jobs preserve downloaded bytes"); + require(current.find("\"total_bytes\":100") != std::string::npos, + "completed jobs preserve total bytes"); + require(current.find("\"progress_percent\":100") != std::string::npos, + "completed jobs report 100 percent"); + completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(observed_progress, "running progress markers are exposed through status JSON"); + require(completed, "the background test installation completes"); + + const auto initial_sizes = installer.package_sizes(); + require(initial_sizes.find("\"state\":\"running\"") != std::string::npos, + "package size scan starts in the background"); + const auto removed = installer.remove("qwen3_asr_0_6b"); + require(removed.find("\"removed\":true") != std::string::npos, + "a package can be removed while an older metadata scan is running"); + require(removed.find("removed test package") != std::string::npos, + "package removal reports the manager result"); + + bool sizes_completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto sizes = installer.package_sizes(); + if (sizes.find("\"state\":\"complete\"") != std::string::npos) { + require(sizes.find("\"id\":\"demo_q8_0\"") != std::string::npos, + "package size metadata includes its package id"); + require(sizes.find("\"size_bytes\":25") != std::string::npos, + "package size metadata includes the summed byte count"); + require(sizes.find("\"installed\":true") != std::string::npos, + "package metadata includes its installed state"); + sizes_completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(sizes_completed, "the background package size scan completes"); + bool missing_legacy_helper_reported = false; try { (void) installer.start("qwen3_asr_0_6b", "checkpoint.bin", "", "", "", false); diff --git a/tools/model_manager_v2.py b/tools/model_manager_v2.py index 63af948b..ea47f105 100644 --- a/tools/model_manager_v2.py +++ b/tools/model_manager_v2.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor import json import os import shutil @@ -165,15 +166,17 @@ def check_remote_file(package: PackageRecord, remote_path: str) -> int | None: raise ManagerError(f"remote file is not accessible: {repo}/{remote_path} ({error.code})") from error -def download_file(package: PackageRecord, remote_path: str, output_path: Path) -> None: +def download_file(package: PackageRecord, remote_path: str, output_path: Path, progress=None) -> None: repo = package.download["repo"] revision = package.download.get("revision", "main") request = Request(hf_url(repo, revision, remote_path), headers=http_headers()) try: with urlopen(request, timeout=300) as response: - expected = response.headers.get("Content-Length") + expected_header = response.headers.get("Content-Length") + expected = int(expected_header) if expected_header is not None else None output_path.parent.mkdir(parents=True, exist_ok=True) total = 0 + last_report = 0 with output_path.open("wb") as handle: while True: chunk = response.read(1024 * 1024) @@ -181,7 +184,12 @@ def download_file(package: PackageRecord, remote_path: str, output_path: Path) - break handle.write(chunk) total += len(chunk) - if expected is not None and total != int(expected): + if progress is not None and total - last_report >= 8 * 1024 * 1024: + progress(total, expected) + last_report = total + if progress is not None: + progress(total, expected) + if expected is not None and total != expected: raise ManagerError(f"downloaded size mismatch for {output_path}: {total} != {expected}") except HTTPError as error: if package.download.get("gated") is True and error.code in (401, 403): @@ -225,22 +233,93 @@ def install_package(package: PackageRecord, args: argparse.Namespace) -> None: if args.dry_run or args.check: return - if final_dir.exists() and not args.overwrite: - raise ManagerError(f"target already exists: {final_dir} (use --overwrite)") + existing_outputs = [output for _remote, output in plan if output.exists()] + if existing_outputs and not args.overwrite: + raise ManagerError(f"package files already exist in: {final_dir} (use --overwrite)") models_root.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root)) try: + progress_enabled = bool(getattr(args, "progress", False)) + expected_sizes: list[int | None] = [] + if progress_enabled: + expected_sizes = [check_remote_file(package, remote) for remote, _output in plan] + total_expected = sum(size for size in expected_sizes if size is not None) + if any(size is None for size in expected_sizes): + total_expected = 0 + completed = 0 + + def emit_progress(downloaded: int) -> None: + print( + f"AUDIOCPP_PROGRESS downloaded={downloaded} total={total_expected}", + flush=True, + ) + + if progress_enabled: + emit_progress(0) for remote, output in plan: - download_file(package, remote, staging / output.relative_to(final_dir)) - if final_dir.exists(): - shutil.rmtree(final_dir) - staging.rename(final_dir) + destination = staging / output.relative_to(final_dir) + if progress_enabled: + download_file( + package, + remote, + destination, + lambda file_bytes, _expected, base=completed: emit_progress(base + file_bytes), + ) + completed += destination.stat().st_size + emit_progress(completed) + else: + download_file(package, remote, destination) + if not final_dir.exists(): + final_dir.parent.mkdir(parents=True, exist_ok=True) + staging.rename(final_dir) + else: + # Precision variants commonly share one target directory. Merge the + # fully downloaded staging tree so Q8 and F16/BF16 can coexist. An + # overwrite replaces only this package's files, preserving siblings. + for staged_file in sorted(path for path in staging.rglob("*") if path.is_file()): + relative = staged_file.relative_to(staging) + destination = final_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + if not args.overwrite: + raise ManagerError(f"package file already exists: {destination} (use --overwrite)") + destination.unlink() + staged_file.replace(destination) + shutil.rmtree(staging) except Exception: shutil.rmtree(staging, ignore_errors=True) raise print(f"installed {package.id} -> {final_dir}") +def uninstall_package(package: PackageRecord, args: argparse.Namespace) -> None: + target_dir = validate_relative_path(package.target_directory, "target_directory") + models_root = Path(args.models_root) + final_dir = models_root / target_dir + package_files = [final_dir / stripped_path(remote, package.strip_prefix) for remote in package.files] + existing = [path for path in package_files if path.is_file() or path.is_symlink()] + if not existing: + raise ManagerError(f"package is not installed: {package.id}") + + for path in existing: + path.unlink() + + # Remove only directories made empty by this package. Other precision + # variants and unrelated files in the shared target remain untouched. + cleanup: set[Path] = set() + for path in package_files: + parent = path.parent + while parent != final_dir.parent: + cleanup.add(parent) + parent = parent.parent + for directory in sorted(cleanup, key=lambda path: len(path.parts), reverse=True): + try: + directory.rmdir() + except OSError: + pass + print(f"removed {package.id} -> {final_dir}") + + def command_list(records: list[PackageRecord], args: argparse.Namespace) -> None: rows = [ { @@ -291,6 +370,78 @@ def command_info(records: list[PackageRecord], args: argparse.Namespace) -> None print(f"file: {remote}") +def package_is_installed(package: PackageRecord, models_root: Path | None) -> bool: + if models_root is None: + return False + target_dir = validate_relative_path(package.target_directory, "target_directory") + final_dir = models_root / target_dir + if not final_dir.is_dir(): + return False + return bool(package.files) and all( + (final_dir / stripped_path(remote_path, package.strip_prefix)).is_file() + for remote_path in package.files + ) + + +def package_size_record(package: PackageRecord, models_root: Path | None = None) -> dict[str, Any]: + installed = package_is_installed(package, models_root) + try: + ensure_hf_package(package) + total = 0 + unknown = False + for remote_path in package.files: + size = check_remote_file(package, remote_path) + if size is None: + unknown = True + else: + total += size + state = "gated" if unknown and package.download.get("gated") is True else "unknown" if unknown else "ok" + return { + "id": package.id, + "size_bytes": None if unknown else total, + "state": state, + "message": "Hugging Face access and a valid token are required" if state == "gated" else "", + "installed": installed, + } + except ManagerError as error: + return { + "id": package.id, + "size_bytes": None, + "state": "error", + "message": str(error), + "installed": installed, + } + + +def command_sizes(records: list[PackageRecord], args: argparse.Namespace) -> None: + selected = records + if args.package: + requested = set(args.package) + selected = [record for record in records if record.id in requested] + missing = sorted(requested - {record.id for record in selected}) + if missing: + raise ManagerError(f"unknown package ids: {', '.join(missing)}") + models_root = Path(args.models_root) if args.models_root else None + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + rows = list(pool.map(lambda package: package_size_record(package, models_root), selected)) + print(json.dumps(rows, ensure_ascii=False)) + + +def command_installed(records: list[PackageRecord], args: argparse.Namespace) -> None: + models_root = Path(args.models_root) + rows = [ + { + "id": package.id, + "size_bytes": None, + "state": "pending", + "message": "", + "installed": package_is_installed(package, models_root), + } + for package in records + ] + print(json.dumps(rows, ensure_ascii=False)) + + def make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Install audio.cpp model packages from model_specs/*.json.") parser.add_argument("--specs-dir", default=str(DEFAULT_SPECS_DIR), help="directory containing model spec JSON files") @@ -305,6 +456,22 @@ def make_parser() -> argparse.ArgumentParser: info_parser.add_argument("--precision") info_parser.add_argument("--json", action="store_true") + sizes_parser = sub.add_parser("sizes", help="check package download sizes without downloading files") + sizes_parser.add_argument("package", nargs="*", help="optional package ids; default checks every package") + sizes_parser.add_argument("--jobs", type=int, default=12, help="parallel metadata checks") + sizes_parser.add_argument("--models-root", help="also report packages whose required files are installed") + sizes_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + installed_parser = sub.add_parser("installed", help="report locally installed packages without network access") + installed_parser.add_argument("--models-root", default="models") + installed_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + uninstall_parser = sub.add_parser("uninstall", help="remove only the files belonging to one installed package") + uninstall_parser.add_argument("package", help="package id or family") + uninstall_parser.add_argument("--format") + uninstall_parser.add_argument("--precision") + uninstall_parser.add_argument("--models-root", default="models") + install_parser = sub.add_parser("install", help="install one Hugging Face snapshot package") install_parser.add_argument("package", help="package id or family") install_parser.add_argument("--format") @@ -313,6 +480,11 @@ def make_parser() -> argparse.ArgumentParser: install_parser.add_argument("--overwrite", action="store_true") install_parser.add_argument("--dry-run", action="store_true") install_parser.add_argument("--check", action="store_true", help="check remote files without downloading") + install_parser.add_argument( + "--progress", + action="store_true", + help="emit machine-readable AUDIOCPP_PROGRESS lines while downloading", + ) return parser @@ -325,6 +497,12 @@ def main() -> int: command_list(records, args) elif args.command == "info": command_info(records, args) + elif args.command == "sizes": + command_sizes(records, args) + elif args.command == "installed": + command_installed(records, args) + elif args.command == "uninstall": + uninstall_package(select_package(records, args), args) elif args.command == "install": install_package(select_package(records, args), args) else: diff --git a/webui/README.md b/webui/README.md index b9bb5060..74c3785f 100644 --- a/webui/README.md +++ b/webui/README.md @@ -21,8 +21,10 @@ Build `audiocpp_server` normally, then start the native WebUI host: ``` Open **http://127.0.0.1:8080**. With no `--config`, `--ui` enables on-demand model load/unload and -temporary audio uploads automatically. Relative model paths are resolved from the server's current working -directory, so start it from the bundle or repository root when the catalog uses `models/...`. +temporary audio uploads automatically. The standalone UI host searches upward from both the working directory +and executable location for the nearest `models/` directory and for the package resources (`tools/` plus +`model_specs/`). Consequently, a development binary can be started directly from `build/.../bin`, while a portable +bundle continues to use the `models/`, `tools/`, and `model_specs/` directories beside the executable. The same UI can front an existing server config: @@ -37,7 +39,12 @@ The native UI supports the shared catalog, model-specific controls, file decodin cloning, transcription, generic audio tasks, multiple separation outputs, structured results, and request timing. It also includes: -- model download/preparation jobs with status reporting on the Models page; +- model download/preparation jobs with ordered GGUF Q8, GGUF FP16/BF16, and safetensors choices, + fast required-file checks that disable choices already downloaded, metadata-only package-size checks, + and card-local byte/percentage progress on the Models page; +- GGUF precision variants can coexist in a shared package directory; the selected button stores the exact + GGUF file path, and overwriting one precision does not remove its sibling variants; +- downloaded choices expose a confirmation-gated trash action that removes only that package's declared files; - sentence-aware long-text synthesis and browser-side WAV merging; - microphone capture for source and reference audio; - a saved voice library backed by browser IndexedDB; @@ -52,6 +59,8 @@ it falls back to `tools/model_manager_deprecated.py` for legacy preparation work Set `AUDIOCPP_PYTHON` when the desired interpreter is not `python` on Windows or `python3` on Unix. Pure inference, loading an existing folder, and standalone GGUF operation do not use either helper. The Models page exposes optional source directory, source checkpoint, output, variant, and overwrite inputs for specialized preparation workflows. +Package ids and install directories are resolved from `model_specs/*.json` while the frontend is built, so older +catalog aliases continue to select the current precision-qualified package id. ### Frontend development diff --git a/webui/model_manager_webui.py b/webui/model_manager_webui.py index 765f538c..4ab5fedd 100644 --- a/webui/model_manager_webui.py +++ b/webui/model_manager_webui.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from concurrent.futures import ThreadPoolExecutor import json import os import shutil @@ -165,15 +166,17 @@ def check_remote_file(package: PackageRecord, remote_path: str) -> int | None: raise ManagerError(f"remote file is not accessible: {repo}/{remote_path} ({error.code})") from error -def download_file(package: PackageRecord, remote_path: str, output_path: Path) -> None: +def download_file(package: PackageRecord, remote_path: str, output_path: Path, progress=None) -> None: repo = package.download["repo"] revision = package.download.get("revision", "main") request = Request(hf_url(repo, revision, remote_path), headers=http_headers()) try: with urlopen(request, timeout=300) as response: - expected = response.headers.get("Content-Length") + expected_header = response.headers.get("Content-Length") + expected = int(expected_header) if expected_header is not None else None output_path.parent.mkdir(parents=True, exist_ok=True) total = 0 + last_report = 0 with output_path.open("wb") as handle: while True: chunk = response.read(1024 * 1024) @@ -181,7 +184,12 @@ def download_file(package: PackageRecord, remote_path: str, output_path: Path) - break handle.write(chunk) total += len(chunk) - if expected is not None and total != int(expected): + if progress is not None and total - last_report >= 8 * 1024 * 1024: + progress(total, expected) + last_report = total + if progress is not None: + progress(total, expected) + if expected is not None and total != expected: raise ManagerError(f"downloaded size mismatch for {output_path}: {total} != {expected}") except HTTPError as error: if package.download.get("gated") is True and error.code in (401, 403): @@ -225,22 +233,93 @@ def install_package(package: PackageRecord, args: argparse.Namespace) -> None: if args.dry_run or args.check: return - if final_dir.exists() and not args.overwrite: - raise ManagerError(f"target already exists: {final_dir} (use --overwrite)") + existing_outputs = [output for _remote, output in plan if output.exists()] + if existing_outputs and not args.overwrite: + raise ManagerError(f"package files already exist in: {final_dir} (use --overwrite)") models_root.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root)) try: + progress_enabled = bool(getattr(args, "progress", False)) + expected_sizes: list[int | None] = [] + if progress_enabled: + expected_sizes = [check_remote_file(package, remote) for remote, _output in plan] + total_expected = sum(size for size in expected_sizes if size is not None) + if any(size is None for size in expected_sizes): + total_expected = 0 + completed = 0 + + def emit_progress(downloaded: int) -> None: + print( + f"AUDIOCPP_PROGRESS downloaded={downloaded} total={total_expected}", + flush=True, + ) + + if progress_enabled: + emit_progress(0) for remote, output in plan: - download_file(package, remote, staging / output.relative_to(final_dir)) - if final_dir.exists(): - shutil.rmtree(final_dir) - staging.rename(final_dir) + destination = staging / output.relative_to(final_dir) + if progress_enabled: + download_file( + package, + remote, + destination, + lambda file_bytes, _expected, base=completed: emit_progress(base + file_bytes), + ) + completed += destination.stat().st_size + emit_progress(completed) + else: + download_file(package, remote, destination) + if not final_dir.exists(): + final_dir.parent.mkdir(parents=True, exist_ok=True) + staging.rename(final_dir) + else: + # Precision variants commonly share one target directory. Merge the + # fully downloaded staging tree so Q8 and F16/BF16 can coexist. An + # overwrite replaces only this package's files, preserving siblings. + for staged_file in sorted(path for path in staging.rglob("*") if path.is_file()): + relative = staged_file.relative_to(staging) + destination = final_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + if not args.overwrite: + raise ManagerError(f"package file already exists: {destination} (use --overwrite)") + destination.unlink() + staged_file.replace(destination) + shutil.rmtree(staging) except Exception: shutil.rmtree(staging, ignore_errors=True) raise print(f"installed {package.id} -> {final_dir}") +def uninstall_package(package: PackageRecord, args: argparse.Namespace) -> None: + target_dir = validate_relative_path(package.target_directory, "target_directory") + models_root = Path(args.models_root) + final_dir = models_root / target_dir + package_files = [final_dir / stripped_path(remote, package.strip_prefix) for remote in package.files] + existing = [path for path in package_files if path.is_file() or path.is_symlink()] + if not existing: + raise ManagerError(f"package is not installed: {package.id}") + + for path in existing: + path.unlink() + + # Remove only directories made empty by this package. Other precision + # variants and unrelated files in the shared target remain untouched. + cleanup: set[Path] = set() + for path in package_files: + parent = path.parent + while parent != final_dir.parent: + cleanup.add(parent) + parent = parent.parent + for directory in sorted(cleanup, key=lambda path: len(path.parts), reverse=True): + try: + directory.rmdir() + except OSError: + pass + print(f"removed {package.id} -> {final_dir}") + + def command_list(records: list[PackageRecord], args: argparse.Namespace) -> None: rows = [ { @@ -291,6 +370,78 @@ def command_info(records: list[PackageRecord], args: argparse.Namespace) -> None print(f"file: {remote}") +def package_is_installed(package: PackageRecord, models_root: Path | None) -> bool: + if models_root is None: + return False + target_dir = validate_relative_path(package.target_directory, "target_directory") + final_dir = models_root / target_dir + if not final_dir.is_dir(): + return False + return bool(package.files) and all( + (final_dir / stripped_path(remote_path, package.strip_prefix)).is_file() + for remote_path in package.files + ) + + +def package_size_record(package: PackageRecord, models_root: Path | None = None) -> dict[str, Any]: + installed = package_is_installed(package, models_root) + try: + ensure_hf_package(package) + total = 0 + unknown = False + for remote_path in package.files: + size = check_remote_file(package, remote_path) + if size is None: + unknown = True + else: + total += size + state = "gated" if unknown and package.download.get("gated") is True else "unknown" if unknown else "ok" + return { + "id": package.id, + "size_bytes": None if unknown else total, + "state": state, + "message": "Hugging Face access and a valid token are required" if state == "gated" else "", + "installed": installed, + } + except ManagerError as error: + return { + "id": package.id, + "size_bytes": None, + "state": "error", + "message": str(error), + "installed": installed, + } + + +def command_sizes(records: list[PackageRecord], args: argparse.Namespace) -> None: + selected = records + if args.package: + requested = set(args.package) + selected = [record for record in records if record.id in requested] + missing = sorted(requested - {record.id for record in selected}) + if missing: + raise ManagerError(f"unknown package ids: {', '.join(missing)}") + models_root = Path(args.models_root) if args.models_root else None + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + rows = list(pool.map(lambda package: package_size_record(package, models_root), selected)) + print(json.dumps(rows, ensure_ascii=False)) + + +def command_installed(records: list[PackageRecord], args: argparse.Namespace) -> None: + models_root = Path(args.models_root) + rows = [ + { + "id": package.id, + "size_bytes": None, + "state": "pending", + "message": "", + "installed": package_is_installed(package, models_root), + } + for package in records + ] + print(json.dumps(rows, ensure_ascii=False)) + + def make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Install audio.cpp model packages from model_specs/*.json.") parser.add_argument("--specs-dir", default=str(DEFAULT_SPECS_DIR), help="directory containing model spec JSON files") @@ -305,6 +456,22 @@ def make_parser() -> argparse.ArgumentParser: info_parser.add_argument("--precision") info_parser.add_argument("--json", action="store_true") + sizes_parser = sub.add_parser("sizes", help="check package download sizes without downloading files") + sizes_parser.add_argument("package", nargs="*", help="optional package ids; default checks every package") + sizes_parser.add_argument("--jobs", type=int, default=12, help="parallel metadata checks") + sizes_parser.add_argument("--models-root", help="also report packages whose required files are installed") + sizes_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + installed_parser = sub.add_parser("installed", help="report locally installed packages without network access") + installed_parser.add_argument("--models-root", default="models") + installed_parser.add_argument("--json", action="store_true", help="retained for command symmetry; output is JSON") + + uninstall_parser = sub.add_parser("uninstall", help="remove only the files belonging to one installed package") + uninstall_parser.add_argument("package", help="package id or family") + uninstall_parser.add_argument("--format") + uninstall_parser.add_argument("--precision") + uninstall_parser.add_argument("--models-root", default="models") + install_parser = sub.add_parser("install", help="install one Hugging Face snapshot package") install_parser.add_argument("package", help="package id or family") install_parser.add_argument("--format") @@ -313,6 +480,11 @@ def make_parser() -> argparse.ArgumentParser: install_parser.add_argument("--overwrite", action="store_true") install_parser.add_argument("--dry-run", action="store_true") install_parser.add_argument("--check", action="store_true", help="check remote files without downloading") + install_parser.add_argument( + "--progress", + action="store_true", + help="emit machine-readable AUDIOCPP_PROGRESS lines while downloading", + ) return parser @@ -325,6 +497,12 @@ def main() -> int: command_list(records, args) elif args.command == "info": command_info(records, args) + elif args.command == "sizes": + command_sizes(records, args) + elif args.command == "installed": + command_installed(records, args) + elif args.command == "uninstall": + uninstall_package(select_package(records, args), args) elif args.command == "install": install_package(select_package(records, args), args) else: diff --git a/webui/native/dist/index.html b/webui/native/dist/index.html index e70f4978..bf9d89ae 100644 --- a/webui/native/dist/index.html +++ b/webui/native/dist/index.html @@ -6,25 +6,27 @@ -
diff --git a/webui/native/src/app.css b/webui/native/src/app.css index 9f0877d1..f5bde309 100644 --- a/webui/native/src/app.css +++ b/webui/native/src/app.css @@ -140,16 +140,34 @@ pre { background: #071220; border: 1px solid var(--line); border-radius: 8px; pa .installer-options { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(180px, .4fr) auto; gap: 12px; align-items: end; padding: 15px; margin-bottom: 13px; } .installer-options label { margin-top: 0; } .overwrite-toggle { margin: 0 4px 11px!important; white-space: nowrap; } -.model-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } -.model-grid article { display: grid; grid-template-columns: 58px 1fr auto; gap: 14px; align-items: center; padding: 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--panel); } +.model-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; align-items: stretch; } +.model-grid article { display: grid; grid-template-columns: 58px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--panel); } .model-grid article.selected { border-color: #3b718c; box-shadow: inset 0 0 0 1px rgba(66,232,213,.18); } .model-icon { display: grid; place-items: center; height: 50px; border-radius: 10px; background: #122b45; color: var(--cyan); font-size: 10px; font-weight: 900; letter-spacing: .1em; } .model-copy span, .model-actions small { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } .model-copy h3 { font-size: 15px; margin: 4px 0; }.model-copy p { color: var(--muted); font: 11px ui-monospace, monospace; margin: 0; overflow-wrap: anywhere; } -.model-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; min-width: 210px; }.model-actions small { grid-column: 1/-1; text-align: right; } +.model-actions { display: grid; gap: 6px; width: clamp(280px, 17vw, 324px); min-width: 0; }.model-actions small { text-align: right; } .model-actions button { padding: 7px; font-size: 11px; } -.install-status { grid-column: 1/-1; max-width: 250px; color: var(--muted); font-size: 10px; line-height: 1.35; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.install-status strong { color: var(--blue); text-transform: uppercase; }.install-status.complete strong { color: var(--cyan); }.install-status.failed strong { color: var(--danger); } +.model-open { width: 100%; } +.package-buttons { display: grid; grid-template-columns: repeat(3, minmax(76px, 1fr)); gap: 5px; } +.package-choice { position: relative; height: 48px; min-width: 0; } +.package-install { display: flex; width: 100%; height: 48px; min-width: 0; min-height: 0; padding-top: 4px; padding-bottom: 4px; flex-direction: column; align-items: center; justify-content: center; gap: 2px; white-space: nowrap; } +.package-install > span:first-child { line-height: 1.1; } +.package-install.preferred { border-color: #347d8f; color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.1); } +.package-install.downloaded { padding-right: 23px; color: var(--muted); white-space: normal; } +.model-actions .package-delete { position: absolute; top: 3px; right: 3px; z-index: 1; display: grid; width: 18px; height: 18px; min-height: 0; padding: 2px; place-items: center; border-color: transparent; background: transparent; color: #7189a8; } +.model-actions .package-delete:hover:not(:disabled) { border-color: rgba(239,107,124,.55); background: rgba(239,107,124,.12); color: var(--danger); transform: none; } +.package-delete svg { width: 12px; height: 12px; fill: currentColor; } +.package-size { color: var(--muted); font-size: 8px; font-weight: 500; letter-spacing: 0; text-transform: none; } +.install-progress { grid-column: 1/-1; width: 100%; min-width: 0; max-width: 100%; } +.install-progress-head { display: flex; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 9px; } +.install-progress-head strong { color: var(--blue); text-transform: uppercase; }.install-progress.complete .install-progress-head strong { color: var(--cyan); }.install-progress.failed .install-progress-head strong { color: var(--danger); } +.install-progress-track { position: relative; height: 6px; margin: 5px 0 4px; overflow: hidden; border: 1px solid #28425f; border-radius: 999px; background: #071220; } +.install-progress-track span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #1c9cba, var(--cyan)); transition: width .25s ease; } +.install-progress.failed .install-progress-track span { background: var(--danger); }.install-progress.complete .install-progress-track span { background: var(--cyan); } +.install-progress-track.indeterminate span { width: 35%!important; animation: install-scan 1.2s ease-in-out infinite; } +.install-status { min-width: 0; max-width: 100%; color: var(--muted); font-size: 9px; line-height: 1.35; text-align: left; overflow-wrap: anywhere; word-break: break-word; white-space: normal; } +@keyframes install-scan { from { transform: translateX(-110%); } to { transform: translateX(300%); } } .log-panel { padding: 20px; }.runtime-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; } .runtime-cards div { padding: 16px; background: #081426; border: 1px solid var(--line); border-radius: 10px; } .runtime-cards span { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; }.runtime-cards strong { display: block; margin-top: 6px; font-size: 21px; text-transform: capitalize; } @@ -174,7 +192,7 @@ footer { display: flex; justify-content: space-between; max-width: 1600px; margi } @media (max-width: 520px) { .brand strong { font-size: 14px; } nav button { font-size: 12px; padding: 0 7px; } - .model-grid article { grid-template-columns: 45px 1fr; }.model-actions { grid-column: 1/-1; min-width: 0; } + .model-grid article { grid-template-columns: 45px 1fr; }.model-actions { grid-column: 1/-1; width: 100%; min-width: 0; } .installer-options { grid-template-columns: 1fr; } .long-text-row, .voice-library { grid-template-columns: 1fr; }.long-text-row > .toggle { margin-top: 12px; } footer { display: block; } footer span { display: block; margin-top: 5px; } diff --git a/webui/native/src/lib/api.ts b/webui/native/src/lib/api.ts index 5aa33dd5..e55a8475 100644 --- a/webui/native/src/lib/api.ts +++ b/webui/native/src/lib/api.ts @@ -58,6 +58,9 @@ export interface ModelInstallJob { state: 'idle' | 'queued' | 'running' | 'complete' | 'failed'; message: string; exit_code: number; + downloaded_bytes: number; + total_bytes: number; + progress_percent: number; started_at_ms: number; finished_at_ms: number; } @@ -76,11 +79,36 @@ export async function installModelPackage(body: { }); } +export async function deleteModelPackage(id: string): Promise<{ id: string; removed: boolean; message: string }> { + return jsonRequest('/v1/ui/models/delete', { + method: 'POST', + body: JSON.stringify({ id }) + }); +} + export async function modelInstallJobs(): Promise { const response = await jsonRequest<{ data: ModelInstallJob[] }>('/v1/ui/models/install-status'); return response.data; } +export interface ModelPackageSize { + id: string; + size_bytes: number | null; + state: 'pending' | 'ok' | 'gated' | 'unknown' | 'error'; + message: string; + installed: boolean; +} + +export interface ModelPackageSizesResponse { + state: 'idle' | 'running' | 'complete' | 'failed'; + message: string; + data: ModelPackageSize[]; +} + +export async function modelPackageSizes(): Promise { + return jsonRequest('/v1/ui/models/package-sizes'); +} + export async function uploadWav(blob: Blob, filename: string, signal?: AbortSignal): Promise { const response = await jsonRequest<{ path: string }>('/v1/ui/upload', { method: 'POST', diff --git a/webui/native/src/lib/catalog.ts b/webui/native/src/lib/catalog.ts index e1fdea26..1034d2f9 100644 --- a/webui/native/src/lib/catalog.ts +++ b/webui/native/src/lib/catalog.ts @@ -1,11 +1,137 @@ import rawCatalog from '../../../configs/models_catalog.json'; import rawParams from '../../../configs/model_params.json'; -import type { CatalogEntry, ParamSpec } from './types'; +import type { CatalogEntry, InstallPackageChoice, ParamSpec } from './types'; -export const catalog = (rawCatalog.models as CatalogEntry[]).map((entry) => ({ - ...entry, - display_name: entry.display_name_en || entry.display_name -})); +interface PackageEntry { + family: string; + id: string; + target_directory: string; + format: string; + precision: string; + files?: string[]; + strip_prefix?: string; + default?: boolean; +} + +interface PackageSpec { + family: string; + packages?: Array>; +} + +const specModules = import.meta.glob('../../../../model_specs/*.json', { + eager: true, + import: 'default' +}) as Record; + +// Package ids and install locations are sourced from model_specs at frontend +// build time. This keeps the embedded catalog aligned when package ids gain a +// precision/format suffix, without needing model_specs files at UI runtime. +const packages: PackageEntry[] = Object.values(specModules).flatMap((spec) => + (spec.packages || []).map((entry) => ({ ...entry, family: spec.family })) +); + +const cleanPath = (value: string) => value + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/^models\//i, '') + .replace(/\/$/, '') + .toLowerCase(); + +const cleanId = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + +function preferredPackage(entries: PackageEntry[]): PackageEntry | undefined { + return entries.find((entry) => entry.default) || + entries.find((entry) => entry.precision === 'q8_0') || + entries[0]; +} + +function relatedPackages(entry: CatalogEntry): PackageEntry[] { + const family = packages.filter((candidate) => candidate.family === entry.family); + if (!family.length) return []; + if (!entry.download_id) return family; + const exact = family.find((candidate) => candidate.id === entry.download_id); + if (exact) { + const stem = exact.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''); + const matches = family.filter((candidate) => + candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, '') === stem); + return matches.length ? matches : [exact]; + } + + // Resolve a legacy family/variant id before considering its old target + // directory. A directory such as models/pocket-tts may identify the gated + // upstream safetensors package, while the family default is the public GGUF + // package intended by the catalog's generic `pocket_tts` download id. + const legacyId = cleanId(entry.download_id); + const legacyMatches = family.filter((candidate) => { + const currentId = cleanId(candidate.id); + return currentId.startsWith(legacyId) || legacyId.startsWith(currentId); + }); + if (legacyMatches.length) return legacyMatches; + + const target = cleanPath(entry.path); + const targetMatches = family.filter((candidate) => cleanPath(candidate.target_directory) === target); + if (targetMatches.length) { + const stems = new Set(targetMatches.map((candidate) => + candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''))); + const matches = family.filter((candidate) => stems.has( + candidate.id.replace(/_(?:q8_0|q8|f16|fp16|bf16|safetensors|orig)$/i, ''))); + return matches.length ? matches : targetMatches; + } + return family; +} + +function packageLabel(entry: PackageEntry): string { + if (entry.format === 'safetensors') return 'Safetensors'; + if (entry.precision === 'q8_0' || entry.precision === 'q8') return 'GGUF Q8'; + if (entry.precision === 'bf16') return 'GGUF BF16'; + if (entry.precision === 'f16' || entry.precision === 'fp16') return 'GGUF FP16'; + return `GGUF ${entry.precision.toUpperCase()}`; +} + +function packageModelPath(entry: PackageEntry): string { + if (entry.format !== 'gguf' || entry.files?.length !== 1) { + return `models/${entry.target_directory}`; + } + let relative = entry.files[0].replace(/\\/g, '/'); + const prefix = (entry.strip_prefix || '').replace(/\\/g, '/').replace(/\/$/, ''); + if (prefix && relative.startsWith(`${prefix}/`)) relative = relative.slice(prefix.length + 1); + return `models/${entry.target_directory}/${relative}`.replace(/\/+/g, '/'); +} + +function installChoices(entry: CatalogEntry): InstallPackageChoice[] { + const related = relatedPackages(entry); + const q8 = preferredPackage(related.filter((candidate) => + candidate.format === 'gguf' && ['q8_0', 'q8'].includes(candidate.precision))); + const fp16 = preferredPackage(related.filter((candidate) => + candidate.format === 'gguf' && ['f16', 'fp16'].includes(candidate.precision))) || + preferredPackage(related.filter((candidate) => + candidate.format === 'gguf' && candidate.precision === 'bf16')); + const otherGguf = !q8 && !fp16 + ? preferredPackage(related.filter((candidate) => candidate.format === 'gguf')) + : undefined; + const safetensors = preferredPackage(related.filter((candidate) => candidate.format === 'safetensors')); + return [q8, fp16, otherGguf, safetensors] + .filter((candidate): candidate is PackageEntry => candidate !== undefined) + .map((candidate) => ({ + id: candidate.id, + label: packageLabel(candidate), + path: packageModelPath(candidate), + format: candidate.format, + precision: candidate.precision + })); +} + +export const catalog = (rawCatalog.models as CatalogEntry[]).map((entry) => { + const choices = installChoices(entry); + const installPackage = choices[0]; + return { + ...entry, + display_name: entry.display_name_en || entry.display_name, + download_id: installPackage?.id || entry.download_id, + install_packages: choices, + path: installPackage?.path || entry.path + }; +}); export const parameterCatalog = rawParams as unknown as Record; diff --git a/webui/native/src/lib/types.ts b/webui/native/src/lib/types.ts index 53d67b63..da4eee94 100644 --- a/webui/native/src/lib/types.ts +++ b/webui/native/src/lib/types.ts @@ -1,5 +1,13 @@ export type StringMap = Record; +export interface InstallPackageChoice { + id: string; + label: string; + path: string; + format: string; + precision: string; +} + export interface CatalogEntry { id: string; display_name: string; @@ -9,6 +17,7 @@ export interface CatalogEntry { task: string; mode: string; download_id?: string; + install_packages?: InstallPackageChoice[]; min_vram_gb?: number; input_hint?: string; input_hint_en?: string; diff --git a/webui/native/src/routes/+page.svelte b/webui/native/src/routes/+page.svelte index c9c3dfb7..6b14a172 100644 --- a/webui/native/src/routes/+page.svelte +++ b/webui/native/src/routes/+page.svelte @@ -3,10 +3,12 @@ import { browserDecodeToWav, concatenateAudioBlobs } from '$lib/audio'; import { base64AudioUrl, + deleteModelPackage, health, installModelPackage, loadModel, modelInstallJobs, + modelPackageSizes, models, pathStatus, runTask, @@ -14,11 +16,19 @@ transcription, unloadModel, uploadWav, - type ModelInstallJob + type ModelInstallJob, + type ModelPackageSize } from '$lib/api'; import { catalog, parameterCatalog, taskLabels } from '$lib/catalog'; import { defaultChunkBudget, splitTtsChunks } from '$lib/text'; - import type { AudioOutput, CatalogEntry, LoadedModel, ParamSpec, ServerHealth } from '$lib/types'; + import type { + AudioOutput, + CatalogEntry, + InstallPackageChoice, + LoadedModel, + ParamSpec, + ServerHealth + } from '$lib/types'; import { deleteVoice as deleteSavedVoice, listVoices, @@ -77,6 +87,10 @@ let installVariant = ''; let installOverwrite = false; let installPoll: number | null = null; + let selectedPackagePaths: Record = {}; + let packageSizes: Record = {}; + let packageSizeState: 'idle' | 'running' | 'complete' | 'failed' = 'idle'; + let packageSizePoll: number | null = null; const workflowTabs = [ { id: 'tts', label: 'Text to speech', tasks: ['tts', 'clon'] }, @@ -110,6 +124,101 @@ logs = [line, ...logs].slice(0, 200); } + function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / 1024 ** index).toFixed(index < 2 ? 0 : 1)} ${units[index]}`; + } + + function installPercent(job: ModelInstallJob) { + if (job.state === 'complete') return 100; + if (job.progress_percent >= 0) return Math.min(100, Math.max(0, job.progress_percent)); + return 0; + } + + function installProgressLabel(job: ModelInstallJob) { + const percent = installPercent(job); + if (job.total_bytes > 0) { + return `${percent}% · ${formatBytes(job.downloaded_bytes)} / ${formatBytes(job.total_bytes)}`; + } + if (job.downloaded_bytes > 0) return `${formatBytes(job.downloaded_bytes)} downloaded`; + if (job.state === 'failed') return 'Download failed'; + if (job.state === 'complete') return '100% · complete'; + return job.state === 'queued' ? '0% · queued' : 'Connecting and checking package files…'; + } + + function entryInstallJobs(entry: CatalogEntry, jobs: Record) { + return (entry.install_packages || []) + .map((choice) => jobs[choice.id]) + .filter((job): job is ModelInstallJob => job !== undefined); + } + + function displayInstallJob(entry: CatalogEntry, installState: Record) { + const jobs = entryInstallJobs(entry, installState); + return jobs.find((job) => job.state === 'running' || job.state === 'queued') || + [...jobs].sort((left, right) => right.finished_at_ms - left.finished_at_ms)[0]; + } + + function entryInstallBusy(entry: CatalogEntry, installState: Record) { + return entryInstallJobs(entry, installState).some((job) => + job.state === 'running' || job.state === 'queued'); + } + + function installButtonLabel( + choice: InstallPackageChoice, + job: ModelInstallJob | undefined, + isInstalled: boolean + ) { + if (isInstalled) return 'Already downloaded'; + if (job?.state === 'running') return `${choice.label}…`; + if (job?.state === 'queued') return `${choice.label} queued`; + return choice.label; + } + + function packageSizeLabel( + size: ModelPackageSize | undefined, + sizeState: 'idle' | 'running' | 'complete' | 'failed' + ) { + if (size?.size_bytes !== null && size?.size_bytes !== undefined) return formatBytes(size.size_bytes); + if (size?.state === 'pending') return 'checking size...'; + if (size?.state === 'gated') return 'HF access required'; + if (size?.state === 'error' || size?.state === 'unknown') return 'size unavailable'; + return sizeState === 'running' ? 'checking size…' : ''; + } + + async function refreshPackageSizes() { + if (!server?.ui_management) return; + try { + const response = await modelPackageSizes(); + packageSizeState = response.state; + if (response.data.length) { + packageSizes = Object.fromEntries(response.data.map((size) => [size.id, size])); + } + if (response.state === 'running' && packageSizePoll === null) { + packageSizePoll = window.setInterval(refreshPackageSizes, 1000); + } else if (response.state !== 'running' && packageSizePoll !== null) { + window.clearInterval(packageSizePoll); + packageSizePoll = null; + } + } catch (error) { + packageSizeState = 'failed'; + log(`Package sizes unavailable: ${error instanceof Error ? error.message : error}`); + } + } + + function openModelsPage() { + tab = 'models'; + if (packageSizeState === 'idle') packageSizeState = 'running'; + refreshPackageSizes(); + } + + function rememberPackagePath(entry: CatalogEntry, choice: InstallPackageChoice) { + selectedPackagePaths = { ...selectedPackagePaths, [entry.id]: choice.path }; + localStorage.setItem('audiocpp.ui.packagePaths', JSON.stringify(selectedPackagePaths)); + if (entry.id === selectedId) modelPath = choice.path; + } + function resetParams() { const byId = parameterCatalog[selected?.id] || parameterCatalog[selected?.family] || []; paramSpecs = byId; @@ -139,7 +248,7 @@ if (!next) return; selectedId = id; selected = next; - modelPath = next.path; + modelPath = selectedPackagePaths[id] || next.path; chunkBudget = defaultChunkBudget(next.family); localStorage.setItem('audiocpp.ui.model', id); resetParams(); @@ -536,12 +645,30 @@ if (!server?.ui_management) return; try { const jobs = await modelInstallJobs(); - installJobs = Object.fromEntries(jobs.map((job) => [job.id, job])); + const incoming = Object.fromEntries(jobs.map((job) => { + const local = installJobs[job.id]; + return [job.id, { + ...job, + total_bytes: job.total_bytes || local?.total_bytes || 0 + }]; + })); + // Preserve a just-created local job if a status request races the server's + // worker registration. This keeps the progress row visible from the click + // until the authoritative job appears in a later poll. + installJobs = { ...installJobs, ...incoming }; for (const entry of catalog) { - const job = entry.download_id ? installJobs[entry.download_id] : undefined; - if (job?.state === 'complete' && entry.id === selectedId) await inspectPath(); + const completedJobs = entryInstallJobs(entry, installJobs).filter((job) => job.state === 'complete'); + for (const job of completedJobs) { + const known = packageSizes[job.id]; + if (known && !known.installed) { + packageSizes = { ...packageSizes, [job.id]: { ...known, installed: true } }; + } + } + const complete = completedJobs.length > 0; + if (complete && entry.id === selectedId) await inspectPath(); } - const active = jobs.some((job) => job.state === 'queued' || job.state === 'running'); + const active = Object.values(installJobs).some((job) => + job.state === 'queued' || job.state === 'running'); if (active && installPoll === null) { installPoll = window.setInterval(refreshInstallJobs, 1500); } else if (!active && installPoll !== null) { @@ -553,35 +680,114 @@ } } - async function installPackage(entry: CatalogEntry) { - if (!entry.download_id) return; - const existing = installJobs[entry.download_id]; + async function installPackage(entry: CatalogEntry, choice: InstallPackageChoice) { + if (packageSizes[choice.id]?.installed) return; + const existing = installJobs[choice.id]; if (existing?.state === 'queued' || existing?.state === 'running') return; - status = `Starting installation for ${entry.display_name}...`; + rememberPackagePath(entry, choice); + status = `Starting ${choice.label} installation for ${entry.display_name}...`; + const expectedBytes = packageSizes[choice.id]?.size_bytes || 0; + installJobs = { + ...installJobs, + [choice.id]: { + id: choice.id, + state: 'queued', + message: 'Sending installation request…', + exit_code: -1, + downloaded_bytes: 0, + total_bytes: expectedBytes, + progress_percent: 0, + started_at_ms: 0, + finished_at_ms: 0 + } + }; try { + if (installPoll === null) { + installPoll = window.setInterval(refreshInstallJobs, 1000); + } const job = await installModelPackage({ - id: entry.download_id, + id: choice.id, source_file: installSourceFile.trim() || undefined, output_file: installOutputFile.trim() || undefined, source_directory: installSourceDirectory.trim() || undefined, variant: installVariant.trim() || undefined, overwrite: installOverwrite }); - installJobs = { ...installJobs, [job.id]: job }; + installJobs = { + ...installJobs, + [job.id]: { ...job, total_bytes: job.total_bytes || expectedBytes } + }; await refreshInstallJobs(); - status = `${entry.display_name} installation is running in the background.`; + status = `${entry.display_name} ${choice.label} installation is running in the background.`; log(status); } catch (error) { status = error instanceof Error ? error.message : String(error); + installJobs = { + ...installJobs, + [choice.id]: { + id: choice.id, + state: 'failed', + message: status, + exit_code: -1, + downloaded_bytes: 0, + total_bytes: 0, + progress_percent: -1, + started_at_ms: 0, + finished_at_ms: Date.now() + } + }; log(`Installer failed to start: ${status}`); } } + async function removePackage(entry: CatalogEntry, choice: InstallPackageChoice) { + if (!packageSizes[choice.id]?.installed) return; + const confirmed = window.confirm( + `Delete ${entry.display_name} ${choice.label}?\n\nOnly this package precision will be removed.` + ); + if (!confirmed) return; + status = `Deleting ${entry.display_name} ${choice.label}...`; + try { + const result = await deleteModelPackage(choice.id); + packageSizes = { + ...packageSizes, + [choice.id]: { ...packageSizes[choice.id], installed: false } + }; + const nextJobs = { ...installJobs }; + delete nextJobs[choice.id]; + installJobs = nextJobs; + + if (selectedPackagePaths[entry.id] === choice.path) { + const replacement = (entry.install_packages || []).find((candidate) => + candidate.id !== choice.id && packageSizes[candidate.id]?.installed); + const nextPaths = { ...selectedPackagePaths }; + if (replacement) nextPaths[entry.id] = replacement.path; + else delete nextPaths[entry.id]; + selectedPackagePaths = nextPaths; + localStorage.setItem('audiocpp.ui.packagePaths', JSON.stringify(selectedPackagePaths)); + if (entry.id === selectedId) modelPath = replacement?.path || entry.path; + } + + packageSizeState = 'idle'; + await refreshPackageSizes(); + status = result.message || `${entry.display_name} ${choice.label} deleted.`; + log(status); + } catch (error) { + status = error instanceof Error ? error.message : String(error); + log(`Package deletion failed: ${status}`); + } + } + onMount(async () => { + try { + selectedPackagePaths = JSON.parse(localStorage.getItem('audiocpp.ui.packagePaths') || '{}'); + } catch { + selectedPackagePaths = {}; + } const stored = localStorage.getItem('audiocpp.ui.model'); if (stored && catalog.some((entry) => entry.id === stored)) selectedId = stored; selected = catalog.find((entry) => entry.id === selectedId) || catalog[0]; - modelPath = selected.path; + modelPath = selectedPackagePaths[selected.id] || selected.path; resetParams(); await refresh(); await inspectPath(); @@ -598,6 +804,7 @@ liveStream?.getTracks().forEach((track) => track.stop()); for (const output of outputAudio) URL.revokeObjectURL(output.url); if (installPoll !== null) window.clearInterval(installPoll); + if (packageSizePoll !== null) window.clearInterval(packageSizePoll); }); @@ -614,7 +821,7 @@
@@ -918,29 +1125,61 @@
{#each catalog as entry} - {@const installJob = entry.download_id ? installJobs[entry.download_id] : undefined} + {@const installJob = displayInstallJob(entry, installJobs)} + {@const packageChoices = entry.install_packages || []}
{entry.task.toUpperCase()}
{taskLabels[entry.task] || entry.task}

{entry.display_name}

-

{entry.path}

+

{selectedPackagePaths[entry.id] || entry.path}

- {entry.min_vram_gb || '?'} GB - - {#if entry.download_id} - - {#if installJob} -
- {installJob.state} {installJob.message} + VRAM ~{entry.min_vram_gb || '?'} GB + + {#if packageChoices.length} +
+ {#each packageChoices as choice} +
+ + {#if packageSizes[choice.id]?.installed} + + {/if} +
+ {/each} +
+ {#if installJob && installJob.state !== 'complete'} +
+
+ {installJob.state} + {installProgressLabel(installJob)} +
+
+ +
+
{installJob.message}
{/if} {/if} diff --git a/webui/test_model_manager_webui.py b/webui/test_model_manager_webui.py index 3b5e1dce..fca9e50d 100644 --- a/webui/test_model_manager_webui.py +++ b/webui/test_model_manager_webui.py @@ -10,6 +10,8 @@ import sys import tempfile import unittest +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path from types import SimpleNamespace @@ -65,6 +67,37 @@ def test_non_huggingface_package_is_rejected(self): with self.assertRaises(mmw.ManagerError): mmw.ensure_hf_package(package) + def test_package_size_record_sums_metadata_without_downloading(self): + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, remote: 10 if remote == "a" else 15 + row = mmw.package_size_record(_package(files=("a", "b"))) + self.assertEqual(row["state"], "ok") + self.assertEqual(row["size_bytes"], 25) + + def test_gated_package_size_is_reported_without_failing_the_scan(self): + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, _remote: None + row = mmw.package_size_record(_package(download={ + "kind": "huggingface_snapshot", "repo": "gated/demo", "gated": True})) + self.assertEqual(row["state"], "gated") + self.assertIsNone(row["size_bytes"]) + + def test_package_status_requires_every_file_before_reporting_installed(self): + root = Path(tempfile.mkdtemp(prefix="audiocpp_package_status_test_")) + self.addCleanup(shutil.rmtree, root, True) + package = _package(files=("Demo-GGUF/model-q8_0.gguf", "Demo-GGUF/config.json")) + target = root / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"gguf") + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, _remote: 4 + self.assertFalse(mmw.package_size_record(package, root)["installed"]) + (target / "config.json").write_text("{}", encoding="utf-8") + self.assertTrue(mmw.package_size_record(package, root)["installed"]) + class InstallPlacementTests(unittest.TestCase): def setUp(self): @@ -74,22 +107,28 @@ def setUp(self): original = mmw.download_file self.addCleanup(setattr, mmw, "download_file", original) - def fake_download(package, remote_path, output_path): + def fake_download(package, remote_path, output_path, progress=None): self.calls.append((remote_path, output_path.name)) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_bytes(b"gguf") + if progress is not None: + progress(4, 4) mmw.download_file = fake_download - def _install(self, package, overwrite=False): + def _install(self, package, overwrite=False, progress=False): args = SimpleNamespace( models_root=self.root, overwrite=overwrite, check=False, dry_run=False, + progress=progress, ) mmw.install_package(package, args) + def _uninstall(self, package): + mmw.uninstall_package(package, SimpleNamespace(models_root=self.root)) + def test_strip_prefix_file_lands_at_its_installed_path(self): self._install(_package()) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "model-q8_0.gguf"))) @@ -102,18 +141,79 @@ def test_nested_paths_below_the_stripped_prefix_are_preserved(self): self._install(package) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "tokenizer", "config.json"))) + def test_nested_target_directory_creates_its_parent_before_atomic_rename(self): + package = _package( + target_directory="Demo-GGUF/english", + files=("Demo-GGUF/english/model-q8_0.gguf",), + strip_prefix="Demo-GGUF/english") + self._install(package) + self.assertTrue(os.path.isfile(os.path.join( + self.root, "Demo-GGUF", "english", "model-q8_0.gguf"))) + def test_packages_without_a_strip_prefix_are_unchanged(self): package = _package(files=("config.json",), strip_prefix="") self._install(package) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "config.json"))) def test_existing_target_requires_overwrite(self): - os.makedirs(os.path.join(self.root, "Demo-GGUF")) + target = Path(self.root) / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"old") with self.assertRaises(mmw.ManagerError): self._install(_package(), overwrite=False) self._install(_package(), overwrite=True) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "model-q8_0.gguf"))) + def test_precision_variants_can_share_a_target_directory(self): + self._install(_package()) + self._install(_package( + id="demo_f16", + precision="f16", + files=("Demo-GGUF/model-f16.gguf",), + )) + target = Path(self.root) / "Demo-GGUF" + self.assertTrue((target / "model-q8_0.gguf").is_file()) + self.assertTrue((target / "model-f16.gguf").is_file()) + + def test_overwrite_preserves_other_precision_variants(self): + target = Path(self.root) / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"q8") + (target / "model-f16.gguf").write_bytes(b"old") + self._install(_package( + id="demo_f16", + precision="f16", + files=("Demo-GGUF/model-f16.gguf",), + ), overwrite=True) + self.assertEqual((target / "model-q8_0.gguf").read_bytes(), b"q8") + self.assertEqual((target / "model-f16.gguf").read_bytes(), b"gguf") + + def test_uninstall_removes_only_selected_precision(self): + q8 = _package() + f16 = _package(id="demo_f16", precision="f16", files=("Demo-GGUF/model-f16.gguf",)) + self._install(q8) + self._install(f16) + self._uninstall(q8) + target = Path(self.root) / "Demo-GGUF" + self.assertFalse((target / "model-q8_0.gguf").exists()) + self.assertTrue((target / "model-f16.gguf").is_file()) + + def test_uninstall_last_package_removes_empty_target(self): + package = _package() + self._install(package) + self._uninstall(package) + self.assertFalse((Path(self.root) / "Demo-GGUF").exists()) + + def test_progress_mode_emits_downloaded_and_total_bytes(self): + original = mmw.check_remote_file + self.addCleanup(setattr, mmw, "check_remote_file", original) + mmw.check_remote_file = lambda _package, _remote: 4 + output = StringIO() + with redirect_stdout(output): + self._install(_package(), progress=True) + self.assertIn("AUDIOCPP_PROGRESS downloaded=0 total=4", output.getvalue()) + self.assertIn("AUDIOCPP_PROGRESS downloaded=4 total=4", output.getvalue()) + if __name__ == "__main__": unittest.main() From 55f495a96697ab321cc3565c8dc104000be520ad Mon Sep 17 00:00:00 2001 From: mirek190 Date: Wed, 5 Aug 2026 16:13:11 +0100 Subject: [PATCH 3/4] Isolate WebUI model installer state --- app/server/model_installer.cpp | 29 ++++++++++++- .../unittests/test_server_model_installer.cpp | 43 +++++++++++++++++++ tools/model_manager_v2.py | 5 ++- webui/model_manager_webui.py | 5 ++- webui/test_model_manager_webui.py | 17 ++++++-- 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/app/server/model_installer.cpp b/app/server/model_installer.cpp index 8f272b6f..9927100b 100644 --- a/app/server/model_installer.cpp +++ b/app/server/model_installer.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -174,6 +175,26 @@ int64_t now_ms() { std::chrono::system_clock::now().time_since_epoch()).count(); } +std::filesystem::path make_job_root() { + const auto temporary = std::filesystem::temp_directory_path(); + std::random_device random; + for (int attempt = 0; attempt < 32; ++attempt) { + const auto ticks = std::chrono::high_resolution_clock::now().time_since_epoch().count(); + const auto candidate = temporary / + ("audiocpp-model-installer-" + std::to_string(ticks) + "-" + + std::to_string(static_cast(random()))); + std::error_code error; + if (std::filesystem::create_directory(candidate, error)) { + return candidate; + } + if (error) { + throw std::runtime_error( + "failed to create model installer temporary directory: " + error.message()); + } + } + throw std::runtime_error("failed to allocate a unique model installer temporary directory"); +} + } // namespace struct ModelInstaller::State { @@ -200,6 +221,11 @@ struct ModelInstaller::State { std::filesystem::path size_error_path; std::filesystem::path installed_output_path; uint64_t size_generation = 0; + + ~State() { + std::error_code error; + std::filesystem::remove_all(job_root, error); + } }; ModelInstaller::ModelInstaller( @@ -208,8 +234,7 @@ ModelInstaller::ModelInstaller( : state_(std::make_shared()) { state_->repository_root = std::filesystem::absolute(std::move(repository_root)).lexically_normal(); state_->models_root = std::filesystem::absolute(std::move(models_root)).lexically_normal(); - state_->job_root = std::filesystem::temp_directory_path() / "audiocpp-model-installer"; - std::filesystem::create_directories(state_->job_root); + state_->job_root = make_job_root(); std::filesystem::create_directories(state_->models_root); state_->size_output_path = state_->job_root / "package-sizes.json"; state_->size_error_path = state_->job_root / "package-sizes.log"; diff --git a/tests/unittests/test_server_model_installer.cpp b/tests/unittests/test_server_model_installer.cpp index fa2583fc..e757851d 100644 --- a/tests/unittests/test_server_model_installer.cpp +++ b/tests/unittests/test_server_model_installer.cpp @@ -122,6 +122,49 @@ void test_idle_status_and_validation() { } require(sizes_completed, "the background package size scan completes"); + const auto other_root = make_root(); + try { + std::filesystem::create_directories(other_root / "tools"); + { + std::ofstream script(other_root / "tools" / "model_manager_v2.py", std::ios::binary); + script + << "import sys\n" + << "row = '[{\\\"id\\\":\\\"other_q8_0\\\",\\\"size_bytes\\\":50," + "\\\"state\\\":\\\"ok\\\",\\\"message\\\":\\\"\\\"," + "\\\"installed\\\":false}]'\n" + << "print(row, flush=True)\n"; + } + { + minitts::server::ModelInstaller other(other_root, other_root / "models"); + (void) other.package_sizes(); + bool other_completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto sizes = other.package_sizes(); + if (sizes.find("\"state\":\"complete\"") != std::string::npos) { + require(sizes.find("\"id\":\"other_q8_0\"") != std::string::npos, + "a second installer reads its own metadata output"); + other_completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(other_completed, "the second package size scan completes"); + } + const auto original_sizes = installer.package_sizes(); + require(original_sizes.find("\"id\":\"demo_q8_0\"") != std::string::npos, + "installer instances keep isolated metadata output files"); + require(original_sizes.find("\"id\":\"other_q8_0\"") == std::string::npos, + "another installer cannot overwrite a live inventory snapshot"); + } catch (...) { + std::error_code ec; + std::filesystem::remove_all(other_root, ec); + throw; + } + { + std::error_code ec; + std::filesystem::remove_all(other_root, ec); + } + bool missing_legacy_helper_reported = false; try { (void) installer.start("qwen3_asr_0_6b", "checkpoint.bin", "", "", "", false); diff --git a/tools/model_manager_v2.py b/tools/model_manager_v2.py index ea47f105..b39969d5 100644 --- a/tools/model_manager_v2.py +++ b/tools/model_manager_v2.py @@ -235,7 +235,10 @@ def install_package(package: PackageRecord, args: argparse.Namespace) -> None: return existing_outputs = [output for _remote, output in plan if output.exists()] if existing_outputs and not args.overwrite: - raise ManagerError(f"package files already exist in: {final_dir} (use --overwrite)") + if len(existing_outputs) == len(plan) and all(output.is_file() for output in existing_outputs): + print(f"already installed {package.id} -> {final_dir}") + return + raise ManagerError(f"some package files already exist in: {final_dir} (use --overwrite)") models_root.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root)) try: diff --git a/webui/model_manager_webui.py b/webui/model_manager_webui.py index 4ab5fedd..2554a941 100644 --- a/webui/model_manager_webui.py +++ b/webui/model_manager_webui.py @@ -235,7 +235,10 @@ def install_package(package: PackageRecord, args: argparse.Namespace) -> None: return existing_outputs = [output for _remote, output in plan if output.exists()] if existing_outputs and not args.overwrite: - raise ManagerError(f"package files already exist in: {final_dir} (use --overwrite)") + if len(existing_outputs) == len(plan) and all(output.is_file() for output in existing_outputs): + print(f"already installed {package.id} -> {final_dir}") + return + raise ManagerError(f"some package files already exist in: {final_dir} (use --overwrite)") models_root.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{package.target_directory.replace('/', '_')}.", dir=models_root)) try: diff --git a/webui/test_model_manager_webui.py b/webui/test_model_manager_webui.py index fca9e50d..17301510 100644 --- a/webui/test_model_manager_webui.py +++ b/webui/test_model_manager_webui.py @@ -155,14 +155,23 @@ def test_packages_without_a_strip_prefix_are_unchanged(self): self._install(package) self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "config.json"))) - def test_existing_target_requires_overwrite(self): + def test_complete_existing_package_is_an_idempotent_success(self): target = Path(self.root) / "Demo-GGUF" target.mkdir() (target / "model-q8_0.gguf").write_bytes(b"old") - with self.assertRaises(mmw.ManagerError): - self._install(_package(), overwrite=False) + self._install(_package(), overwrite=False) + self.assertEqual((target / "model-q8_0.gguf").read_bytes(), b"old") + self.assertEqual(self.calls, []) self._install(_package(), overwrite=True) - self.assertTrue(os.path.isfile(os.path.join(self.root, "Demo-GGUF", "model-q8_0.gguf"))) + self.assertEqual((target / "model-q8_0.gguf").read_bytes(), b"gguf") + + def test_partial_existing_package_still_requires_overwrite(self): + target = Path(self.root) / "Demo-GGUF" + target.mkdir() + (target / "model-q8_0.gguf").write_bytes(b"old") + package = _package(files=("Demo-GGUF/model-q8_0.gguf", "Demo-GGUF/config.json")) + with self.assertRaises(mmw.ManagerError): + self._install(package, overwrite=False) def test_precision_variants_can_share_a_target_directory(self): self._install(_package()) From 0fe5bca1fcc38038863e19c8f558418b3ad84fa8 Mon Sep 17 00:00:00 2001 From: mirek190 Date: Wed, 5 Aug 2026 16:56:42 +0100 Subject: [PATCH 4/4] Select installed WebUI model variants --- app/server/model_installer.cpp | 5 ++ .../unittests/test_server_model_installer.cpp | 18 +++++++ webui/native/dist/index.html | 20 ++++---- webui/native/src/app.css | 4 +- webui/native/src/routes/+page.svelte | 50 +++++++++++++++---- 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/app/server/model_installer.cpp b/app/server/model_installer.cpp index 9927100b..60bce2b2 100644 --- a/app/server/model_installer.cpp +++ b/app/server/model_installer.cpp @@ -351,6 +351,11 @@ std::string ModelInstaller::start( job.message = !log.message.empty() ? log.message : (result == 0 ? "Model installation completed" : "Model installation failed"); + if (result == 0) { + ++shared->size_generation; + shared->size_state = "idle"; + shared->size_message = "Package inventory will be refreshed"; + } } catch (const std::exception & error) { std::lock_guard lock(shared->mutex); auto & job = shared->jobs.at(package_id); diff --git a/tests/unittests/test_server_model_installer.cpp b/tests/unittests/test_server_model_installer.cpp index e757851d..ba1a2b6f 100644 --- a/tests/unittests/test_server_model_installer.cpp +++ b/tests/unittests/test_server_model_installer.cpp @@ -165,6 +165,24 @@ void test_idle_status_and_validation() { std::filesystem::remove_all(other_root, ec); } + const auto restarted = installer.start("qwen3_asr_0_6b", "", "", "", "", false); + require(restarted.find("\"state\":\"queued\"") != std::string::npos || + restarted.find("\"state\":\"running\"") != std::string::npos, + "a completed package can be prepared again"); + bool repeated_completed = false; + for (int attempt = 0; attempt < 100; ++attempt) { + const auto current = installer.status("qwen3_asr_0_6b"); + if (current.find("\"state\":\"complete\"") != std::string::npos) { + repeated_completed = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + require(repeated_completed, "the repeated package preparation completes"); + const auto refreshed_sizes = installer.package_sizes(); + require(refreshed_sizes.find("\"state\":\"running\"") != std::string::npos, + "a successful install invalidates the cached package inventory"); + bool missing_legacy_helper_reported = false; try { (void) installer.start("qwen3_asr_0_6b", "checkpoint.bin", "", "", "", false); diff --git a/webui/native/dist/index.html b/webui/native/dist/index.html index bf9d89ae..5322b9a1 100644 --- a/webui/native/dist/index.html +++ b/webui/native/dist/index.html @@ -6,27 +6,27 @@ -
diff --git a/webui/native/src/app.css b/webui/native/src/app.css index f5bde309..4064ca00 100644 --- a/webui/native/src/app.css +++ b/webui/native/src/app.css @@ -154,7 +154,9 @@ pre { background: #071220; border: 1px solid var(--line); border-radius: 8px; pa .package-install { display: flex; width: 100%; height: 48px; min-width: 0; min-height: 0; padding-top: 4px; padding-bottom: 4px; flex-direction: column; align-items: center; justify-content: center; gap: 2px; white-space: nowrap; } .package-install > span:first-child { line-height: 1.1; } .package-install.preferred { border-color: #347d8f; color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.1); } -.package-install.downloaded { padding-right: 23px; color: var(--muted); white-space: normal; } +.package-install.downloaded { padding-right: 23px; border-color: #345873; background: #102238; color: var(--blue); white-space: normal; } +.package-install.downloaded:hover:not(:disabled) { border-color: #4384a3; background: #142b43; } +.package-install.downloaded.preferred { border-color: #347d8f; color: var(--cyan); box-shadow: inset 0 0 0 1px rgba(66,232,213,.16); } .model-actions .package-delete { position: absolute; top: 3px; right: 3px; z-index: 1; display: grid; width: 18px; height: 18px; min-height: 0; padding: 2px; place-items: center; border-color: transparent; background: transparent; color: #7189a8; } .model-actions .package-delete:hover:not(:disabled) { border-color: rgba(239,107,124,.55); background: rgba(239,107,124,.12); color: var(--danger); transform: none; } .package-delete svg { width: 12px; height: 12px; fill: currentColor; } diff --git a/webui/native/src/routes/+page.svelte b/webui/native/src/routes/+page.svelte index 6b14a172..be1bc4b8 100644 --- a/webui/native/src/routes/+page.svelte +++ b/webui/native/src/routes/+page.svelte @@ -91,6 +91,7 @@ let packageSizes: Record = {}; let packageSizeState: 'idle' | 'running' | 'complete' | 'failed' = 'idle'; let packageSizePoll: number | null = null; + let refreshedInstallFinishes: Record = {}; const workflowTabs = [ { id: 'tts', label: 'Text to speech', tasks: ['tts', 'clon'] }, @@ -167,10 +168,8 @@ function installButtonLabel( choice: InstallPackageChoice, - job: ModelInstallJob | undefined, - isInstalled: boolean + job: ModelInstallJob | undefined ) { - if (isInstalled) return 'Already downloaded'; if (job?.state === 'running') return `${choice.label}…`; if (job?.state === 'queued') return `${choice.label} queued`; return choice.label; @@ -178,9 +177,14 @@ function packageSizeLabel( size: ModelPackageSize | undefined, - sizeState: 'idle' | 'running' | 'complete' | 'failed' + sizeState: 'idle' | 'running' | 'complete' | 'failed', + selected: boolean ) { - if (size?.size_bytes !== null && size?.size_bytes !== undefined) return formatBytes(size.size_bytes); + const bytes = size?.size_bytes !== null && size?.size_bytes !== undefined + ? formatBytes(size.size_bytes) + : ''; + if (size?.installed) return `${selected ? 'Selected' : 'Downloaded'}${bytes ? ` · ${bytes}` : ''}`; + if (bytes) return bytes; if (size?.state === 'pending') return 'checking size...'; if (size?.state === 'gated') return 'HF access required'; if (size?.state === 'error' || size?.state === 'unknown') return 'size unavailable'; @@ -656,6 +660,7 @@ // worker registration. This keeps the progress row visible from the click // until the authoritative job appears in a later poll. installJobs = { ...installJobs, ...incoming }; + let refreshInventory = false; for (const entry of catalog) { const completedJobs = entryInstallJobs(entry, installJobs).filter((job) => job.state === 'complete'); for (const job of completedJobs) { @@ -663,10 +668,21 @@ if (known && !known.installed) { packageSizes = { ...packageSizes, [job.id]: { ...known, installed: true } }; } + if (job.finished_at_ms > (refreshedInstallFinishes[job.id] || 0)) { + refreshedInstallFinishes = { + ...refreshedInstallFinishes, + [job.id]: job.finished_at_ms + }; + refreshInventory = true; + } } const complete = completedJobs.length > 0; if (complete && entry.id === selectedId) await inspectPath(); } + if (refreshInventory) { + packageSizeState = 'idle'; + await refreshPackageSizes(); + } const active = Object.values(installJobs).some((job) => job.state === 'queued' || job.state === 'running'); if (active && installPoll === null) { @@ -740,6 +756,16 @@ } } + function useOrInstallPackage(entry: CatalogEntry, choice: InstallPackageChoice) { + if (packageSizes[choice.id]?.installed) { + rememberPackagePath(entry, choice); + status = `${entry.display_name} will use ${choice.label}. Press Open to continue.`; + log(status); + return; + } + installPackage(entry, choice); + } + async function removePackage(entry: CatalogEntry, choice: InstallPackageChoice) { if (!packageSizes[choice.id]?.installed) return; const confirmed = window.confirm( @@ -1144,14 +1170,16 @@ {#if packageSizes[choice.id]?.installed}