From 5d9e5ac30e469d44c0a5a52556de0ead03aaa5b0 Mon Sep 17 00:00:00 2001 From: Chipmunk <101038159+CHIPMUNK-T0T@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:20:28 +0900 Subject: [PATCH 01/41] server : support slot save/restore with media inputs (#26640) * server : save serialized image chunks at the end of the llama state * server : support multimodal slot state save/restore with packed payload * server : refine image slot state serialization * server : support media slot state and centralize media validation * server : remove unnecessary comment * server : remove defensive media checks and move the chunk type check to validate() --- include/llama.h | 1 + src/llama-context.cpp | 11 + tools/server/server-common.cpp | 185 ++++++++++- tools/server/server-common.h | 10 +- tools/server/server-context.cpp | 81 +++-- tools/server/tests/unit/test_slot_save.py | 377 +++++++++++++++++++--- tools/server/tests/utils.py | 6 + 7 files changed, 593 insertions(+), 78 deletions(-) diff --git a/include/llama.h b/include/llama.h index bfef0e1d1..ef278c9c3 100644 --- a/include/llama.h +++ b/include/llama.h @@ -883,6 +883,7 @@ extern "C" { const llama_token * tokens, size_t n_token_count); + // If tokens_out is NULL, only the token count is reported through n_token_count_out and no state is loaded LLAMA_API size_t llama_state_seq_load_file( struct llama_context * ctx, const char * filepath, diff --git a/src/llama-context.cpp b/src/llama-context.cpp index 0de3a68d1..aa9fb2c3b 100644 --- a/src/llama-context.cpp +++ b/src/llama-context.cpp @@ -3110,6 +3110,17 @@ size_t llama_context::state_seq_load_file(llama_seq_id seq_id, const char * file { const uint32_t n_token_count = file.read_u32(); + if (tokens_out == nullptr) { + const size_t n_token_max = (file.size() - file.tell()) / sizeof(llama_token); + if (n_token_count > n_token_max) { + LLAMA_LOG_ERROR("%s: token count in sequence state file exceeds the file size! %u > %zu\n", __func__, n_token_count, n_token_max); + return 0; + } + + *n_token_count_out = n_token_count; + return file.tell(); + } + if (n_token_count > n_token_capacity) { LLAMA_LOG_ERROR("%s: token count in sequence state file exceeded capacity! %u > %zu\n", __func__, n_token_count, n_token_capacity); return 0; diff --git a/tools/server/server-common.cpp b/tools/server/server-common.cpp index c9109fc96..148b7ea6d 100644 --- a/tools/server/server-common.cpp +++ b/tools/server/server-common.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include json format_error_response(const std::string & message, const enum error_type type) { std::string type_str; @@ -235,6 +237,102 @@ static inline raw_buffer base64_decode(const std::string & encoded_string) { // server_tokens implementation // +namespace { + +constexpr uint32_t SERVER_TOKENS_STATE_VERSION = 1; + +uint32_t server_tokens_state_u32(size_t value) { + if (value > std::numeric_limits::max()) { + throw std::runtime_error("Server tokens state is too large"); + } + return value; +} + +class server_tokens_state_writer { +public: + template + void write(T value) { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + const auto * ptr = reinterpret_cast(&value); + data.insert(data.end(), ptr, ptr + sizeof(value)); + } + + template + void write(const std::vector & values) { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + write(server_tokens_state_u32(values.size())); + if (values.empty()) { + return; + } + const auto * ptr = reinterpret_cast(values.data()); + data.insert(data.end(), ptr, ptr + values.size() * sizeof(T)); + } + + void write_media_chunk(const mtmd_input_chunk * chunk) { + size_t chunk_size = 0; + if (mtmd_input_chunk_save(chunk, nullptr, 0, &chunk_size) != 0 || chunk_size == 0) { + throw std::runtime_error("Cannot serialize media chunk in server tokens"); + } + std::vector chunk_data(server_tokens_state_u32(chunk_size)); + if (mtmd_input_chunk_save(chunk, chunk_data.data(), chunk_data.size(), nullptr) != 0) { + throw std::runtime_error("Cannot serialize media chunk in server tokens"); + } + write(chunk_data); + } + + std::vector take() { + data.resize((data.size() + sizeof(llama_token) - 1) / sizeof(llama_token) * sizeof(llama_token), 0); + return std::move(data); + } + +private: + std::vector data; +}; + +class server_tokens_state_reader { +public: + server_tokens_state_reader(const char * data, size_t size) : data(data), size(size) {} + + template + T read() { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + if (size - pos < sizeof(T)) { + throw std::runtime_error("Unexpected end of server tokens state"); + } + T value; + std::memcpy(&value, data + pos, sizeof(value)); + pos += sizeof(value); + return value; + } + + template + std::vector read_vector() { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + const uint32_t n_values = read(); + // reject before resizing, so that a small corrupted payload cannot request a huge allocation + if (n_values > remaining() / sizeof(T)) { + throw std::runtime_error("Unexpected end of server tokens state"); + } + std::vector values(n_values); + if (n_values > 0) { + std::memcpy(values.data(), data + pos, values.size() * sizeof(T)); + pos += values.size() * sizeof(T); + } + return values; + } + + size_t remaining() const { + return size - pos; + } + +private: + const char * data; + size_t size; + size_t pos = 0; +}; + +} // namespace + server_tokens::server_tokens(mtmd::input_chunks & mtmd_chunks, bool has_mtmd) : has_mtmd(has_mtmd) { for (size_t i = 0; i < mtmd_chunks.size(); ++i) { push_back(mtmd_chunks[i]); @@ -408,6 +506,73 @@ const llama_tokens & server_tokens::get_tokens() const { return tokens; } +std::vector server_tokens::serialize() const { + static_assert(sizeof(llama_token) == sizeof(uint32_t), "unexpected llama_token size"); + + server_tokens_state_writer writer; + writer.write((llama_token) LLAMA_TOKEN_NULL); + writer.write(SERVER_TOKENS_STATE_VERSION); + writer.write(tokens); + + std::vector media_keys; + media_keys.reserve(map_idx_to_media.size()); + for (const auto & item : map_idx_to_media) { + media_keys.push_back(server_tokens_state_u32(item.first)); + } + writer.write(media_keys); + + for (const auto & item : map_idx_to_media) { + writer.write_media_chunk(item.second.get()); + } + + return writer.take(); +} + +server_tokens server_tokens::deserialize(const llama_tokens & packed, bool has_mtmd) { + static_assert(sizeof(llama_token) == sizeof(uint32_t), "unexpected llama_token size"); + + if (packed.empty() || packed[0] != LLAMA_TOKEN_NULL) { + // plain token list, as written by older versions + return server_tokens(packed, has_mtmd); + } + + server_tokens_state_reader reader(reinterpret_cast(packed.data()), packed.size() * sizeof(llama_token)); + reader.read(); // format marker + if (reader.read() != SERVER_TOKENS_STATE_VERSION) { + throw std::runtime_error("Unsupported server tokens state version"); + } + + const llama_tokens tokens = reader.read_vector(); + + // the media start indices, followed by the media chunks in the same order + const std::vector media_keys = reader.read_vector(); + if (!media_keys.empty() && !has_mtmd) { + throw std::runtime_error("Cannot restore media tokens without an mmproj"); + } + + server_tokens result(tokens, has_mtmd); + + for (const uint32_t key : media_keys) { + const size_t start_idx = key; + const std::vector chunk_data = reader.read_vector(); + if (chunk_data.empty()) { + throw std::runtime_error("Cannot load media chunk from server tokens state"); + } + + mtmd::input_chunk_ptr chunk(mtmd_input_chunk_load(chunk_data.data(), chunk_data.size())); + if (!chunk) { + throw std::runtime_error("Cannot load media chunk from server tokens state"); + } + result.map_idx_to_media[start_idx] = std::move(chunk); + } + + if (reader.remaining() >= sizeof(llama_token)) { + throw std::runtime_error("Trailing data in server tokens state"); + } + + return result; +} + llama_tokens server_tokens::get_text_tokens() const { llama_tokens res; res.reserve(tokens.size()); @@ -530,14 +695,28 @@ bool server_tokens::validate(const struct llama_context * ctx) const { const llama_model * model = llama_get_model(ctx); const llama_vocab * vocab = llama_model_get_vocab(model); const int32_t n_vocab = llama_vocab_n_tokens(vocab); + size_t n_media = 0; for (size_t i = 0; i < tokens.size(); ++i) { const auto & t = tokens[i]; if (t == LLAMA_TOKEN_NULL) { try { const auto & chunk = find_chunk(i); - size_t n_tokens = mtmd_input_chunk_get_n_tokens(chunk.get()); - i += n_tokens - 1; // will be +1 by the for loop + if (mtmd_input_chunk_get_type(chunk.get()) == MTMD_INPUT_CHUNK_TYPE_TEXT) { + return false; + } + const size_t n_tokens = mtmd_input_chunk_get_n_tokens(chunk.get()); + const llama_pos n_pos = mtmd_input_chunk_get_n_pos(chunk.get()); + if (n_tokens == 0 || n_pos <= 0 || n_tokens > tokens.size() - i) { + return false; + } + for (size_t j = i; j < i + n_tokens; ++j) { + if (tokens[j] != LLAMA_TOKEN_NULL) { + return false; + } + } + ++n_media; + i += n_tokens - 1; } catch (const std::exception & e) { return false; } @@ -545,7 +724,7 @@ bool server_tokens::validate(const struct llama_context * ctx) const { return false; } } - return true; + return n_media == map_idx_to_media.size(); } server_tokens server_tokens::clone() const { diff --git a/tools/server/server-common.h b/tools/server/server-common.h index 6ef797ebb..79607e288 100644 --- a/tools/server/server-common.h +++ b/tools/server/server-common.h @@ -201,11 +201,14 @@ public: // for compatibility with context shift and prompt truncation void insert(const llama_tokens & inp_tokens); - // for compatibility with speculative decoding, ctx shift, slot save/load + // for compatibility with speculative decoding, ctx shift const llama_tokens & get_tokens() const; llama_tokens get_text_tokens() const; + std::vector serialize() const; + static server_tokens deserialize(const llama_tokens & packed, bool has_mtmd); + // for compatibility with speculative decoding void set_token(llama_pos pos, llama_token id); @@ -213,9 +216,6 @@ public: bool empty() const { return tokens.empty(); } - // true if the sequence actually contains image/audio chunks. - bool has_media() const { return !map_idx_to_media.empty(); } - void clear() { map_idx_to_media.clear(); tokens.clear(); @@ -230,7 +230,7 @@ public: // split the tokens into message spans, skipping over media chunks common_chat_msg_spans find_message_spans(const common_chat_msg_delimiters & delims) const; - // make sure all text tokens are within the vocab range + // check text token IDs and the mapping between media chunks and token ranges bool validate(const struct llama_context * ctx) const; server_tokens clone() const; diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index d75c6856d..8b2ae72a4 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -2072,18 +2072,6 @@ private: queue_results.send(std::move(res)); } - // Gate slot save/restore/erase on slot content (does it hold media), - // not model capability: a multimodal model may hold a pure-text slot. - bool check_slot_no_media(const server_slot & slot, const int id_task) { - if (slot.prompt.tokens.has_media()) { - send_error(id_task, - "This operation is not supported while the slot holds image/audio tokens (a pure-text prefix is supported)", - ERROR_TYPE_NOT_SUPPORTED); - return false; - } - return true; - } - void send_partial_response(server_slot & slot, const completion_token_output & tkn, bool is_progress, bool is_begin = false) { auto res = std::make_unique(); @@ -2577,9 +2565,6 @@ private: send_error(task, "Invalid slot ID", ERROR_TYPE_INVALID_REQUEST); break; } - if (!check_slot_no_media(*slot, task.id)) { - break; - } if (slot->is_processing()) { // if requested slot is unavailable, we defer this task for processing later SRV_DBG("requested slot is unavailable, defer task, id_task = %d\n", task.id); @@ -2592,9 +2577,22 @@ private: std::string filename = task.slot_action.filename; std::string filepath = task.slot_action.filepath; - const llama_tokens tokens = slot->prompt.tokens.get_text_tokens(); - const size_t token_count = tokens.size(); - const size_t nwrite = llama_state_seq_save_file(ctx_tgt, filepath.c_str(), slot->id, tokens.data(), token_count); + std::vector packed; + try { + packed = slot->prompt.tokens.serialize(); + } catch (const std::exception & err) { + send_error(task, err.what(), ERROR_TYPE_NOT_SUPPORTED); + break; + } + + GGML_ASSERT(packed.size() % sizeof(llama_token) == 0); + const size_t nwrite = llama_state_seq_save_file( + ctx_tgt, filepath.c_str(), slot->id, + reinterpret_cast(packed.data()), packed.size() / sizeof(llama_token)); + if (nwrite == 0) { + send_error(task, "Unable to save slot", ERROR_TYPE_SERVER); + break; + } const int64_t t_end = ggml_time_us(); const double t_save_ms = (t_end - t_start) / 1000.0; @@ -2604,7 +2602,7 @@ private: res->id_slot = id_slot; res->filename = filename; res->is_save = true; - res->n_tokens = token_count; + res->n_tokens = slot->prompt.tokens.size(); res->n_bytes = nwrite; res->t_ms = t_save_ms; queue_results.send(std::move(res)); @@ -2629,18 +2627,37 @@ private: std::string filename = task.slot_action.filename; std::string filepath = task.slot_action.filepath; - llama_tokens tokens; - tokens.resize(slot->n_ctx); - size_t token_count = 0; - size_t nread = llama_state_seq_load_file(ctx_tgt, filepath.c_str(), slot->id, tokens.data(), tokens.size(), &token_count); - if (nread == 0) { - slot->prompt.clear(); // KV may already been invalidated? - send_error(task, "Unable to restore slot, no available space in KV cache or invalid slot save file", ERROR_TYPE_INVALID_REQUEST); + size_t nread = 0; + try { + size_t n_packed = 0; + llama_tokens packed; + nread = llama_state_seq_load_file(ctx_tgt, filepath.c_str(), slot->id, nullptr, 0, &n_packed); + if (nread != 0) { + packed.resize(std::max(1, n_packed)); + nread = llama_state_seq_load_file(ctx_tgt, filepath.c_str(), slot->id, packed.data(), packed.size(), &n_packed); + } + if (nread == 0) { + throw std::runtime_error("No available space in KV cache or invalid slot save file"); + } + packed.resize(n_packed); + + server_tokens restored = server_tokens::deserialize(packed, mctx != nullptr); + + if (restored.size() > (size_t) slot->n_ctx) { + throw std::runtime_error("Restored prompt does not fit in the slot context"); + } + + if (!restored.validate(ctx_tgt)) { + throw std::runtime_error("Invalid tokens in slot save file"); + } + + slot->prompt.clear(); + slot->prompt.tokens = std::move(restored); + } catch (const std::exception & err) { + slot->prompt_clear(); + send_error(task, std::string("Unable to restore slot: ") + err.what(), ERROR_TYPE_INVALID_REQUEST); break; } - tokens.resize(token_count); - slot->prompt.clear(); - slot->prompt.tokens.insert(tokens); const int64_t t_end = ggml_time_us(); const double t_restore_ms = (t_end - t_start) / 1000.0; @@ -2650,7 +2667,7 @@ private: res->id_slot = id_slot; res->filename = filename; res->is_save = false; - res->n_tokens = token_count; + res->n_tokens = slot->prompt.tokens.size(); res->n_bytes = nread; res->t_ms = t_restore_ms; queue_results.send(std::move(res)); @@ -2663,10 +2680,6 @@ private: send_error(task, "Invalid slot ID", ERROR_TYPE_INVALID_REQUEST); break; } - // Gate on slot content, consistent with save/restore. - if (!check_slot_no_media(*slot, task.id)) { - break; - } if (slot->is_processing()) { // if requested slot is unavailable, we defer this task for processing later SRV_DBG("requested slot is unavailable, defer task, id_task = %d\n", task.id); diff --git a/tools/server/tests/unit/test_slot_save.py b/tools/server/tests/unit/test_slot_save.py index be22d9859..05acb1be1 100644 --- a/tools/server/tests/unit/test_slot_save.py +++ b/tools/server/tests/unit/test_slot_save.py @@ -2,6 +2,10 @@ import pytest from utils import * import base64 import requests +import struct + +# sequence state file: magic(4) version(4) payload_size(4), then payload_size llama_token words +STATE_FILE_HEADER_SIZE = 12 server = ServerPreset.tinyllama2() @@ -72,6 +76,60 @@ def test_slot_save_restore(): assert res.body["timings"]["prompt_n"] == 1 +def test_slot_restore_legacy_token_list(): + global server + server.start() + + res = server.make_request("POST", "/completion", data={ + "prompt": "What is the capital of France?", + "id_slot": 1, + "cache_prompt": True, + }) + assert res.status_code == 200 + + res = server.make_request("POST", "/slots/1?action=save", data={ + "filename": "slot_legacy.bin", + }) + assert res.status_code == 200 + assert res.body["n_saved"] == 84 + + # rewrite the token payload into a plain token list, as written by servers that predate the packed server_tokens format + path = os.path.join("tmp", "slot_legacy.bin") + with open(path, "rb") as f: + data = bytearray(f.read()) + + # the payload written by this server starts with a packed header: LLAMA_TOKEN_NULL(4) version(4) n_tokens(4) + packed_header_size = 12 + + payload_size = struct.unpack_from("=I", data, STATE_FILE_HEADER_SIZE - 4)[0] + payload_end = STATE_FILE_HEADER_SIZE + payload_size * 4 + n_tokens = struct.unpack_from("=I", data, STATE_FILE_HEADER_SIZE + 8)[0] + assert n_tokens == 84 + + tokens_start = STATE_FILE_HEADER_SIZE + packed_header_size + data = data[:STATE_FILE_HEADER_SIZE] + data[tokens_start:tokens_start + n_tokens * 4] + data[payload_end:] + struct.pack_into("=I", data, STATE_FILE_HEADER_SIZE - 4, n_tokens) + + with open(path, "wb") as f: + f.write(data) + + # the plain token list must restore, and the restored KV must be reusable + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "slot_legacy.bin", + }) + assert res.status_code == 200 + assert res.body["n_restored"] == 84 + + res = server.make_request("POST", "/completion", data={ + "prompt": "What is the capital of Germany?", + "id_slot": 0, + "cache_prompt": True, + }) + assert res.status_code == 200 + assert res.body["timings"]["prompt_n"] == 6 # only the different part is processed + + + def test_slot_erase(): global server server.start() @@ -103,14 +161,12 @@ def test_slot_erase(): # # Multimodal server (mmproj loaded) slot save/restore. # -# Regression coverage for issue #21133: slot save/restore/erase must be gated on -# the slot's CONTENT (does it actually hold image/audio tokens) rather than the -# model's CAPABILITY (is an mmproj loaded). A pure-text slot on a multimodal -# server must save/restore/erase normally; a slot that actually holds an image -# must be rejected with ERROR_TYPE_NOT_SUPPORTED (HTTP 501). +# A pure-text slot on a multimodal server and a slot containing images must both support save/restore. +# Erase remains gated on the slot's content. # IMG_URL_CAT = "https://huggingface.co/ggml-org/tinygemma3-GGUF/resolve/main/test/91_cat.png" +IMG_URL_TRUCK = "https://huggingface.co/ggml-org/tinygemma3-GGUF/resolve/main/test/11_truck.png" def _get_img_base64(url: str) -> str: @@ -121,8 +177,7 @@ def _get_img_base64(url: str) -> str: @pytest.fixture def mmproj_server(): - # tinygemma3 is a small multimodal model: the mmproj is provided by the HF - # registry API and auto-downloaded on first run. + # tinygemma3 is a small multimodal model: the mmproj is provided by the HF registry API and auto-downloaded on first run. os.environ['LLAMA_MEDIA_MARKER'] = '<__media__>' mm_server = ServerPreset.tinygemma3() mm_server.slot_save_path = "./tmp" @@ -159,10 +214,7 @@ def test_slot_save_restore_text_only_on_multimodal(mmproj_server): assert res.status_code == 200 assert res.body["n_restored"] == n_saved - # The restored slot is usable for a follow-up completion. We do NOT assert - # prefix reuse here: tinygemma3 is a SWA model, which forces full prompt - # re-processing after a restore (a model property, not the save/restore gate - # under test). + # Prefix reuse is not checked with the default SWA cache. res = server.make_request("POST", "/completion", data={ "prompt": "The quick brown fox jumps over the lazy dog.", "id_slot": 0, @@ -171,54 +223,307 @@ def test_slot_save_restore_text_only_on_multimodal(mmproj_server): assert res.status_code == 200 -def test_slot_save_rejected_when_slot_holds_image(mmproj_server): +def test_slot_save_restore_with_image(mmproj_server): server = mmproj_server + # Use the full SWA cache so the restored image prefix can be reused. + server.swa_full = True server.start() - # Process a prompt that actually contains an image on slot 1. + prompt_cat = { + "prompt_string": "What is this: <__media__>\n", + "multimodal_data": [_get_img_base64(IMG_URL_CAT)], + } res = server.make_request("POST", "/completions", data={ "temperature": 0.0, "top_k": 1, "id_slot": 1, "cache_prompt": True, + "prompt": prompt_cat, + }) + assert res.status_code == 200 + content_cat = res.body["content"] + prompt_n_full = res.body["timings"]["prompt_n"] + assert res.body["timings"]["cache_n"] == 0 + assert prompt_n_full > 32 # text plus image tokens are all processed + + res = server.make_request("POST", "/slots/1?action=save", data={ + "filename": "mm_slot_image.bin", + }) + assert res.status_code == 200 + n_saved = res.body["n_saved"] + n_written = res.body["n_written"] + assert n_saved > 0 + assert n_written > 0 + + res = server.make_request("POST", "/slots/1?action=erase") + assert res.status_code == 200 + + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "mm_slot_image.bin", + }) + assert res.status_code == 200 + assert res.body["n_restored"] == n_saved + assert res.body["n_read"] == n_written + + # a different image must not reuse the restored image tokens; only the text prefix before the image is common + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, "prompt": { "prompt_string": "What is this: <__media__>\n", - "multimodal_data": [ _get_img_base64(IMG_URL_CAT) ], + "multimodal_data": [_get_img_base64(IMG_URL_TRUCK)], + }, + }) + assert res.status_code == 200 + cache_n = res.body["timings"]["cache_n"] + assert cache_n < 16 + assert res.body["timings"]["prompt_n"] == prompt_n_full - cache_n + + # restore again and resend the same image: the image tokens must be reused and greedy sampling must reproduce the original content + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "mm_slot_image.bin", + }) + assert res.status_code == 200 + assert res.body["n_restored"] == n_saved + + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": prompt_cat, + }) + assert res.status_code == 200 + assert res.body["timings"]["cache_n"] == prompt_n_full - 1 + assert res.body["timings"]["prompt_n"] == 1 + assert res.body["content"] == content_cat + + +def test_slot_save_restore_with_two_images(mmproj_server): + server = mmproj_server + server.swa_full = True + server.n_ctx = 2048 # two images need more than the default 512 per slot + server.start() + + prompt = { + "prompt_string": "A: <__media__> B: <__media__>\n", + "multimodal_data": [_get_img_base64(IMG_URL_CAT), _get_img_base64(IMG_URL_TRUCK)], + } + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 1, + "cache_prompt": True, + "prompt": prompt, + }) + assert res.status_code == 200 + content = res.body["content"] + prompt_n_full = res.body["timings"]["prompt_n"] + assert prompt_n_full > 64 + + res = server.make_request("POST", "/slots/1?action=save", data={ + "filename": "mm_slot_two_images.bin", + }) + assert res.status_code == 200 + n_saved = res.body["n_saved"] + + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "mm_slot_two_images.bin", + }) + assert res.status_code == 200 + assert res.body["n_restored"] == n_saved + + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": prompt, + }) + assert res.status_code == 200 + assert res.body["timings"]["cache_n"] == prompt_n_full - 1 + assert res.body["timings"]["prompt_n"] == 1 + assert res.body["content"] == content + + +def test_slot_save_restore_with_image_across_restart(mmproj_server): + server = mmproj_server + server.swa_full = True + server.start() + + prompt_cat = { + "prompt_string": "What is this: <__media__>\n", + "multimodal_data": [_get_img_base64(IMG_URL_CAT)], + } + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": prompt_cat, + }) + assert res.status_code == 200 + content = res.body["content"] + prompt_n_full = res.body["timings"]["prompt_n"] + + res = server.make_request("POST", "/slots/0?action=save", data={ + "filename": "mm_slot_restart.bin", + }) + assert res.status_code == 200 + n_saved = res.body["n_saved"] + + # restart the server with the same model and mmproj: the saved file must restore in the new process and the image KV must be reused + server.stop() + server.start() + + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "mm_slot_restart.bin", + }) + assert res.status_code == 200 + assert res.body["n_restored"] == n_saved + + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": prompt_cat, + }) + assert res.status_code == 200 + assert res.body["timings"]["cache_n"] == prompt_n_full - 1 + assert res.body["timings"]["prompt_n"] == 1 + assert res.body["content"] == content + + +def test_slot_save_restore_image_payload_larger_than_context(mmproj_server): + server = mmproj_server + server.swa_full = True + server.start() + + # the slot context, as the server computed it (n_ctx split across the slots) + res = server.make_request("GET", "/props") + assert res.status_code == 200 + n_ctx_slot = res.body["default_generation_settings"]["n_ctx"] + + # a filler token, used to grow the prompt up to the slot context + res = server.make_request("POST", "/tokenize", data={"content": " hello" * 8}) + assert res.status_code == 200 + assert len(res.body["tokens"]) == 8 + + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": { + "prompt_string": "What is this: <__media__>\n", + "multimodal_data": [_get_img_base64(IMG_URL_CAT)], }, }) assert res.status_code == 200 - # Saving a slot that holds image tokens must be rejected (HTTP 501, - # not_supported_error). - res = server.make_request("POST", "/slots/1?action=save", data={ - "filename": "mm_slot_image.bin", + prompt_cat = { + "prompt_string": "What is this: <__media__>\n" + " hello" * (n_ctx_slot - res.body["timings"]["prompt_n"] - 8), + "multimodal_data": [_get_img_base64(IMG_URL_CAT)], + } + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": prompt_cat, }) - assert res.status_code != 200 - assert res.body["error"]["type"] == "not_supported_error" + assert res.status_code == 200 + prompt_n_full = res.body["timings"]["cache_n"] + res.body["timings"]["prompt_n"] + + res = server.make_request("POST", "/slots/0?action=save", data={ + "filename": "mm_slot_large_payload.bin", + }) + assert res.status_code == 200 + + path = os.path.join("tmp", "mm_slot_large_payload.bin") + with open(path, "rb") as f: + data = bytearray(f.read()) + payload_size = struct.unpack_from("=I", data, STATE_FILE_HEADER_SIZE - 4)[0] + assert payload_size > n_ctx_slot # the scenario under test: the payload does not fit in n_ctx + + # drop the image from the slot, then restore it from the file + res = server.make_request("POST", "/completion", data={ + "prompt": "The quick brown fox", + "id_slot": 0, + "cache_prompt": True, + }) + assert res.status_code == 200 + + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "mm_slot_large_payload.bin", + }) + assert res.status_code == 200 + + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": prompt_cat, + }) + assert res.status_code == 200 + assert res.body["timings"]["cache_n"] == prompt_n_full - 1 + assert res.body["timings"]["prompt_n"] == 1 -def test_slot_erase_text_only_on_multimodal(mmproj_server): +def test_slot_restore_media_file_without_mmproj(mmproj_server): server = mmproj_server server.start() - res = server.make_request("POST", "/completion", data={ - "prompt": "The quick brown fox jumps over the lazy dog.", - "id_slot": 1, + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, "cache_prompt": True, + "prompt": { + "prompt_string": "What is this: <__media__>\n", + "multimodal_data": [_get_img_base64(IMG_URL_CAT)], + }, }) assert res.status_code == 200 - prompt_n = res.body["timings"]["prompt_n"] - assert prompt_n > 0 # all tokens are processed - # Erasing a pure-text slot must succeed even though an mmproj is loaded. - res = server.make_request("POST", "/slots/1?action=erase") - assert res.status_code == 200 - - # Re-running the same prompt should process all tokens again. - res = server.make_request("POST", "/completion", data={ - "prompt": "The quick brown fox jumps over the lazy dog.", - "id_slot": 1, - "cache_prompt": True, + res = server.make_request("POST", "/slots/0?action=save", data={ + "filename": "mm_slot_no_mmproj.bin", }) assert res.status_code == 200 - assert res.body["timings"]["prompt_n"] == prompt_n # all tokens are processed again + + # restart the same model without the mmproj: restoring the media file must fail gracefully and leave the slot usable + server.stop() + server.no_mmproj = True + server.start() + + res = server.make_request("POST", "/slots/0?action=restore", data={ + "filename": "mm_slot_no_mmproj.bin", + }) + assert res.status_code == 400 + assert "Cannot restore media tokens without an mmproj" in res.body["error"]["message"] + + # A failed restore must leave the slot empty and usable. + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 1, + "cache_prompt": True, + "prompt": "The quick brown fox", + }) + assert res.status_code == 200 + content = res.body["content"] + + res = server.make_request("POST", "/completions", data={ + "temperature": 0.0, + "top_k": 1, + "id_slot": 0, + "cache_prompt": True, + "prompt": "The quick brown fox", + }) + assert res.status_code == 200 + assert res.body["timings"]["cache_n"] == 0 + assert res.body["content"] == content diff --git a/tools/server/tests/utils.py b/tools/server/tests/utils.py index fffe07a67..9171dbc02 100644 --- a/tools/server/tests/utils.py +++ b/tools/server/tests/utils.py @@ -86,6 +86,7 @@ class ServerProcess: server_reranking: bool | None = False server_metrics: bool | None = False kv_unified: bool | None = False + swa_full: bool | None = False server_slots: bool | None = False pooling: str | None = None api_key: str | None = None @@ -106,6 +107,7 @@ class ServerProcess: chat_template_file: str | None = None server_path: str | None = None mmproj_url: str | None = None + no_mmproj: bool | None = None media_path: str | None = None sleep_idle_seconds: int | None = None cache_ram: int | None = None @@ -198,6 +200,8 @@ class ServerProcess: server_args.append("--metrics") if self.kv_unified: server_args.append("--kv-unified") + if self.swa_full: + server_args.append("--swa-full") if self.server_slots: server_args.append("--slots") else: @@ -259,6 +263,8 @@ class ServerProcess: server_args.extend(["--chat-template-file", self.chat_template_file]) if self.mmproj_url: server_args.extend(["--mmproj-url", self.mmproj_url]) + if self.no_mmproj: + server_args.append("--no-mmproj") if self.media_path: server_args.extend(["--media-path", self.media_path]) if self.sleep_idle_seconds is not None: From 13fd0bb55ed30216ec47ee615c0a28455baedbf0 Mon Sep 17 00:00:00 2001 From: Daniel Bevenius Date: Wed, 12 Aug 2026 12:46:06 +0200 Subject: [PATCH 02/41] cmake : add config version support (ggml/1582) * cmake : add config version support (wip) [no ci] This commit adds support for find_package using a version, for example: ``` find_package(ggml 0.19.0 REQUIRED) ``` examples/test-cmake has been updated to use this and build scripts have been added to verify this manually. This is still a work in progress and I'm not sure about the scripts and if we can find better ways to test this but it might be useful to have for verification of changes to the cmake build. * cmake : add semver to ggml backends [no ci] This commit adds a semver to the ggml backend modules files. The motivation for this is that the backends are currently loaded just a file extension, for example .so on linux. With the introduction of semantic versioning installing a new version should just work but since these files don't have a version they would get overwritten. Adding the semver to the library names allows multiple version to be supported and the correct one will be loaded by the code. I've only tested this on linux and need to test on mac and win. * Revert "cmake : add semver to ggml backends [no ci]" This reverts commit 53a6c58a07591951324c891b9986b2cffe5c7972. * examples : update build-install.sh and set GGML_BACKEND_DIR --- ggml/CMakeLists.txt | 4 ++-- ggml/cmake/ggml-config.cmake.in | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ggml/CMakeLists.txt b/ggml/CMakeLists.txt index 1b1de6b7d..b6843578b 100644 --- a/ggml/CMakeLists.txt +++ b/ggml/CMakeLists.txt @@ -402,7 +402,7 @@ configure_package_config_file( GGML_BIN_INSTALL_DIR) write_basic_package_version_file( - ${CMAKE_CURRENT_BINARY_DIR}/ggml-version.cmake + ${CMAKE_CURRENT_BINARY_DIR}/ggml-config-version.cmake VERSION ${GGML_INSTALL_VERSION} COMPATIBILITY SameMajorVersion) @@ -414,7 +414,7 @@ message(STATUS "ggml version: ${GGML_INSTALL_VERSION}") message(STATUS "ggml commit: ${GGML_BUILD_COMMIT}") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ggml-config.cmake - ${CMAKE_CURRENT_BINARY_DIR}/ggml-version.cmake + ${CMAKE_CURRENT_BINARY_DIR}/ggml-config-version.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/ggml) if (MSVC) diff --git a/ggml/cmake/ggml-config.cmake.in b/ggml/cmake/ggml-config.cmake.in index 23a3066f5..abe17804a 100644 --- a/ggml/cmake/ggml-config.cmake.in +++ b/ggml/cmake/ggml-config.cmake.in @@ -113,6 +113,7 @@ set_and_check(GGML_LIB_DIR "@PACKAGE_GGML_LIB_INSTALL_DIR@") if(NOT TARGET ggml::ggml) find_package(Threads REQUIRED) + unset(GGML_LIBRARY CACHE) find_library(GGML_LIBRARY ggml REQUIRED HINTS ${GGML_LIB_DIR} @@ -121,8 +122,10 @@ if(NOT TARGET ggml::ggml) add_library(ggml::ggml UNKNOWN IMPORTED) set_target_properties(ggml::ggml PROPERTIES - IMPORTED_LOCATION "${GGML_LIBRARY}") + IMPORTED_LOCATION "${GGML_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${GGML_INCLUDE_DIR}") + unset(GGML_BASE_LIBRARY CACHE) find_library(GGML_BASE_LIBRARY ggml-base REQUIRED HINTS ${GGML_LIB_DIR} @@ -132,6 +135,7 @@ if(NOT TARGET ggml::ggml) set_target_properties(ggml::ggml-base PROPERTIES IMPORTED_LOCATION "${GGML_BASE_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${GGML_INCLUDE_DIR}" INTERFACE_LINK_LIBRARIES "${GGML_BASE_INTERFACE_LINK_LIBRARIES}") set(_ggml_all_targets "") @@ -140,6 +144,7 @@ if(NOT TARGET ggml::ggml) string(REPLACE "-" "_" _ggml_backend_pfx "${_ggml_backend}") string(TOUPPER "${_ggml_backend_pfx}" _ggml_backend_pfx) + unset(${_ggml_backend_pfx}_LIBRARY CACHE) find_library(${_ggml_backend_pfx}_LIBRARY ${_ggml_backend} REQUIRED HINTS ${GGML_LIB_DIR} From af05a42a7c18db1f64be406505e394f61589cf56 Mon Sep 17 00:00:00 2001 From: Georgi Gerganov Date: Wed, 12 Aug 2026 13:59:44 +0300 Subject: [PATCH 03/41] sync : ggml --- scripts/sync-ggml.last | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sync-ggml.last b/scripts/sync-ggml.last index 7af9ecb0f..c08cb625b 100644 --- a/scripts/sync-ggml.last +++ b/scripts/sync-ggml.last @@ -1 +1 @@ -30bf8685ed4eb0a47f2b06229543327749904150 +8846b79e66747bb9f68597420e95114c177315ce From ece98b87f72bc9e3bd356a217feda8dd5f9ca24c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigbj=C3=B8rn=20Skj=C3=A6ret?= Date: Wed, 12 Aug 2026 13:24:10 +0200 Subject: [PATCH 04/41] model : disallow integer dflash sliding_window_pattern (#26900) * fix sliding_window_pattern * disallow integer pattern --- src/models/dflash.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/dflash.cpp b/src/models/dflash.cpp index bfbdb28ee..eb3633676 100644 --- a/src/models/dflash.cpp +++ b/src/models/dflash.cpp @@ -66,7 +66,7 @@ void llama_model_dflash::load_arch_hparams(llama_model_loader & ml) { // DFlash has a single rope, so the SWA rope == main rope. if (ml.get_key(LLM_KV_ATTENTION_SLIDING_WINDOW, hparams.n_swa, false) && hparams.n_swa > 0) { hparams.swa_type = LLAMA_SWA_TYPE_STANDARD; - ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, hparams.is_swa_impl, hparams.n_layer()); + ml.get_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, hparams.is_swa_impl); hparams.rope_freq_base_train_swa = hparams.rope_freq_base_train; hparams.rope_freq_scale_train_swa = hparams.rope_freq_scale_train; } From 132753bf4e52b1a8bda8f6ec33f1785bd80470da Mon Sep 17 00:00:00 2001 From: Jonathan Clohessy Date: Wed, 12 Aug 2026 12:49:11 +0100 Subject: [PATCH 05/41] kleidiai: Add runtime feature detection mechanism for aarch64/kleidiai (#26076) * Add runtime feature detection mechanism for aarch64/kleidiai Signed-off-by: Jonathan Clohessy * Address Review Comments Signed-off-by: Jonathan Clohessy * Add log warning for NSMC reserved value Signed-off-by: Jonathan Clohessy * Address review comments Signed-off-by: Jonathan Clohessy * Fix Rebase, move code from cpu-feats to ggml-feats Signed-off-by: Jonathan Clohessy * Address naming of runtime feature struct Signed-off-by: Jonathan Clohessy --------- Signed-off-by: Jonathan Clohessy --- ggml/src/ggml-cpu/arch/arm/cpu-feats.cpp | 87 +------ ggml/src/ggml-cpu/kleidiai/kleidiai.cpp | 312 ++++++++++++++++------- ggml/src/ggml-feats.h | 166 ++++++++++++ 3 files changed, 383 insertions(+), 182 deletions(-) create mode 100644 ggml/src/ggml-feats.h diff --git a/ggml/src/ggml-cpu/arch/arm/cpu-feats.cpp b/ggml/src/ggml-cpu/arch/arm/cpu-feats.cpp index a64ad7a3c..139425608 100644 --- a/ggml/src/ggml-cpu/arch/arm/cpu-feats.cpp +++ b/ggml/src/ggml-cpu/arch/arm/cpu-feats.cpp @@ -1,90 +1,11 @@ #include "ggml-backend-impl.h" +#include "ggml-feats.h" -#if defined(__aarch64__) - -#if defined(__linux__) -#include -#elif defined(__APPLE__) -#include -#endif - -#if !defined(HWCAP_FPHP) -#define HWCAP_FPHP (1 << 9) -#endif - -#if !defined(HWCAP_ASIMDHP) -#define HWCAP_ASIMDHP (1 << 10) -#endif - -#if !defined(HWCAP_ASIMDDP) -#define HWCAP_ASIMDDP (1 << 20) -#endif - -#if !defined(HWCAP_SVE) -#define HWCAP_SVE (1 << 22) -#endif - -#if !defined(HWCAP2_SVE2) -#define HWCAP2_SVE2 (1 << 1) -#endif - -#if !defined(HWCAP2_I8MM) -#define HWCAP2_I8MM (1 << 13) -#endif - -#if !defined(HWCAP2_SME) -#define HWCAP2_SME (1 << 23) -#endif - -struct aarch64_features { - // has_neon not needed, aarch64 has NEON guaranteed - bool has_dotprod = false; - bool has_fp16 = false; - bool has_sve = false; - bool has_sve2 = false; - bool has_i8mm = false; - bool has_sme = false; - bool has_sme2 = false; - - aarch64_features() { -#if defined(__linux__) - uint32_t hwcap = getauxval(AT_HWCAP); - uint32_t hwcap2 = getauxval(AT_HWCAP2); - - has_dotprod = !!(hwcap & HWCAP_ASIMDDP); - has_fp16 = !!(hwcap & HWCAP_FPHP) && !!(hwcap & HWCAP_ASIMDHP); - has_sve = !!(hwcap & HWCAP_SVE); - has_sve2 = !!(hwcap2 & HWCAP2_SVE2); - has_i8mm = !!(hwcap2 & HWCAP2_I8MM); - has_sme = !!(hwcap2 & HWCAP2_SME); -#elif defined(__APPLE__) - int oldp = 0; - size_t size = sizeof(oldp); - - if (sysctlbyname("hw.optional.arm.FEAT_DotProd", &oldp, &size, NULL, 0) == 0) { - has_dotprod = static_cast(oldp); - } - - if (sysctlbyname("hw.optional.arm.FEAT_I8MM", &oldp, &size, NULL, 0) == 0) { - has_i8mm = static_cast(oldp); - } - - if (sysctlbyname("hw.optional.arm.FEAT_SME", &oldp, &size, NULL, 0) == 0) { - has_sme = static_cast(oldp); - } - - if (sysctlbyname("hw.optional.arm.FEAT_SME2", &oldp, &size, NULL, 0) == 0) { - has_sme2 = static_cast(oldp); - } - - // Apple apparently does not implement SVE yet -#endif - } -}; +#if defined(__aarch64__) || defined(_M_ARM64) static int ggml_backend_cpu_aarch64_score() { int score = 1; - aarch64_features af; + ggml_feats_arch64_runtime_t af = ggml_get_aarch64_runtime_features(); #ifdef GGML_USE_DOTPROD if (!af.has_dotprod) { return 0; } @@ -116,4 +37,4 @@ static int ggml_backend_cpu_aarch64_score() { GGML_BACKEND_DL_SCORE_IMPL(ggml_backend_cpu_aarch64_score) -# endif // defined(__aarch64__) +# endif // defined(__aarch64__) || defined(_M_ARM64) diff --git a/ggml/src/ggml-cpu/kleidiai/kleidiai.cpp b/ggml/src/ggml-cpu/kleidiai/kleidiai.cpp index 1c5a459f2..c1cc33db2 100644 --- a/ggml/src/ggml-cpu/kleidiai/kleidiai.cpp +++ b/ggml/src/ggml-cpu/kleidiai/kleidiai.cpp @@ -2,10 +2,12 @@ // SPDX-License-Identifier: MIT // #include -#include -#include +#include +#include +#include #include #include +#include #include #include #include @@ -17,25 +19,21 @@ #include #include #include -#include +#include #include #include +#include +#include #if defined(__linux__) #include +#include #include #include #include #include -#ifndef HWCAP2_SME2 -#define HWCAP2_SME2 (1UL << 37) -#endif #elif defined(__APPLE__) -#include #include #include -#elif defined(_WIN32) -#include -#include #endif #include "kleidiai.h" @@ -43,6 +41,7 @@ #include "ggml-cpu.h" #include "ggml-cpu-impl.h" #include "ggml-impl.h" +#include "ggml-feats.h" #include "ggml-backend-impl.h" #include "ggml-threading.h" #include "traits.h" @@ -64,8 +63,8 @@ struct ggml_kleidiai_context { ggml_kleidiai_kernels * kernels_q4; ggml_kleidiai_kernels * kernels_q8; ggml_kleidiai_kernels * kernels_f32; - int sme_thread_cap; // <= 0 means “SME disabled/unknown”; - int thread_hint; // <= 0 means “no hint” + int sme_thread_cap; // <= 0 means "SME disabled/unknown" + int thread_hint; // <= 0 means "no hint" int chunk_multiplier; } static ctx = { CPU_FEATURE_NONE, nullptr, nullptr, nullptr, 0, -1, 4 }; @@ -93,24 +92,117 @@ static const char* cpu_feature_to_string(cpu_feature f) { } } +#if defined(__linux__) && defined(__aarch64__) +static bool parse_cpu_dir_name(const char* name, size_t* cpu) { + if (strncmp(name, "cpu", 3) != 0 || + name[3] < '0' || name[3] > '9') { + return false; + } + + const char* first = name + 3; + const char* last = name + strlen(name); + + size_t value = 0; + const auto [end, ec] = std::from_chars(first, last, value, 10); + + if (ec != std::errc{} || end != last) { + return false; + } + + *cpu = value; + return true; +} + +static std::vector detect_cpu_ids() { + std::vector cpus; + + DIR * dir = opendir("/sys/devices/system/cpu"); + if (dir == nullptr) { + return cpus; + } + + while (dirent * entry = readdir(dir)) { + size_t cpu = 0; + if (parse_cpu_dir_name(entry->d_name, &cpu)) { + cpus.push_back(cpu); + } + } + closedir(dir); + + std::sort(cpus.begin(), cpus.end()); + cpus.erase(std::unique(cpus.begin(), cpus.end()), cpus.end()); + return cpus; +} +#endif + +#if defined(__APPLE__) && defined(__aarch64__) +static bool apple_sme_counted_perf_level(std::string name) { + for (std::string::size_type i = 0; i < name.size(); ++i) { + name[i] = (char) std::tolower((unsigned char) name[i]); + } + + // Conservative ceiling: only count perf-level names observed to provide full SME throughput. + // Future names should be calibrated here before they raise the automatic SME thread cap. + return name.find("super") != std::string::npos || + name.find("performance") != std::string::npos; +} +#endif + +static void add_smcus_from_smidr(uint64_t smidr, size_t & num_private, std::map & shared_counts) { + // Arm ARM: SMIDR_EL1. SH==0 is implementation-defined; keep the existing + // conservative policy and only treat zero affinity as private. + const uint32_t sh = (uint32_t)((smidr >> 13) & 0x3); + const uint32_t nsmc = (uint32_t)((smidr >> 56) & 0xF); + const size_t shared_count = nsmc == 0xF ? 1 : (size_t)nsmc + 1; + const uint32_t affinity = (uint32_t)(smidr & 0xFFFu); + const uint32_t affinity2 = (uint32_t)((smidr >> 32) & 0xFFFFFu); + const uint32_t id = (affinity2 << 12) | affinity; + + if (nsmc == 0xF) { + GGML_LOG_WARN("kleidiai: NSMC detected as 0xF indicating reseved value, setting min safe shared SMCU count to 1"); + } + + switch (sh) { + case 2: // private SMCU + ++num_private; + break; + case 3: // shared SMCU + if (shared_counts[id] < shared_count) { + shared_counts[id] = shared_count; + } + break; + case 0: + if (id == 0) { + ++num_private; + } else if (shared_counts[id] < shared_count) { + shared_counts[id] = shared_count; + } + break; + default: + break; + } +} + static size_t detect_num_smcus() { - if (!ggml_cpu_has_sme()) { + auto runtime_feat = ggml_get_aarch64_runtime_features(); + if (!runtime_feat.has_sme) { return 0; } #if defined(__linux__) && defined(__aarch64__) // Linux/aarch64: Best-effort count of Streaming Mode Compute Units (SMCUs) via SMIDR_EL1 sysfs. size_t num_private = 0; - std::set shared_ids; + std::map shared_counts; - for (size_t cpu = 0;; ++cpu) { + const std::vector cpus = detect_cpu_ids(); + for (const size_t cpu : cpus) { const std::string path = "/sys/devices/system/cpu/cpu" + std::to_string(cpu) + "/regs/identification/smidr_el1"; std::ifstream file(path); if (!file.is_open()) { - break; + continue; } uint64_t smidr = 0; @@ -118,54 +210,69 @@ static size_t detect_num_smcus() { continue; } - // Arm ARM: SMIDR_EL1 - const uint32_t sh = (uint32_t)((smidr >> 13) & 0x3); - // Build an "affinity-like" identifier for shared SMCUs. - // Keep the original packing logic, but isolate it here. - const uint32_t id = (uint32_t)((smidr & 0xFFFu) | ((smidr >> 20) & 0xFFFFF000u)); - - switch (sh) { - case 0b10: // private SMCU - ++num_private; - break; - case 0b11: // shared SMCU - shared_ids.emplace(id); - break; - case 0b00: - // Ambiguous / implementation-defined. Be conservative: - // treat id==0 as private, otherwise as shared. - if (id == 0) ++num_private; - else shared_ids.emplace(id); - break; - default: - break; - } + add_smcus_from_smidr(smidr, num_private, shared_counts); } - return num_private + shared_ids.size(); + size_t total = num_private; + for (const auto & entry : shared_counts) { + total += entry.second; + } + return total; #elif defined(__APPLE__) && defined(__aarch64__) - // table for known M4 variants. Users can override via GGML_KLEIDIAI_SME=. - char chip_name[256] = {}; - size_t size = sizeof(chip_name); + int perf_levels = 0; + size_t size = sizeof(perf_levels); + if (sysctlbyname("hw.nperflevels", &perf_levels, &size, nullptr, 0) != 0 || + size != sizeof(perf_levels) || perf_levels <= 0) { + return 0; + } - if (sysctlbyname("machdep.cpu.brand_string", chip_name, &size, nullptr, 0) == 0) { - const std::string brand(chip_name); + size_t units = 0; + for (int i = 0; i < perf_levels; ++i) { + char key[64] = {}; + int physical_cpus = 0; + int cpus_per_l2 = 0; - struct ModelSMCU { const char *match; size_t smcus; }; - static const ModelSMCU table[] = { - { "M4 Ultra", 2 }, - { "M4 Max", 2 }, - { "M4 Pro", 2 }, - { "M4", 1 }, - }; + snprintf(key, sizeof(key), "hw.perflevel%d.physicalcpu", i); + size = sizeof(physical_cpus); + if (sysctlbyname(key, &physical_cpus, &size, nullptr, 0) != 0 || + size != sizeof(physical_cpus) || physical_cpus <= 0) { + continue; + } - for (const auto &e : table) { - if (brand.find(e.match) != std::string::npos) { - return e.smcus; - } + snprintf(key, sizeof(key), "hw.perflevel%d.cpusperl2", i); + size = sizeof(cpus_per_l2); + if (sysctlbyname(key, &cpus_per_l2, &size, nullptr, 0) != 0 || + size != sizeof(cpus_per_l2) || cpus_per_l2 <= 0) { + continue; + } + + snprintf(key, sizeof(key), "hw.perflevel%d.name", i); + size = 0; + if (sysctlbyname(key, nullptr, &size, nullptr, 0) != 0 || size == 0) { + continue; + } + + std::string name(size, '\0'); + if (sysctlbyname(key, &name[0], &size, nullptr, 0) != 0) { + continue; + } + name.resize(size); + while (!name.empty() && name.back() == '\0') { + name.pop_back(); + } + + if (apple_sme_counted_perf_level(name)) { + units += (size_t) ((physical_cpus + cpus_per_l2 - 1) / cpus_per_l2); } } + + return units; + +#elif defined(_WIN32) && (defined(_M_ARM64) || defined(__aarch64__)) + // No verified Windows arm64 SMCU detection path yet. Return unknown and use + // GGML_KLEIDIAI_SME=N as a diagnostics/debug override for SME thread cap + // calibration until a detection mechanism is verified on real hardware. return 0; #else @@ -198,15 +305,18 @@ static void init_kleidiai_context(void) { if (!initialized) { initialized = true; + // Optional diagnostics/debug overrides; production defaults come from runtime detection. const char *env_sme = getenv("GGML_KLEIDIAI_SME"); const char *env_threads = getenv("GGML_TOTAL_THREADS"); const char *env_chunk_mult = getenv("GGML_KLEIDIAI_CHUNK_MULTIPLIER"); + auto runtime_feat = ggml_get_aarch64_runtime_features(); + size_t detected_smcus = 0; - ctx.features = (ggml_cpu_has_dotprod() ? CPU_FEATURE_DOTPROD : CPU_FEATURE_NONE) | - (ggml_cpu_has_matmul_int8() ? CPU_FEATURE_I8MM : CPU_FEATURE_NONE) | - ((ggml_cpu_has_sve() && ggml_cpu_get_sve_cnt() == QK8_0) ? CPU_FEATURE_SVE : CPU_FEATURE_NONE); + ctx.features = (runtime_feat.has_dotprod ? CPU_FEATURE_DOTPROD : CPU_FEATURE_NONE) | + (runtime_feat.has_i8mm ? CPU_FEATURE_I8MM : CPU_FEATURE_NONE) | + (runtime_feat.sve_cnt == QK8_0 ? CPU_FEATURE_SVE : CPU_FEATURE_NONE); if (env_threads) { bool ok = false; @@ -224,54 +334,54 @@ static void init_kleidiai_context(void) { } } - // SME policy: - // - env unset => auto-detect SMCUs; enable SME only if detected > 0. - // - env=0 => force off. - // - env>0 => force N cores, if the binary was built with SME. int sme_cores = 0; bool sme_env_ok = false; bool sme_env_set = (env_sme != nullptr); + const bool has_supported_sme_family = runtime_feat.has_sme; + bool sme_cap_detected = false; + + if (has_supported_sme_family) { + detected_smcus = detect_num_smcus(); + sme_cap_detected = detected_smcus > 0; + // Some platforms expose SME without exposing a calibrated SMCU count. + // Use one SME thread as the conservative default; add platform SMCU detection to raise it. + sme_cores = sme_cap_detected ? (int)detected_smcus : 1; + + if (!sme_env_set && !sme_cap_detected) { + GGML_LOG_INFO("kleidiai: SME detected; SMCU count unavailable, using conservative SME thread cap=1\n"); + } + } + + // Runtime-detect SME support and available SMCUs first. The detected SMCU + // count is used as the SME thread cap, and GGML_KLEIDIAI_SME can debug-override that: + // - unset: use runtime detection. + // - 0: disable SME-family kernels. + // - N > 0: use N as the SME thread cap, if an SME-family kernel is selectable. if (sme_env_set) { bool ok = false; int v = parse_uint_env(env_sme, "GGML_KLEIDIAI_SME", &ok); sme_env_ok = ok; - if (!ok) { - GGML_LOG_WARN("kleidiai: GGML_KLEIDIAI_SME set but parsing failed; falling back to runtime SME-core detection\n"); - detected_smcus = detect_num_smcus(); - sme_cores = detected_smcus > 0 ? (int)detected_smcus : 0; - } else if (v == 0) { - sme_cores = 0; - } else if (!ggml_cpu_has_sme()) { - GGML_LOG_WARN("kleidiai: GGML_KLEIDIAI_SME=%d but the binary was not built with SME; disabling SME\n", v); - sme_cores = 0; + if (ok) { + if (has_supported_sme_family) { + sme_cores = v; + } else { + if (v > 0) { + GGML_LOG_WARN("kleidiai: GGML_KLEIDIAI_SME=%d but SME is not supported on this CPU; disabling SME-family kernels\n", v); + } + sme_cores = 0; + } } else { - sme_cores = v; + GGML_LOG_WARN("kleidiai: GGML_KLEIDIAI_SME set but parsing failed; using automatic SME thread cap\n"); } - } else { - detected_smcus = detect_num_smcus(); - sme_cores = detected_smcus > 0 ? (int)detected_smcus : 0; } - if (!sme_env_set && ggml_cpu_has_sme() && sme_cores == 0) { - GGML_LOG_WARN("kleidiai: runtime SME-core detection returned 0; falling back to NEON\n"); - } - - if (sme_cores > 0) { + if (sme_cores > 0 && has_supported_sme_family) { ctx.features |= CPU_FEATURE_SME; -#if defined(__aarch64__) && defined(__linux__) - // ARM guarantees SME2 implies SME, so only check SME2 when SME is enabled. - if (getauxval(AT_HWCAP2) & HWCAP2_SME2) { + if (runtime_feat.has_sme2) { ctx.features |= CPU_FEATURE_SME2; } -#elif defined(__aarch64__) && defined(__APPLE__) - int feat_sme2 = 0; - size_t size = sizeof(feat_sme2); - if (sysctlbyname("hw.optional.arm.FEAT_SME2", &feat_sme2, &size, NULL, 0) == 0 && feat_sme2) { - ctx.features |= CPU_FEATURE_SME2; - } -#endif } // Kernel selection @@ -297,16 +407,19 @@ static void init_kleidiai_context(void) { GGML_LOG_INFO("kleidiai: primary f32 kernel feature %s\n", cpu_feature_to_string(ctx.kernels_f32->required_cpu)); } - ctx.sme_thread_cap = (ctx.features & CPU_FEATURE_SME) ? sme_cores : 0; + const bool has_selected_sme_family_kernel = + (ctx.kernels_q4 && is_sme_family(ctx.kernels_q4->required_cpu)) || + (ctx.kernels_q8 && is_sme_family(ctx.kernels_q8->required_cpu)) || + (ctx.kernels_f32 && is_sme_family(ctx.kernels_f32->required_cpu)); + ctx.sme_thread_cap = has_selected_sme_family_kernel ? sme_cores : 0; - if (ctx.features & CPU_FEATURE_SME) { - const bool has_sme2 = (ctx.features & CPU_FEATURE_SME2) != CPU_FEATURE_NONE; + if (has_selected_sme_family_kernel) { if (sme_env_set && sme_env_ok && sme_cores > 0) { - GGML_LOG_INFO("kleidiai: SME%s enabled (GGML_KLEIDIAI_SME=%d override)\n", - has_sme2 ? "2" : "", sme_cores); + GGML_LOG_INFO("kleidiai: SME enabled (GGML_KLEIDIAI_SME=%d debug override)\n", sme_cores); + } else if (sme_cap_detected) { + GGML_LOG_INFO("kleidiai: SME enabled (runtime-detected SME thread cap=%d)\n", sme_cores); } else { - GGML_LOG_INFO("kleidiai: SME%s enabled (runtime-detected SME cores=%d)\n", - has_sme2 ? "2" : "", sme_cores); + GGML_LOG_INFO("kleidiai: SME enabled (runtime SME detected, conservative thread cap=%d)\n", sme_cores); } } else { GGML_LOG_INFO("kleidiai: SME disabled\n"); @@ -467,7 +580,7 @@ static int kleidiai_collect_kernel_chain_common( } if (is_sme_family(primary->required_cpu)) { - const cpu_feature fallback_mask = static_cast(features & ~CPU_FEATURE_SME & ~CPU_FEATURE_SME2); + const cpu_feature fallback_mask = static_cast(features & ~(CPU_FEATURE_SME | CPU_FEATURE_SME2)); if (fallback_mask != CPU_FEATURE_NONE) { ggml_kleidiai_kernels * fallback = select_fallback(fallback_mask); if (fallback && fallback != primary && @@ -1077,13 +1190,14 @@ class tensor_traits : public ggml::cpu::tensor_traits { const int ith_total = params->ith; int sme_slot = -1; + int non_sme_slot = -1; for (int i = 0; i < runtime_count; ++i) { if (is_sme_family(runtime[i].kernels->required_cpu)) { sme_slot = i; break; } } - int non_sme_slot = -1; + for (int i = 0; i < runtime_count; ++i) { if (!is_sme_family(runtime[i].kernels->required_cpu)) { non_sme_slot = i; diff --git a/ggml/src/ggml-feats.h b/ggml/src/ggml-feats.h new file mode 100644 index 000000000..d4a8c83a7 --- /dev/null +++ b/ggml/src/ggml-feats.h @@ -0,0 +1,166 @@ +#pragma once + +#if defined(__aarch64__) || defined(_M_ARM64) + +#if defined(__linux__) +#include +#include + +#if !defined(HWCAP2_SVE2) +#define HWCAP2_SVE2 (1ULL << 1) +#endif + +#if !defined(HWCAP_FPHP) +#define HWCAP_FPHP (1 << 9) +#endif + +#if !defined(HWCAP_ASIMDHP) +#define HWCAP_ASIMDHP (1 << 10) +#endif + +#if !defined(HWCAP2_I8MM) +#define HWCAP2_I8MM (1ULL << 13) +#endif + +#if !defined(HWCAP_ASIMDDP) +#define HWCAP_ASIMDDP (1 << 20) +#endif + +#if !defined(HWCAP_SVE) +#define HWCAP_SVE (1 << 22) +#endif + +#if !defined(HWCAP2_SME) +#define HWCAP2_SME (1ULL << 23) +#endif + +#if !defined(HWCAP2_SME2) +#define HWCAP2_SME2 (1ULL << 37) +#endif + +#if !defined(PR_SVE_GET_VL) +#define PR_SVE_GET_VL 51 +#endif + +#if !defined(PR_SVE_VL_LEN_MASK) +#define PR_SVE_VL_LEN_MASK 0xffff +#endif + +#elif defined(__APPLE__) +#include +#elif defined(_WIN32) +#include + +#if !defined(PF_ARM_V82_DP_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_V82_DP_INSTRUCTIONS_AVAILABLE 43 +#endif + +#if !defined(PF_ARM_SVE_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_SVE_INSTRUCTIONS_AVAILABLE 46 +#endif + +#if !defined(PF_ARM_SVE2_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_SVE2_INSTRUCTIONS_AVAILABLE 47 +#endif + +#if !defined(PF_ARM_V82_I8MM_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_V82_I8MM_INSTRUCTIONS_AVAILABLE 66 +#endif + +#if !defined(PF_ARM_V82_FP16_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_V82_FP16_INSTRUCTIONS_AVAILABLE 67 +#endif + +#if !defined(PF_ARM_SME_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_SME_INSTRUCTIONS_AVAILABLE 70 +#endif + +#if !defined(PF_ARM_SME2_INSTRUCTIONS_AVAILABLE) +#define PF_ARM_SME2_INSTRUCTIONS_AVAILABLE 71 +#endif + +#endif + +typedef struct ggml_feats_arch64_runtime { + bool has_dotprod; + bool has_fp16; + bool has_sve; + bool has_sve2; + bool has_i8mm; + bool has_sme; + bool has_sme2; + int sve_cnt; +} ggml_feats_arch64_runtime_t; + +static inline ggml_feats_arch64_runtime_t ggml_get_aarch64_runtime_features(void) { + ggml_feats_arch64_runtime_t runtime_feat = {}; + +#if defined(__linux__) + const unsigned long hwcap = getauxval(AT_HWCAP); + const unsigned long hwcap2 = getauxval(AT_HWCAP2); + + runtime_feat.has_dotprod = !!(hwcap & HWCAP_ASIMDDP); + runtime_feat.has_fp16 = !!(hwcap & HWCAP_FPHP) && !!(hwcap & HWCAP_ASIMDHP);; + runtime_feat.has_sve = !!(hwcap & HWCAP_SVE); + runtime_feat.has_sve2 = !!(hwcap2 & HWCAP2_SVE2); + runtime_feat.has_i8mm = !!(hwcap2 & HWCAP2_I8MM); + runtime_feat.has_sme = !!(hwcap2 & HWCAP2_SME); + runtime_feat.has_sme2 = !!(hwcap2 & HWCAP2_SME2); + + if (runtime_feat.has_sve) { + const int vl = prctl(PR_SVE_GET_VL); + if (vl >= 0) { + runtime_feat.sve_cnt = vl & PR_SVE_VL_LEN_MASK; + } + } +#elif defined(__APPLE__) + int oldp = 0; + size_t size = sizeof(oldp); + + if (sysctlbyname("hw.optional.arm.FEAT_DotProd", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_dotprod = static_cast(oldp); + } + + if (sysctlbyname("hw.optional.arm.FEAT_FP16", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_fp16 = static_cast(oldp); + } + + if (sysctlbyname("hw.optional.arm.FEAT_SVE", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_sve = static_cast(oldp); + } + + if (sysctlbyname("hw.optional.arm.FEAT_SVE2", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_sve2 = static_cast(oldp); + } + + if (sysctlbyname("hw.optional.arm.FEAT_I8MM", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_i8mm = static_cast(oldp); + } + + if (sysctlbyname("hw.optional.arm.FEAT_SME", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_sme = static_cast(oldp); + } + + if (sysctlbyname("hw.optional.arm.FEAT_SME2", &oldp, &size, nullptr, 0) == 0) { + runtime_feat.has_sme2 = static_cast(oldp); + } + + // Apple does not support userspace non-streaming SVE; keep SVE vector length unknown. + runtime_feat.sve_cnt = 0; +#elif defined (_WIN32) + runtime_feat.has_dotprod = IsProcessorFeaturePresent(PF_ARM_V82_DP_INSTRUCTIONS_AVAILABLE) != 0; + runtime_feat.has_fp16 = IsProcessorFeaturePresent(PF_ARM_V82_FP16_INSTRUCTIONS_AVAILABLE) != 0; + runtime_feat.has_sve = IsProcessorFeaturePresent(PF_ARM_SVE_INSTRUCTIONS_AVAILABLE) != 0; + runtime_feat.has_sve2 = IsProcessorFeaturePresent(PF_ARM_SVE2_INSTRUCTIONS_AVAILABLE) != 0; + runtime_feat.has_i8mm = IsProcessorFeaturePresent(PF_ARM_V82_I8MM_INSTRUCTIONS_AVAILABLE) != 0; + runtime_feat.has_sme = IsProcessorFeaturePresent(PF_ARM_SME_INSTRUCTIONS_AVAILABLE) != 0; + runtime_feat.has_sme2 = IsProcessorFeaturePresent(PF_ARM_SME2_INSTRUCTIONS_AVAILABLE) != 0; + + // Windows exposes SVE feature presence, but not the runtime SVE vector length here. + runtime_feat.sve_cnt = 0; +#endif + + return runtime_feat; +} + +#endif // defined(__aarch64__) || defined(_M_ARM64) From d8a8beac22d450ebadf175a8ce7b6bf49b66db14 Mon Sep 17 00:00:00 2001 From: HarrisonSec Date: Wed, 12 Aug 2026 05:07:48 -0700 Subject: [PATCH 06/41] gguf : harden loader against malformed tensor dims and metadata types (#25596) * gguf : harden loader against malformed tensor dims and metadata types * gguf: address review on malformed-metadata hardening - report the expected vs. actual type when general.alignment is not u32 - use ggml_nelements() > 0 for the zero-element guard and keep the representability checks visually aligned - add test-gguf cases for a wrong-typed alignment key and a zero-dim tensor (both used to crash: assert-abort and SIGFPE respectively) Ran tests/test-gguf: 164/164 pass. Used an AI assistant to help draft these edits; reviewed and verified by me. * cont : less comments Co-authored-by: Georgi Gerganov --------- Co-authored-by: Georgi Gerganov --- ggml/src/gguf.cpp | 15 ++++++++++++--- tests/test-gguf.cpp | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/ggml/src/gguf.cpp b/ggml/src/gguf.cpp index 9f9e4fe5d..6c7b58178 100644 --- a/ggml/src/gguf.cpp +++ b/ggml/src/gguf.cpp @@ -611,6 +611,13 @@ static struct gguf_context * gguf_init_from_reader(const struct gguf_reader & gr GGML_ASSERT(int64_t(ctx->kv.size()) == n_kv); const int alignment_idx = gguf_find_key(ctx, GGUF_KEY_GENERAL_ALIGNMENT); + if (alignment_idx != -1 && gguf_get_kv_type(ctx, alignment_idx) != GGUF_TYPE_UINT32) { + GGML_LOG_ERROR("%s: key '%s' must be of type %s but is %s\n", + __func__, GGUF_KEY_GENERAL_ALIGNMENT, gguf_type_name(GGUF_TYPE_UINT32), + gguf_type_name(gguf_get_kv_type(ctx, alignment_idx))); + gguf_free(ctx); + return nullptr; + } ctx->alignment = alignment_idx == -1 ? GGUF_DEFAULT_ALIGNMENT : gguf_get_val_u32(ctx, alignment_idx); if (ctx->alignment == 0 || (ctx->alignment & (ctx->alignment - 1)) != 0) { @@ -682,9 +689,11 @@ static struct gguf_context * gguf_init_from_reader(const struct gguf_reader & gr } // check that the total number of elements is representable - if (ok && ((INT64_MAX/info.t.ne[1] <= info.t.ne[0]) || - (INT64_MAX/info.t.ne[2] <= info.t.ne[0]*info.t.ne[1]) || - (INT64_MAX/info.t.ne[3] <= info.t.ne[0]*info.t.ne[1]*info.t.ne[2]))) { + // (a zero-element tensor is trivially representable; the guard also avoids a division by zero below) + if (ok && ggml_nelements(&info.t) > 0 && + ((INT64_MAX/info.t.ne[1] <= info.t.ne[0]) || + (INT64_MAX/info.t.ne[2] <= info.t.ne[0]*info.t.ne[1]) || + (INT64_MAX/info.t.ne[3] <= info.t.ne[0]*info.t.ne[1]*info.t.ne[2]))) { GGML_LOG_ERROR("%s: total number of elements in tensor '%s' with shape " "(%" PRIi64 ", %" PRIi64 ", %" PRIi64 ", %" PRIi64 ") is >= %" PRIi64 "\n", diff --git a/tests/test-gguf.cpp b/tests/test-gguf.cpp index 2875dec80..fc636186f 100644 --- a/tests/test-gguf.cpp +++ b/tests/test-gguf.cpp @@ -31,11 +31,13 @@ enum handcrafted_file_type { // HANDCRAFTED_KV_BAD_VALUE_SIZE = 30 + offset_has_kv, // removed because it can result in allocations > 1 TB (default sanitizer limit) HANDCRAFTED_KV_DUPLICATE_KEY = 40 + offset_has_kv, HANDCRAFTED_KV_BAD_ALIGN = 50 + offset_has_kv, + HANDCRAFTED_KV_WRONG_TYPE_ALIGN = 55 + offset_has_kv, HANDCRAFTED_KV_SUCCESS = 800 + offset_has_kv, HANDCRAFTED_TENSORS_BAD_NAME_SIZE = 10 + offset_has_tensors, HANDCRAFTED_TENSORS_BAD_N_DIMS = 20 + offset_has_tensors, HANDCRAFTED_TENSORS_BAD_SHAPE = 30 + offset_has_tensors, + HANDCRAFTED_TENSORS_ZERO_DIM = 35 + offset_has_tensors, HANDCRAFTED_TENSORS_NE_TOO_BIG = 40 + offset_has_tensors, HANDCRAFTED_TENSORS_NBYTES_TOO_BIG = 45 + offset_has_tensors, HANDCRAFTED_TENSORS_BAD_TYPE = 50 + offset_has_tensors, @@ -69,11 +71,13 @@ static std::string handcrafted_file_type_name(const enum handcrafted_file_type h case HANDCRAFTED_KV_BAD_TYPE: return "KV_BAD_TYPE"; case HANDCRAFTED_KV_DUPLICATE_KEY: return "KV_DUPLICATE_KEY"; case HANDCRAFTED_KV_BAD_ALIGN: return "KV_BAD_ALIGN"; + case HANDCRAFTED_KV_WRONG_TYPE_ALIGN: return "KV_WRONG_TYPE_ALIGN"; case HANDCRAFTED_KV_SUCCESS: return "KV_RANDOM_KV"; case HANDCRAFTED_TENSORS_BAD_NAME_SIZE: return "TENSORS_BAD_NAME_SIZE"; case HANDCRAFTED_TENSORS_BAD_N_DIMS: return "TENSORS_BAD_N_DIMS"; case HANDCRAFTED_TENSORS_BAD_SHAPE: return "TENSORS_BAD_SHAPE"; + case HANDCRAFTED_TENSORS_ZERO_DIM: return "TENSORS_ZERO_DIM"; case HANDCRAFTED_TENSORS_NE_TOO_BIG: return "TENSORS_NE_TOO_BIG"; case HANDCRAFTED_TENSORS_NBYTES_TOO_BIG: return "TENSORS_NBYTES_TOO_BIG"; case HANDCRAFTED_TENSORS_BAD_TYPE: return "TENSORS_BAD_TYPE"; @@ -95,6 +99,9 @@ static std::string handcrafted_file_type_name(const enum handcrafted_file_type h } static bool expect_context_not_null(const enum handcrafted_file_type hft) { + if (hft == HANDCRAFTED_TENSORS_ZERO_DIM) { + return true; + } if (hft < offset_has_kv) { return hft >= HANDCRAFTED_HEADER_EMPTY; } @@ -257,9 +264,9 @@ static FILE * get_handcrafted_file(const unsigned int seed, const enum handcraft } { uint64_t n_kv = kv_types.size(); - if (hft == HANDCRAFTED_KV_BAD_ALIGN || - hft == HANDCRAFTED_TENSORS_BAD_ALIGN || hft == HANDCRAFTED_TENSORS_CUSTOM_ALIGN || - hft == HANDCRAFTED_DATA_BAD_ALIGN || hft == HANDCRAFTED_DATA_CUSTOM_ALIGN) { + if (hft == HANDCRAFTED_KV_BAD_ALIGN || hft == HANDCRAFTED_KV_WRONG_TYPE_ALIGN || + hft == HANDCRAFTED_TENSORS_BAD_ALIGN || hft == HANDCRAFTED_TENSORS_CUSTOM_ALIGN || + hft == HANDCRAFTED_DATA_BAD_ALIGN || hft == HANDCRAFTED_DATA_CUSTOM_ALIGN) { n_kv += 1; } else if (hft == HANDCRAFTED_HEADER_BAD_N_KV) { @@ -344,15 +351,17 @@ static FILE * get_handcrafted_file(const unsigned int seed, const enum handcraft helper_write(file, data, hft == HANDCRAFTED_KV_BAD_TYPE ? 1 : gguf_type_size(type)); } - if (hft == HANDCRAFTED_KV_BAD_ALIGN || - hft == HANDCRAFTED_TENSORS_BAD_ALIGN || hft == HANDCRAFTED_TENSORS_CUSTOM_ALIGN || - hft == HANDCRAFTED_DATA_BAD_ALIGN || hft == HANDCRAFTED_DATA_CUSTOM_ALIGN) { + if (hft == HANDCRAFTED_KV_BAD_ALIGN || hft == HANDCRAFTED_KV_WRONG_TYPE_ALIGN || + hft == HANDCRAFTED_TENSORS_BAD_ALIGN || hft == HANDCRAFTED_TENSORS_CUSTOM_ALIGN || + hft == HANDCRAFTED_DATA_BAD_ALIGN || hft == HANDCRAFTED_DATA_CUSTOM_ALIGN) { const uint64_t n = strlen(GGUF_KEY_GENERAL_ALIGNMENT); helper_write(file, n); helper_write(file, GGUF_KEY_GENERAL_ALIGNMENT, n); - const int32_t type = gguf_type(GGUF_TYPE_UINT32); + // HANDCRAFTED_KV_WRONG_TYPE_ALIGN declares general.alignment with a non-UINT32 type, + // which the loader must reject cleanly instead of aborting on an assertion + const int32_t type = hft == HANDCRAFTED_KV_WRONG_TYPE_ALIGN ? int32_t(GGUF_TYPE_INT32) : int32_t(GGUF_TYPE_UINT32); helper_write(file, type); alignment = expect_context_not_null(hft) ? 1 : 13; @@ -403,6 +412,9 @@ static FILE * get_handcrafted_file(const unsigned int seed, const enum handcraft break; } } + if (hft == HANDCRAFTED_TENSORS_ZERO_DIM) { + n_dims = 2; + } if (hft == HANDCRAFTED_TENSORS_BAD_N_DIMS) { const uint32_t n_dims_bad = GGML_MAX_DIMS + 1; helper_write(file, n_dims_bad); @@ -415,6 +427,9 @@ static FILE * get_handcrafted_file(const unsigned int seed, const enum handcraft for (uint32_t j = 0; j < n_dims; ++j) { helper_write(file, bad_dim); } + } else if (hft == HANDCRAFTED_TENSORS_ZERO_DIM) { + const int64_t zero_shape[2] = { shape[0], 0 }; + helper_write(file, zero_shape, 2*sizeof(int64_t)); } else if (hft == HANDCRAFTED_TENSORS_NE_TOO_BIG){ const int64_t big_dim = 4*int64_t(INT32_MAX); for (uint32_t j = 0; j < n_dims; ++j) { @@ -446,6 +461,9 @@ static FILE * get_handcrafted_file(const unsigned int seed, const enum handcraft for (uint32_t i = 1; i < n_dims; ++i) { ne *= shape[i]; } + if (hft == HANDCRAFTED_TENSORS_ZERO_DIM) { + ne = 0; + } offset += GGML_PAD(ggml_row_size(type, ne), (uint64_t) alignment); } @@ -747,11 +765,13 @@ static std::pair test_handcrafted_file(const unsigned int seed) { HANDCRAFTED_KV_BAD_TYPE, HANDCRAFTED_KV_DUPLICATE_KEY, HANDCRAFTED_KV_BAD_ALIGN, + HANDCRAFTED_KV_WRONG_TYPE_ALIGN, HANDCRAFTED_KV_SUCCESS, HANDCRAFTED_TENSORS_BAD_NAME_SIZE, HANDCRAFTED_TENSORS_BAD_N_DIMS, HANDCRAFTED_TENSORS_BAD_SHAPE, + HANDCRAFTED_TENSORS_ZERO_DIM, HANDCRAFTED_TENSORS_NE_TOO_BIG, HANDCRAFTED_TENSORS_NBYTES_TOO_BIG, HANDCRAFTED_TENSORS_BAD_TYPE, @@ -840,7 +860,9 @@ static std::pair test_handcrafted_file(const unsigned int seed) { ntest++; } - if (expect_context_not_null(hft) && hft >= offset_has_tensors) { + // HANDCRAFTED_TENSORS_ZERO_DIM deliberately mangles the tensor shapes to 0 elements, + // so only assert that it loads without crashing; skip the exact-geometry comparison. + if (expect_context_not_null(hft) && hft >= offset_has_tensors && hft != HANDCRAFTED_TENSORS_ZERO_DIM) { printf("%s: - check_tensors: ", __func__); if (handcrafted_check_tensors(gguf_ctx, seed)) { printf("\033[1;32mOK\033[0m\n"); From 680a9ae63d60d35c21a0dcd7d3fabdb9c6bfc963 Mon Sep 17 00:00:00 2001 From: Daniel Bevenius Date: Wed, 12 Aug 2026 14:15:03 +0200 Subject: [PATCH 07/41] cmake : introduce semantic versioning (#26839) * cmake : introduce semantic versioning (wip) This commit introduces semantic versioning to llama.cpp. * squash! cmake : introduce semantic versioning (wip) * cmake : update test-cmake README notes [no ci] * include libmtmd in output so show its semversioned * ci : add make-release workflow * ci : fix build number check in build-cmake-pkg.yml * examples : remove trailing whitespace * ci : abort if upstream ggml version does not exist * ci : extract step contents into scripts * ci : add GGML_NATIVE=OFF to ubuntu job * examples : remove CI build information from test-cmake [no ci] This commit removes the nightly/release information that I added previously to keep this focused only on using building and installing llama.cpp with cmake and being able to quickly verify changes or troubleshoot issues. * ci : merge scripts into single script * remove -dev-build_number support This commit removes the incremental build number (versioning) support that I added. This was incorrect and we should only use the semver for the version. Releases will be tag a nightly build and package maintainers/managers that build from source can use the tag and it is therefor important that the correct version is reported. So a nightly-build will report the semver without the build number. The build number and commit as availble via cmake and test-cmake has been updated to include an example of using them: ```console $ ./build.sh [test-cmake] version: 0.1.0, build: 10360 (08c69e381) ... ``` Refs: https://github.com/ggml-org/llama.cpp/pull/26839#discussion_r3755836969 * docs: add initial release.md documentation * cmake : clean-up and add LLAMA_BUILD_IS_DEV option * ci : remove version input from make-release job * ci : add LLAMA_BUILD_IS_DEV=OFF to build-cmake-pkg.yml Refs: https://github.com/danbev/llama.cpp/actions/runs/31576801921/job/94050639145 * docs : update release notes with LLAMA_BUILD_IS_DEV info [no ci] * ci : add TODO to winget workflow [no ci] --------- Co-authored-by: Georgi Gerganov --- .github/workflows/build-cmake-pkg.yml | 8 +- .github/workflows/build-cpu.yml | 3 +- .github/workflows/make-release.yml | 46 ++++++++++ .github/workflows/winget.yml | 2 + CMakeLists.txt | 30 +++++-- README.md | 1 + app/llama.cpp | 8 +- cmake/llama-config.cmake.in | 2 +- cmake/llama.pc.in | 2 +- common/CMakeLists.txt | 4 +- common/arg.cpp | 3 +- common/build-info.cpp.in | 6 +- common/build-info.h | 2 +- docs/release.md | 49 +++++++++++ examples/test-cmake/.gitignore | 3 + examples/test-cmake/CMakeLists.txt | 13 +++ examples/test-cmake/README.md | 36 ++++++++ examples/test-cmake/build-install.sh | 19 +++++ examples/test-cmake/build.sh | 7 ++ examples/test-cmake/test-cmake.cpp | 12 +++ include/llama.h | 2 + scripts/make-release-checks.sh | 83 +++++++++++++++++++ src/CMakeLists.txt | 9 +- src/llama.cpp | 4 + tests/test-quantize-stats.cpp | 2 +- tools/cvector-generator/cvector-generator.cpp | 2 +- tools/gguf-split/gguf-split.cpp | 2 +- tools/mtmd/CMakeLists.txt | 4 +- tools/quantize/quantize.cpp | 2 +- 29 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/make-release.yml create mode 100644 docs/release.md create mode 100644 examples/test-cmake/.gitignore create mode 100644 examples/test-cmake/CMakeLists.txt create mode 100644 examples/test-cmake/README.md create mode 100755 examples/test-cmake/build-install.sh create mode 100755 examples/test-cmake/build.sh create mode 100644 examples/test-cmake/test-cmake.cpp create mode 100755 scripts/make-release-checks.sh diff --git a/.github/workflows/build-cmake-pkg.yml b/.github/workflows/build-cmake-pkg.yml index 5becff09c..a958870de 100644 --- a/.github/workflows/build-cmake-pkg.yml +++ b/.github/workflows/build-cmake-pkg.yml @@ -21,6 +21,7 @@ jobs: -DLLAMA_BUILD_TOOLS=OFF \ -DLLAMA_BUILD_EXAMPLES=OFF \ -DLLAMA_BUILD_APP=OFF \ + -DLLAMA_BUILD_IS_DEV=OFF \ -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release cmake --install build --prefix "$PREFIX" --config Release @@ -29,7 +30,12 @@ jobs: tclsh <<'EOF' set build(commit) [string trim [exec git rev-parse --short HEAD]] set build(number) [string trim [exec git rev-list --count HEAD]] - set build(version) "0.0.$build(number)" + + set cmakelists [read [open "CMakeLists.txt" r]] + regexp {set\(LLAMA_VERSION_MAJOR\s+(\d+)\)} $cmakelists -> major + regexp {set\(LLAMA_VERSION_MINOR\s+(\d+)\)} $cmakelists -> minor + regexp {set\(LLAMA_VERSION_PATCH\s+(\d+)\)} $cmakelists -> patch + set build(version) "$major.$minor.$patch" set llamaconfig [read [open "$env(LLAMA_CONFIG)" r]] set checks [list "set\\(LLAMA_VERSION \\s+$build(version)\\)" \ diff --git a/.github/workflows/build-cpu.yml b/.github/workflows/build-cpu.yml index 30b07ce78..57622e1d4 100644 --- a/.github/workflows/build-cpu.yml +++ b/.github/workflows/build-cpu.yml @@ -95,7 +95,8 @@ jobs: run: | cmake -B build \ -DLLAMA_FATAL_WARNINGS=ON \ - -DGGML_RPC=ON + -DGGML_RPC=ON \ + -DGGML_NATIVE=OFF time cmake --build build --config Release -j $(nproc) - name: Test diff --git a/.github/workflows/make-release.yml b/.github/workflows/make-release.yml new file mode 100644 index 000000000..fed9c877c --- /dev/null +++ b/.github/workflows/make-release.yml @@ -0,0 +1,46 @@ +name: Make Release + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run - validate without creating the tag' + required: true + type: boolean + default: true + +env: + GH_TOKEN: ${{ github.token }} + +permissions: + contents: write + +jobs: + make-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run release checks + id: checks + run: bash scripts/make-release-checks.sh ${{ github.event.inputs.dry_run == 'true' && '--dry-run' || '' }} + env: + GITHUB_REPOSITORY: ${{ github.repository }} + + - name: Create release tag + if: ${{ github.event.inputs.dry_run == 'false' }} + run: | + VERSION="${{ steps.checks.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${VERSION}" -m "Release ${VERSION}" + git push origin "${VERSION}" + echo "Created and pushed tag ${VERSION}" + + - name: Dry run summary + if: ${{ github.event.inputs.dry_run == 'true' }} + run: | + echo "Dry run complete - all checks passed." + echo "Would have created tag: ${{ steps.checks.outputs.version }}" diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 69e24f940..c0a814f3a 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -19,6 +19,8 @@ jobs: run: | cargo binstall komac@2.16.0 -y + # TODO: This should later be updated to publish releases instead of + # development release builds. - name: Find latest release id: find_latest_release uses: actions/github-script@v8 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3df1d82db..b2092d12d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,26 @@ cmake_minimum_required(VERSION 3.14...3.28) # for add_link_options and implicit project("llama.cpp" C CXX) include(CheckIncludeFileCXX) +### llama.cpp version +set(LLAMA_VERSION_MAJOR 0) +set(LLAMA_VERSION_MINOR 1) +set(LLAMA_VERSION_PATCH 0) +set(LLAMA_VERSION_BASE "${LLAMA_VERSION_MAJOR}.${LLAMA_VERSION_MINOR}.${LLAMA_VERSION_PATCH}") + +# whether this is a development/nightly build +# set this to OFF when making a release from a release tag (vX.Y.Z) +# ref: https://github.com/ggml-org/ggml/discussions/1579 +option(LLAMA_BUILD_IS_DEV "llama: dev build" ON) + +if (LLAMA_BUILD_IS_DEV) + set(LLAMA_VERSION "${LLAMA_VERSION_BASE}-dev") +else() + # TODO: check that the current commit is tagged correctly according to the version specified above + set(LLAMA_VERSION "${LLAMA_VERSION_BASE}") +endif() + +message(STATUS "llama.cpp version: ${LLAMA_VERSION}") + #set(CMAKE_WARN_DEPRECATED YES) set(CMAKE_WARN_UNUSED_CLI YES) @@ -24,9 +44,6 @@ if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) set(LLAMA_STANDALONE ON) include(git-vars) - - # configure project version - # TODO else() set(LLAMA_STANDALONE OFF) endif() @@ -139,7 +156,6 @@ endif() if (NOT DEFINED LLAMA_BUILD_COMMIT) set(LLAMA_BUILD_COMMIT ${BUILD_COMMIT}) endif() -set(LLAMA_INSTALL_VERSION 0.0.${LLAMA_BUILD_NUMBER}) # override ggml options set(GGML_ALL_WARNINGS ${LLAMA_ALL_WARNINGS}) @@ -275,12 +291,12 @@ configure_package_config_file( LLAMA_BIN_INSTALL_DIR ) write_basic_package_version_file( - ${CMAKE_CURRENT_BINARY_DIR}/llama-version.cmake - VERSION ${LLAMA_INSTALL_VERSION} + ${CMAKE_CURRENT_BINARY_DIR}/llama-config-version.cmake + VERSION ${LLAMA_VERSION} COMPATIBILITY SameMajorVersion) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/llama-config.cmake - ${CMAKE_CURRENT_BINARY_DIR}/llama-version.cmake + ${CMAKE_CURRENT_BINARY_DIR}/llama-config-version.cmake DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/llama) configure_file(cmake/llama.pc.in diff --git a/README.md b/README.md index fc7633a7c..1b341e7fb 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ The `llama.cpp` project is build on top of the [ggml](https://github.com/ggml-or - [XCFramework](docs/xcframework.md) - [Completions](docs/completions.md) - [Models](docs/models.md) +- [Release process](docs/release.md) ## Contributing diff --git a/app/llama.cpp b/app/llama.cpp index 2cf1aa876..3b7e46f20 100644 --- a/app/llama.cpp +++ b/app/llama.cpp @@ -1,5 +1,7 @@ #include "build-info.h" +#include "llama.h" + #include #include #include @@ -77,12 +79,12 @@ static const command cmds[] = { #undef UPDATE_HIDDEN -static int version(int argc, char ** argv) { - printf("%s\n", llama_build_info()); +static int version(int /*argc*/, char ** /*argv*/) { + llama_print_build_info(llama_version()); return 0; } -static int licenses(int argc, char ** argv) { +static int licenses(int /*argc*/, char ** /*argv*/) { for (int i = 0; LICENSES[i]; ++i) { printf("%s\n", LICENSES[i]); } diff --git a/cmake/llama-config.cmake.in b/cmake/llama-config.cmake.in index b4defc76f..6db73577a 100644 --- a/cmake/llama-config.cmake.in +++ b/cmake/llama-config.cmake.in @@ -1,4 +1,4 @@ -set(LLAMA_VERSION @LLAMA_INSTALL_VERSION@) +set(LLAMA_VERSION @LLAMA_VERSION@) set(LLAMA_BUILD_COMMIT @LLAMA_BUILD_COMMIT@) set(LLAMA_BUILD_NUMBER @LLAMA_BUILD_NUMBER@) set(LLAMA_SHARED_LIB @BUILD_SHARED_LIBS@) diff --git a/cmake/llama.pc.in b/cmake/llama.pc.in index 6fb58b5f6..31b043c0e 100644 --- a/cmake/llama.pc.in +++ b/cmake/llama.pc.in @@ -5,6 +5,6 @@ includedir=@CMAKE_INSTALL_FULL_INCLUDEDIR@ Name: llama Description: Port of Facebook's LLaMA model in C/C++ -Version: @LLAMA_INSTALL_VERSION@ +Version: @LLAMA_VERSION@ Libs: -L${libdir} -lggml -lggml-base -lllama Cflags: -I${includedir} diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 799d22751..d6cfc9a00 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -121,8 +121,8 @@ add_library(${TARGET} ) set_target_properties(${TARGET} PROPERTIES - VERSION ${LLAMA_INSTALL_VERSION} - SOVERSION 0 + VERSION ${LLAMA_VERSION_BASE} + SOVERSION ${LLAMA_VERSION_MAJOR} MACHO_CURRENT_VERSION 0 # keep macOS linker from seeing oversized version number ) diff --git a/common/arg.cpp b/common/arg.cpp index cb314eee7..b2fddfe5f 100644 --- a/common/arg.cpp +++ b/common/arg.cpp @@ -1390,8 +1390,7 @@ common_params_context common_params_parser_init(common_params & params, llama_ex {"--version"}, "show version and build info", [](common_params &) { - fprintf(stderr, "version: %d (%s)\n", llama_build_number(), llama_commit()); - fprintf(stderr, "built with %s for %s\n", llama_compiler(), llama_build_target()); + llama_print_build_info(llama_version()); exit(0); } )); diff --git a/common/build-info.cpp.in b/common/build-info.cpp.in index f888fd079..4ec339708 100644 --- a/common/build-info.cpp.in +++ b/common/build-info.cpp.in @@ -29,7 +29,7 @@ const char * llama_build_info(void) { return s.c_str(); } -void llama_print_build_info(void) { - fprintf(stderr, "%s: build = %d (%s)\n", __func__, llama_build_number(), llama_commit()); - fprintf(stderr, "%s: built with %s for %s\n", __func__, llama_compiler(), llama_build_target()); +void llama_print_build_info(const char * llama_version) { + fprintf(stderr, "version: %s (build %d, commit %s)\n", llama_version, llama_build_number(), llama_commit()); + fprintf(stderr, "built with %s for %s\n", llama_compiler(), llama_build_target()); } diff --git a/common/build-info.h b/common/build-info.h index 382cfa785..1e564591a 100644 --- a/common/build-info.h +++ b/common/build-info.h @@ -8,4 +8,4 @@ const char * llama_compiler(void); const char * llama_build_target(void); const char * llama_build_info(void); -void llama_print_build_info(void); +void llama_print_build_info(const char *); diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 000000000..4335ef9d4 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,49 @@ +# Release process + +llama.cpp uses [semantic versioning](https://semver.org) (`MAJOR.MINOR.PATCH`). + +## Version bump guidelines + +| Change type | Version component | +|---|---| +| Breaking change to the public C API (`include/llama.h`) | `MAJOR` | +| Backward-compatible features, model support, or API addition | `MINOR` | +| Bug fix with no API change | `PATCH` | + +The version is set in the three variables at the top of the root `CMakeLists.txt`: + +```cmake +set(LLAMA_VERSION_MAJOR 0) +set(LLAMA_VERSION_MINOR 1) +set(LLAMA_VERSION_PATCH 0) +``` + +_A version bump should be included in the PR that introduces the change, or in a +dedicated bump commit merged before the release is cut._ + +_TODO: add PR labels (`semver: patch`, `semver: minor`, `semver: major`) to help +identify which PRs require a version bump before cutting a release._ + +## Making a release + +Releases are created by running the [make-release](.github/workflows/make-release.yml) +which is a manual workflow. + +The workflow creates an annotated git tag (e.g. `v0.1.0`) and pushes it to the +remote. No GitHub Release object is created, the tag is the release artifact. + +## Building a release + +By default, `LLAMA_BUILD_IS_DEV=ON` which appends a `-dev` suffix to `LLAMA_VERSION`, +marking the build as a nightly/development build. Distributors building from a +release tag must pass `-DLLAMA_BUILD_IS_DEV=OFF` to produce a clean version string +(e.g. `0.1.0` instead of `0.1.0-dev`). + +## How releases reach users +Currently releases are not published to github releases, only nightly/development +builds are available there. The way users can access releases are using the following +channels: + +- **llama-install.sh** — downloads pre-built binaries built from the release tag. +- **Package managers** — consume the git tag directly. +- **Build from source** — users clone the repo and check out the tag. diff --git a/examples/test-cmake/.gitignore b/examples/test-cmake/.gitignore new file mode 100644 index 000000000..0ddff317a --- /dev/null +++ b/examples/test-cmake/.gitignore @@ -0,0 +1,3 @@ +llama-build-install +install +build diff --git a/examples/test-cmake/CMakeLists.txt b/examples/test-cmake/CMakeLists.txt new file mode 100644 index 000000000..ed5cb1f3c --- /dev/null +++ b/examples/test-cmake/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.14) +project(llama-simple) + +set(CMAKE_CXX_STANDARD 17) + +find_package(llama 0.1.0 REQUIRED) + +add_executable(test-cmake test-cmake.cpp) +target_link_libraries(test-cmake PRIVATE llama) +target_compile_definitions(test-cmake PRIVATE + LLAMA_BUILD_NUMBER=${LLAMA_BUILD_NUMBER} + LLAMA_BUILD_COMMIT="${LLAMA_BUILD_COMMIT}" +) diff --git a/examples/test-cmake/README.md b/examples/test-cmake/README.md new file mode 100644 index 000000000..21e5eb960 --- /dev/null +++ b/examples/test-cmake/README.md @@ -0,0 +1,36 @@ +## cmake-test + +This is just for manually testing/developing of a llama.cpp installation to +enable troubleshooting issues and exploration. The idea is that this can be used +after making changes to llama.cpp installation cmake configuration and then +verify it locally. + +### Usage +The following will configure, build, and install llama.cpp + +Configuring/build/install: +```console +./build-install.sh +``` +The above command will create a directory named `install` in the current directory +which will have the follwing files in its lib directory: +```console +(venv) $ ls install/lib/ +cmake libggml.so libllama-common.so.0 libllama.so.0.1.0 llama.cpp +libggml-base.so libggml.so.0 libllama-common.so.0.1.0 libmtmd.so pkgconfig +libggml-base.so.0 libggml.so.0.19.0 libllama.so libmtmd.so.0 +libggml-base.so.0.19.0 libllama-common.so libllama.so.0 libmtmd.so.0.1.0 +``` + +Build/run this project using the installation created above: +```console +(venv) $ ./build.sh +-- Configuring done (0.0s) +-- Generating done (0.0s) +-- Build files have been written to: /home/danbev/work/ai/llama.cpp/examples/test-cmake/build +[100%] Built target test-cmake +[test-cmake] Using llama.cpp version 0.1.0-dev-b10335 +[test-cmake] Initializing backend... +load_backend: loaded CPU backend from /home/danbev/work/ai/llama.cpp/examples/test-cmake/install/lib/llama.cpp/libggml-cpu-alderlake.so +[test-cmake] Backend initialized. +``` diff --git a/examples/test-cmake/build-install.sh b/examples/test-cmake/build-install.sh new file mode 100755 index 000000000..77a6713d6 --- /dev/null +++ b/examples/test-cmake/build-install.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +rm -rf llama-build-install install + +cmake --fresh -S ../../. -B llama-build-install -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=ON \ + -DGGML_BACKEND_DL=ON \ + -DGGML_CPU_ALL_VARIANTS=ON \ + -DLLAMA_TESTS_INSTALL=OFF \ + -DCMAKE_INSTALL_PREFIX="${PWD}/install" \ + -DGGML_BACKEND_DIR="${PWD}/install/lib/llama.cpp" \ + -DGGML_LIB_INSTALL_DIR="${PWD}/install/lib/llama.cpp" \ + -DLLAMA_LIB_INSTALL_DIR="${PWD}/install/lib/llama.cpp" \ + -DLLAMA_TOOLS_INSTALL=OFF + +cmake --build llama-build-install --parallel 12 +cmake --install llama-build-install diff --git a/examples/test-cmake/build.sh b/examples/test-cmake/build.sh new file mode 100755 index 000000000..a212732b8 --- /dev/null +++ b/examples/test-cmake/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +cmake -S . -B build -DCMAKE_PREFIX_PATH="${PWD}/install" +cmake --build build +LD_LIBRARY_PATH="${PWD}/install/lib/llama.cpp:${PWD}/install/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" ./build/test-cmake diff --git a/examples/test-cmake/test-cmake.cpp b/examples/test-cmake/test-cmake.cpp new file mode 100644 index 000000000..c5c4765b4 --- /dev/null +++ b/examples/test-cmake/test-cmake.cpp @@ -0,0 +1,12 @@ +#include "llama.h" +#include + +int main(void) { + printf("[test-cmake] version: %s, build: %d (%s)\n", + llama_version(), LLAMA_BUILD_NUMBER, LLAMA_BUILD_COMMIT); + printf("[test-cmake] Initializing backend...\n"); + llama_backend_init(); + printf("[test-cmake] Backend initialized.\n"); + llama_backend_free(); + return 0; +} diff --git a/include/llama.h b/include/llama.h index ef278c9c3..177fc10a9 100644 --- a/include/llama.h +++ b/include/llama.h @@ -457,6 +457,8 @@ extern "C" { // lora adapter struct llama_adapter_lora; + LLAMA_API const char * llama_version(void); + // Helpers for getting default parameters // TODO: update API to start accepting pointers to params structs (https://github.com/ggml-org/llama.cpp/discussions/9172) LLAMA_API struct llama_model_params llama_model_default_params(void); diff --git a/scripts/make-release-checks.sh b/scripts/make-release-checks.sh new file mode 100755 index 000000000..c8c632284 --- /dev/null +++ b/scripts/make-release-checks.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Run all pre-release checks and determine the release version. +# +# Usage: make-release-checks.sh [--dry-run] +# --dry-run: warn on failures instead of aborting +# +# Env (when running in GitHub Actions): GH_TOKEN, GITHUB_REPOSITORY, GITHUB_OUTPUT +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +DRY_RUN=false +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + *) echo "Unknown argument: $arg"; exit 1 ;; + esac +done + +MAJOR=$(grep "set(LLAMA_VERSION_MAJOR" "$REPO_ROOT/CMakeLists.txt" | grep -oP '\d+') +MINOR=$(grep "set(LLAMA_VERSION_MINOR" "$REPO_ROOT/CMakeLists.txt" | grep -oP '\d+') +PATCH=$(grep "set(LLAMA_VERSION_PATCH" "$REPO_ROOT/CMakeLists.txt" | grep -oP '\d+') +VERSION="v${MAJOR}.${MINOR}.${PATCH}" +echo "Determined version: ${VERSION}" +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" +fi + +echo "Checking that tag ${VERSION} does not already exist..." +if git ls-remote --tags origin "${VERSION}" | grep -q "${VERSION}"; then + echo "Error: tag ${VERSION} already exists on remote" + exit 1 +fi +echo "Tag ${VERSION} does not exist on remote - OK" + +SHA=$(git rev-parse HEAD) +echo "Checking release.yml status for commit ${SHA}..." +if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + echo "Warning: GITHUB_REPOSITORY not set - skipping CI check (local run)" +else + RUNS=$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/release.yml/runs" \ + --jq "[.workflow_runs[] | select(.head_sha == \"${SHA}\" and .conclusion == \"success\")] | length") + if [[ "$RUNS" -eq 0 ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + echo "Warning: no successful release.yml run found for HEAD (${SHA}) (dry run, continuing)." + else + echo "Error: no successful release.yml run found for HEAD (${SHA})" + echo "The nightly build must complete successfully before making a release." + exit 1 + fi + else + echo "Found successful release.yml run for HEAD." + fi +fi + +MAJOR=$(grep "set(GGML_VERSION_MAJOR" "$REPO_ROOT/ggml/CMakeLists.txt" | grep -oP '\d+') +MINOR=$(grep "set(GGML_VERSION_MINOR" "$REPO_ROOT/ggml/CMakeLists.txt" | grep -oP '\d+') +PATCH=$(grep "set(GGML_VERSION_PATCH" "$REPO_ROOT/ggml/CMakeLists.txt" | grep -oP '\d+') +GGML_VERSION="v${MAJOR}.${MINOR}.${PATCH}" +echo "Local ggml version: ${GGML_VERSION}" + +if ! git clone --depth 1 --branch "${GGML_VERSION}" https://github.com/ggml-org/ggml.git upstream-ggml 2>/dev/null; then + echo "Warning: tag ${GGML_VERSION} not found in upstream ggml - skipping comparison" +else + echo "Comparing local ggml/ src and include with upstream ${GGML_VERSION}..." + DIFF=$(diff -rq "$REPO_ROOT/ggml/src" upstream-ggml/src 2>&1 || true) + DIFF+=$(diff -rq "$REPO_ROOT/ggml/include" upstream-ggml/include 2>&1 || true) + DIFF+=$(diff "$REPO_ROOT/ggml/CMakeLists.txt" upstream-ggml/CMakeLists.txt 2>&1 || true) + rm -rf upstream-ggml + if [[ -n "$DIFF" ]]; then + echo "local ggml/ differs from upstream ${GGML_VERSION}:" + echo "$DIFF" + if [[ "$DRY_RUN" == "true" ]]; then + echo "Warning: would abort release due to ggml mismatch (dry run, continuing)." + else + echo "Error: ggml must match upstream before making a release." + exit 1 + fi + else + echo "local ggml/ matches upstream ${GGML_VERSION}" + fi +fi diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 24f05cc91..39ba3061f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -45,11 +45,16 @@ add_library(llama ) set_target_properties(llama PROPERTIES - VERSION ${LLAMA_INSTALL_VERSION} - SOVERSION 0 + VERSION ${LLAMA_VERSION_BASE} + SOVERSION ${LLAMA_VERSION_MAJOR} MACHO_CURRENT_VERSION 0 # keep macOS linker from seeing oversized version number ) +target_compile_definitions(llama PRIVATE + LLAMA_VERSION="${LLAMA_VERSION}" + LLAMA_COMMIT="${LLAMA_BUILD_COMMIT}" +) + target_include_directories(llama PRIVATE .) target_include_directories(llama PUBLIC ../include) target_compile_features (llama PRIVATE cxx_std_17) # don't bump diff --git a/src/llama.cpp b/src/llama.cpp index 94c8f60e0..9ff1902fc 100644 --- a/src/llama.cpp +++ b/src/llama.cpp @@ -114,6 +114,10 @@ bool llama_supports_rpc(void) { return ggml_backend_reg_by_name("RPC") != nullptr; } +const char * llama_version(void) { + return LLAMA_VERSION; +} + void llama_backend_init(void) { ggml_time_init(); diff --git a/tests/test-quantize-stats.cpp b/tests/test-quantize-stats.cpp index c65557534..e07d75b7e 100644 --- a/tests/test-quantize-stats.cpp +++ b/tests/test-quantize-stats.cpp @@ -301,7 +301,7 @@ int main(int argc, char ** argv) { return 1; } - llama_print_build_info(); + llama_print_build_info(llama_version()); // load the model fprintf(stderr, "Loading model\n"); diff --git a/tools/cvector-generator/cvector-generator.cpp b/tools/cvector-generator/cvector-generator.cpp index 8c6b3d868..558c37e61 100644 --- a/tools/cvector-generator/cvector-generator.cpp +++ b/tools/cvector-generator/cvector-generator.cpp @@ -421,7 +421,7 @@ int main(int argc, char ** argv) { params.cb_eval_user_data = &cb_data; params.warmup = false; - llama_print_build_info(); + llama_print_build_info(llama_version()); llama_backend_init(); llama_numa_init(params.numa); diff --git a/tools/gguf-split/gguf-split.cpp b/tools/gguf-split/gguf-split.cpp index 5cafcc9aa..c6cdbb98e 100644 --- a/tools/gguf-split/gguf-split.cpp +++ b/tools/gguf-split/gguf-split.cpp @@ -106,7 +106,7 @@ static void split_params_parse_ex(int argc, const char ** argv, split_params & p split_print_usage(argv[0]); exit(0); } else if (arg == "--version") { - fprintf(stderr, "version: %d (%s)\n", llama_build_number(), llama_commit()); + fprintf(stderr, "version: %s (build %d, commit %s)\n", llama_version(), llama_build_number(), llama_commit()); fprintf(stderr, "built with %s for %s\n", llama_compiler(), llama_build_target()); exit(0); } else if (arg == "--dry-run") { diff --git a/tools/mtmd/CMakeLists.txt b/tools/mtmd/CMakeLists.txt index 3f4a6c670..769a44e0b 100644 --- a/tools/mtmd/CMakeLists.txt +++ b/tools/mtmd/CMakeLists.txt @@ -72,8 +72,8 @@ add_library(mtmd ) set_target_properties(mtmd PROPERTIES - VERSION ${LLAMA_INSTALL_VERSION} - SOVERSION 0 + VERSION ${LLAMA_VERSION_BASE} + SOVERSION ${LLAMA_VERSION_MAJOR} MACHO_CURRENT_VERSION 0 # keep macOS linker from seeing oversized version number ) diff --git a/tools/quantize/quantize.cpp b/tools/quantize/quantize.cpp index 15ef64c4b..8d03c8fcd 100644 --- a/tools/quantize/quantize.cpp +++ b/tools/quantize/quantize.cpp @@ -611,7 +611,7 @@ int llama_quantize(int argc, char ** argv) { } } - llama_print_build_info(); + llama_print_build_info(llama_version()); if (params.dry_run) { fprintf(stderr, "%s: calculating quantization size for '%s' as %s", __func__, fname_inp.c_str(), ftype_str.c_str()); From 7a9ff95979d0d9a2fa79962c52f0e929cc054a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigbj=C3=B8rn=20Skj=C3=A6ret?= Date: Wed, 12 Aug 2026 15:41:43 +0200 Subject: [PATCH 08/41] disable rocm cache (#26962) --- .github/workflows/build-cache.yml | 40 ++++++++++++------------ .github/workflows/build-cuda-windows.yml | 14 ++++----- .github/workflows/release.yml | 30 +++++++++--------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/build-cache.yml b/.github/workflows/build-cache.yml index e15fc5e08..2a1031728 100644 --- a/.github/workflows/build-cache.yml +++ b/.github/workflows/build-cache.yml @@ -119,27 +119,27 @@ jobs: version_major: ${{ env.OPENVINO_VERSION_MAJOR }} version_full: ${{ env.OPENVINO_VERSION_FULL }} - windows-2022-rocm-cache: - runs-on: windows-2022 + # windows-2022-rocm-cache: + # runs-on: windows-2022 - env: - # Make sure this is in sync with release.yml and build-cuda-windows.yml - ROCM_VERSION: "7.14.0" + # env: + # # Make sure this is in sync with release.yml and build-cuda-windows.yml + # ROCM_VERSION: "7.14.0" - steps: - - name: Clone - id: checkout - uses: actions/checkout@v6 + # steps: + # - name: Clone + # id: checkout + # uses: actions/checkout@v6 - - name: Setup Cache - uses: actions/cache@v5 - id: cache-rocm - with: - path: C:\TheRock\build - key: rocm-wheels-${{ env.ROCM_VERSION }}-multi-arch-${{ runner.os }} + # - name: Setup Cache + # uses: actions/cache@v5 + # id: cache-rocm + # with: + # path: C:\TheRock\build + # key: rocm-wheels-${{ env.ROCM_VERSION }}-multi-arch-${{ runner.os }} - - name: Setup ROCm - if: steps.cache-rocm.outputs.cache-hit != 'true' - uses: ./.github/actions/windows-setup-rocm - with: - version: ${{ env.ROCM_VERSION }} + # - name: Setup ROCm + # if: steps.cache-rocm.outputs.cache-hit != 'true' + # uses: ./.github/actions/windows-setup-rocm + # with: + # version: ${{ env.ROCM_VERSION }} diff --git a/.github/workflows/build-cuda-windows.yml b/.github/workflows/build-cuda-windows.yml index ff900802f..8b59f3975 100644 --- a/.github/workflows/build-cuda-windows.yml +++ b/.github/workflows/build-cuda-windows.yml @@ -97,15 +97,15 @@ jobs: id: checkout uses: actions/checkout@v6 - - name: Cache ROCm Installation - uses: actions/cache@v5 - id: cache-rocm - with: - path: C:\TheRock\build - key: rocm-wheels-${{ env.ROCM_VERSION }}-multi-arch-${{ runner.os }} + # - name: Cache ROCm Installation + # uses: actions/cache@v5 + # id: cache-rocm + # with: + # path: C:\TheRock\build + # key: rocm-wheels-${{ env.ROCM_VERSION }}-multi-arch-${{ runner.os }} - name: Setup ROCm - if: steps.cache-rocm.outputs.cache-hit != 'true' + # if: steps.cache-rocm.outputs.cache-hit != 'true' uses: ./.github/actions/windows-setup-rocm with: version: ${{ env.ROCM_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af34c9499..ad0df09f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -774,15 +774,15 @@ jobs: key: windows-rocm-${{ matrix.ROCM_VERSION }}-${{ matrix.build }} evict-old-files: 1d - - name: Cache ROCm Installation - id: cache-rocm - uses: actions/cache@v5 - with: - path: C:\TheRock\build - key: rocm-wheels-${{ matrix.ROCM_VERSION }}-multi-arch-${{ runner.os }} + # - name: Cache ROCm Installation + # id: cache-rocm + # uses: actions/cache@v5 + # with: + # path: C:\TheRock\build + # key: rocm-wheels-${{ matrix.ROCM_VERSION }}-multi-arch-${{ runner.os }} - name: Setup ROCm - if: steps.cache-rocm.outputs.cache-hit != 'true' + # if: steps.cache-rocm.outputs.cache-hit != 'true' uses: ./.github/actions/windows-setup-rocm with: version: ${{ matrix.ROCM_VERSION }} @@ -1320,10 +1320,10 @@ jobs: with: tool-cache: true - - name: ccache - uses: ggml-org/ccache-action@v1.2.21 - with: - key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} + # - name: ccache + # uses: ggml-org/ccache-action@v1.2.21 + # with: + # key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} - name: Dependencies id: depends @@ -1379,10 +1379,10 @@ jobs: ${{ env.CMAKE_ARGS }} cmake --build build --config Release -j $(nproc) - - name: ccache-clear - uses: ./.github/actions/ccache-clear - with: - key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} + # - name: ccache-clear + # uses: ./.github/actions/ccache-clear + # with: + # key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} - name: Determine tag name id: tag From 9558fa44c92746a58dd07ad1bf0c889715b938a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sigbj=C3=B8rn=20Skj=C3=A6ret?= Date: Wed, 12 Aug 2026 15:41:44 +0200 Subject: [PATCH 09/41] ci : disable ubuntu-rocm (#26969) * disable ubuntu-rocm * link PR --- .github/workflows/release.yml | 200 +++++++++++++++++----------------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad0df09f3..82a8364e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1285,123 +1285,123 @@ jobs: path: llama-${{ steps.tag.outputs.name }}-bin-ubuntu-sycl-${{ matrix.build }}-x64.tar.gz name: llama-bin-ubuntu-sycl-${{ matrix.build }}-x64.tar.gz - ubuntu-22-rocm: - needs: [check-release, get-version] - if: ${{ needs.check-release.outputs.should_release == 'true' }} +# ubuntu-22-rocm: +# needs: [check-release, get-version] +# if: ${{ needs.check-release.outputs.should_release == 'true' }} - runs-on: ubuntu-22.04 +# runs-on: ubuntu-22.04 - permissions: - actions: write +# permissions: +# actions: write - strategy: - matrix: - include: - - ROCM_VERSION: "7.14.0" - gpu_targets: "gfx908;gfx90a;gfx942;gfx950;gfx1010;gfx1011;gfx1012;gfx1030;gfx1031;gfx1032;gfx1033;gfx1034;gfx1035;gfx1036;gfx1100;gfx1101;gfx1102;gfx1150;gfx1151;gfx1152;gfx1200;gfx1201" - build: 'x64' +# strategy: +# matrix: +# include: +# - ROCM_VERSION: "7.14.0" +# gpu_targets: "gfx908;gfx90a;gfx942;gfx950;gfx1010;gfx1011;gfx1012;gfx1030;gfx1031;gfx1032;gfx1033;gfx1034;gfx1035;gfx1036;gfx1100;gfx1101;gfx1102;gfx1150;gfx1151;gfx1152;gfx1200;gfx1201" +# build: 'x64' - steps: - - name: Clone - id: checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 +# steps: +# - name: Clone +# id: checkout +# uses: actions/checkout@v6 +# with: +# fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "npm" - cache-dependency-path: "tools/ui/package-lock.json" +# - name: Setup Node.js +# uses: actions/setup-node@v6 +# with: +# node-version: "24" +# cache: "npm" +# cache-dependency-path: "tools/ui/package-lock.json" - - name: Free up disk space - uses: ggml-org/free-disk-space@v1.3.1 - with: - tool-cache: true +# - name: Free up disk space +# uses: ggml-org/free-disk-space@v1.3.1 +# with: +# tool-cache: true - # - name: ccache - # uses: ggml-org/ccache-action@v1.2.21 - # with: - # key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} +# # - name: ccache +# # uses: ggml-org/ccache-action@v1.2.21 +# # with: +# # key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} - - name: Dependencies - id: depends - run: | - sudo apt install -y build-essential git cmake wget +# - name: Dependencies +# id: depends +# run: | +# sudo apt install -y build-essential git cmake wget - - name: Setup TheRock with Wheels - id: therock_env - run: | - # Create Python virtual environment - python3 -m venv .venv - source .venv/bin/activate +# - name: Setup TheRock with Wheels +# id: therock_env +# run: | +# # Create Python virtual environment +# python3 -m venv .venv +# source .venv/bin/activate - # Install ROCm wheels for build - # libraries = HIP runtime and CMake configs needed for linking - # devel = compilers, headers, static libs - python -m pip install --upgrade pip - python -m pip install --index-url https://repo.amd.com/rocm/whl-multi-arch/ "rocm[libraries,devel]==${{ matrix.ROCM_VERSION }}" +# # Install ROCm wheels for build +# # libraries = HIP runtime and CMake configs needed for linking +# # devel = compilers, headers, static libs +# python -m pip install --upgrade pip +# python -m pip install --index-url https://repo.amd.com/rocm/whl-multi-arch/ "rocm[libraries,devel]==${{ matrix.ROCM_VERSION }}" - # Get ROCm installation paths using the rocm-sdk CLI tool - ROCM_PATH=$(rocm-sdk path --root) - CMAKE_PATH=$(rocm-sdk path --cmake) - BIN_PATH=$(rocm-sdk path --bin) - echo "ROCM_PATH=$ROCM_PATH" - echo "CMAKE_PATH=$CMAKE_PATH" - echo "BIN_PATH=$BIN_PATH" +# # Get ROCm installation paths using the rocm-sdk CLI tool +# ROCM_PATH=$(rocm-sdk path --root) +# CMAKE_PATH=$(rocm-sdk path --cmake) +# BIN_PATH=$(rocm-sdk path --bin) +# echo "ROCM_PATH=$ROCM_PATH" +# echo "CMAKE_PATH=$CMAKE_PATH" +# echo "BIN_PATH=$BIN_PATH" - # Set environment variables - echo "ROCM_PATH=$ROCM_PATH" >> $GITHUB_ENV - echo "CMAKE_PREFIX_PATH=$CMAKE_PATH" >> $GITHUB_ENV - echo "HIP_PATH=$ROCM_PATH" >> $GITHUB_ENV - echo "PATH=$BIN_PATH:${PATH}" >> $GITHUB_ENV - echo "LD_LIBRARY_PATH=$ROCM_PATH/lib:${LD_LIBRARY_PATH:-}" >> $GITHUB_ENV +# # Set environment variables +# echo "ROCM_PATH=$ROCM_PATH" >> $GITHUB_ENV +# echo "CMAKE_PREFIX_PATH=$CMAKE_PATH" >> $GITHUB_ENV +# echo "HIP_PATH=$ROCM_PATH" >> $GITHUB_ENV +# echo "PATH=$BIN_PATH:${PATH}" >> $GITHUB_ENV +# echo "LD_LIBRARY_PATH=$ROCM_PATH/lib:${LD_LIBRARY_PATH:-}" >> $GITHUB_ENV - # Keep venv activated for subsequent steps - echo "$(pwd)/.venv/bin" >> $GITHUB_PATH +# # Keep venv activated for subsequent steps +# echo "$(pwd)/.venv/bin" >> $GITHUB_PATH - - name: Build with native CMake HIP support - id: cmake_build - run: | - cmake -B build -S . \ - -DCMAKE_HIP_COMPILER="$(hipconfig -l)/clang" \ - -DCMAKE_BUILD_TYPE=Release \ - -DGGML_BACKEND_DL=ON \ - -DGGML_NATIVE=OFF \ - -DCMAKE_INSTALL_RPATH='$ORIGIN' \ - -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ - -DGGML_CPU_ALL_VARIANTS=ON \ - -DGPU_TARGETS="${{ matrix.gpu_targets }}" \ - -DGGML_HIP=ON \ - -DHIP_PLATFORM=amd \ - -DHF_UI_VERSION=${{ needs.get-version.outputs.ui_version }} \ - ${{ env.CMAKE_ARGS }} - cmake --build build --config Release -j $(nproc) +# - name: Build with native CMake HIP support +# id: cmake_build +# run: | +# cmake -B build -S . \ +# -DCMAKE_HIP_COMPILER="$(hipconfig -l)/clang" \ +# -DCMAKE_BUILD_TYPE=Release \ +# -DGGML_BACKEND_DL=ON \ +# -DGGML_NATIVE=OFF \ +# -DCMAKE_INSTALL_RPATH='$ORIGIN' \ +# -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ +# -DGGML_CPU_ALL_VARIANTS=ON \ +# -DGPU_TARGETS="${{ matrix.gpu_targets }}" \ +# -DGGML_HIP=ON \ +# -DHIP_PLATFORM=amd \ +# -DHF_UI_VERSION=${{ needs.get-version.outputs.ui_version }} \ +# ${{ env.CMAKE_ARGS }} +# cmake --build build --config Release -j $(nproc) - # - name: ccache-clear - # uses: ./.github/actions/ccache-clear - # with: - # key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} +# # - name: ccache-clear +# # uses: ./.github/actions/ccache-clear +# # with: +# # key: release-ubuntu-22.04-rocm-${{ matrix.ROCM_VERSION }} - - name: Determine tag name - id: tag - uses: ./.github/actions/get-tag-name +# - name: Determine tag name +# id: tag +# uses: ./.github/actions/get-tag-name - - name: Get ROCm short version - run: echo "ROCM_VERSION_SHORT=$(echo '${{ matrix.ROCM_VERSION }}' | cut -d '.' -f 1,2)" >> $GITHUB_ENV +# - name: Get ROCm short version +# run: echo "ROCM_VERSION_SHORT=$(echo '${{ matrix.ROCM_VERSION }}' | cut -d '.' -f 1,2)" >> $GITHUB_ENV - - name: Pack artifacts - id: pack_artifacts - run: | - cp LICENSE ./build/bin/ - tar -czvf llama-${{ steps.tag.outputs.name }}-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz --transform "s,^\.,llama-${{ steps.tag.outputs.name }}," -C ./build/bin . +# - name: Pack artifacts +# id: pack_artifacts +# run: | +# cp LICENSE ./build/bin/ +# tar -czvf llama-${{ steps.tag.outputs.name }}-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz --transform "s,^\.,llama-${{ steps.tag.outputs.name }}," -C ./build/bin . - - name: Upload artifacts - uses: actions/upload-artifact@v6 - with: - path: llama-${{ steps.tag.outputs.name }}-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz - name: llama-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz +# - name: Upload artifacts +# uses: actions/upload-artifact@v6 +# with: +# path: llama-${{ steps.tag.outputs.name }}-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz +# name: llama-bin-ubuntu-rocm-${{ env.ROCM_VERSION_SHORT }}-${{ matrix.build }}.tar.gz ios-xcode: needs: [check-release, get-version] @@ -1578,7 +1578,7 @@ jobs: #- windows-sycl - windows-rocm - windows-openvino - - ubuntu-22-rocm + #- ubuntu-22-rocm - ubuntu-cpu - ubuntu-vulkan - ubuntu-24-openvino @@ -1688,7 +1688,7 @@ jobs: - [Ubuntu s390x (CPU)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-s390x.tar.gz) - [Ubuntu x64 (Vulkan)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-vulkan-x64.tar.gz) - [Ubuntu arm64 (Vulkan)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-vulkan-arm64.tar.gz) - - [Ubuntu x64 (ROCm 7.14)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-rocm-7.14-x64.tar.gz) + - Ubuntu x64 (ROCm 7.14)[DISABLED](https://github.com/ggml-org/llama.cpp/pull/26969) - [Ubuntu x64 (OpenVINO)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-openvino-${{ needs.ubuntu-24-openvino.outputs.openvino_version }}-x64.tar.gz) - [Ubuntu x64 (SYCL FP32)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-sycl-fp32-x64.tar.gz) - [Ubuntu x64 (SYCL FP16)](https://github.com/ggml-org/llama.cpp/releases/download/${{ steps.tag.outputs.name }}/llama-${{ steps.tag.outputs.name }}-bin-ubuntu-sycl-fp16-x64.tar.gz) From 84e908c625fb60992b4cdef8180fb12fa9b4c4bf Mon Sep 17 00:00:00 2001 From: Eve <139727413+netrunnereve@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:01:12 +0000 Subject: [PATCH 10/41] ci: fix thread sanitizer + remove ccache (#26927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test address on Intel-LNL-U7-258V * retry * run address on github * use native build for cpu * this should be runnable everywhere multicore * disable ccache --------- Co-authored-by: Sigbjørn Skjæret --- .github/workflows/build-cmake-pkg.yml | 6 +++--- .github/workflows/build-cpu.yml | 1 + .github/workflows/build-sanitize.yml | 20 ++++++++++---------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-cmake-pkg.yml b/.github/workflows/build-cmake-pkg.yml index a958870de..0e4069ce3 100644 --- a/.github/workflows/build-cmake-pkg.yml +++ b/.github/workflows/build-cmake-pkg.yml @@ -5,7 +5,7 @@ on: jobs: linux: - runs-on: [self-hosted, Linux, CPU] + runs-on: [self-hosted, Linux] steps: - uses: actions/checkout@v6 with: @@ -23,7 +23,7 @@ jobs: -DLLAMA_BUILD_APP=OFF \ -DLLAMA_BUILD_IS_DEV=OFF \ -DCMAKE_BUILD_TYPE=Release - cmake --build build --config Release + cmake --build build --config Release -j $(nproc) cmake --install build --prefix "$PREFIX" --config Release export LLAMA_CONFIG="$PREFIX"/lib/cmake/llama/llama-config.cmake @@ -54,4 +54,4 @@ jobs: cd examples/simple-cmake-pkg cmake -S . -B build -DCMAKE_PREFIX_PATH="$PREFIX"/lib/cmake - cmake --build build + cmake --build build -j $(nproc) diff --git a/.github/workflows/build-cpu.yml b/.github/workflows/build-cpu.yml index 57622e1d4..df70f4d10 100644 --- a/.github/workflows/build-cpu.yml +++ b/.github/workflows/build-cpu.yml @@ -94,6 +94,7 @@ jobs: id: cmake_build run: | cmake -B build \ + -DGGML_NATIVE=OFF \ -DLLAMA_FATAL_WARNINGS=ON \ -DGGML_RPC=ON \ -DGGML_NATIVE=OFF diff --git a/.github/workflows/build-sanitize.yml b/.github/workflows/build-sanitize.yml index 9654cb4e4..974af62eb 100644 --- a/.github/workflows/build-sanitize.yml +++ b/.github/workflows/build-sanitize.yml @@ -39,9 +39,9 @@ jobs: strategy: matrix: include: + # thread and address doesn't run properly on some self hosted machines, so run it on Github instead - sanitizer: ADDRESS - machine: [self-hosted, X64, Linux] - # thread doesn't run properly on some self hosted machines, so run it on Github instead + machine: ubuntu-24.04 - sanitizer: THREAD machine: ubuntu-24.04 - sanitizer: UNDEFINED @@ -54,14 +54,14 @@ jobs: id: checkout uses: actions/checkout@v6 - - name: ccache - uses: ggml-org/ccache-action@v1.2.21 - if: ${{ matrix.sanitizer == 'THREAD' }} - with: - key: ctest-thread-ubuntu-24.04 - variant: ccache - evict-old-files: 1d - save: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + # - name: ccache + # uses: ggml-org/ccache-action@v1.2.21 + # if: ${{ matrix.sanitizer != 'UNDEFINED' }} + # with: + # key: ctest-${{ matrix.sanitizer }}-ubuntu-24.04 + # variant: ccache + # evict-old-files: 1d + # save: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} # with UNDEFINED sanitizer, we have to build in Debug to avoid GCC 13 false-positive warnings - name: Build (undefined) From 8e7f22b67ef4667b4ddd50230771287f328cfb3f Mon Sep 17 00:00:00 2001 From: Johnathan Craig Maudlin <13183098+jcmdln@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:02:27 -0400 Subject: [PATCH 11/41] common: add system-level config file (#26118) * common: Add CLI > ENV > models-presets > INI precedence 1. CLI flags have the highest precedence 2. ENV vars have the second-highest precedence 3. System and User configs have the lowest precedence - Linux/BSD/Mac - /etc/llama.cpp/config.ini < ${XDG_CONFIG_HOME:-~/.config}/llama.cpp/config.ini - Windows - %PROGRAMDATA%\llama.cpp\config.ini < %APPDATA%\llama.cpp\config.ini * fix UB * use common_get_env * ignore_unknown_keys * nits * add docs --------- Co-authored-by: Xuan Son Nguyen --- common/arg.cpp | 50 ++++++++++++++++++++++++++++++++ common/common.cpp | 73 ++++++++++++++++++++++++++++++++++++++++------- common/common.h | 1 + common/preset.cpp | 2 ++ common/preset.h | 4 +++ docs/preset.md | 17 ++++++++++- 6 files changed, 136 insertions(+), 11 deletions(-) diff --git a/common/arg.cpp b/common/arg.cpp index b2fddfe5f..9fe7d66e2 100644 --- a/common/arg.cpp +++ b/common/arg.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include // for hardware_concurrency #include @@ -704,12 +705,61 @@ void common_models_handler_apply(common_models_handler & handler, common_params // CLI argument parsing functions // +// apply config files (if present), a later file overrides an earlier one: +// 1. system-wide: /etc/llama.cpp/config.ini (%PROGRAMDATA%\llama.cpp\config.ini on windows) +// 2. user-level: ${XDG_CONFIG_HOME:-~/.config}/llama.cpp/config.ini (%APPDATA%\llama.cpp\config.ini on windows) +static void common_params_apply_system_config(common_params & params, llama_example ex) { + std::vector paths; + +#if defined(_WIN32) + const std::string program_data = common_get_env("PROGRAMDATA"); + if (!program_data.empty()) { + paths.push_back(program_data + "\\llama.cpp\\config.ini"); + } +#else + paths.push_back("/etc/llama.cpp/config.ini"); +#endif + + try { + paths.push_back(fs_get_config_directory() + "config.ini"); + } catch (const std::exception & e) { + LOG_DBG("cannot read user-level config file, skipping: %s\n", e.what()); + } + + std::vector found; + for (const auto & path : paths) { + std::error_code ec; + if (std::filesystem::exists(path, ec)) { + found.push_back(path); + } + } + if (found.empty()) { + return; + } + + common_preset_context ctx(ex); + ctx.ignore_unknown_keys = true; // the same config file is shared by all programs + for (const auto & path : found) { + LOG_INF("using config file: %s\n", path.c_str()); + common_preset global; + common_presets presets = ctx.load_from_ini(path, global); + global.apply_to_params(params); + auto it = presets.find(COMMON_PRESET_DEFAULT_NAME); + if (it != presets.end()) { + it->second.apply_to_params(params); + } + } +} + static bool common_params_parse_ex(int argc, char ** argv, common_params_context & ctx_arg) { common_params & params = ctx_arg.params; // setup log directly from params.verbosity: see tools/cli/cli.cpp common_log_set_verbosity_thold(params.verbosity); + // config file applies first, so env variables and CLI arguments override it + common_params_apply_system_config(params, ctx_arg.ex); + std::unordered_map> arg_to_options; for (auto & opt : ctx_arg.options) { for (const auto & arg : opt.args) { diff --git a/common/common.cpp b/common/common.cpp index 2e3f14cd1..5639bdf41 100644 --- a/common/common.cpp +++ b/common/common.cpp @@ -1019,20 +1019,21 @@ std::string fs_get_cache_directory() { std::string cache_directory = ""; auto ensure_trailing_slash = [](std::string p) { // Make sure to add trailing slash - if (p.back() != DIRECTORY_SEPARATOR) { + if (p.empty() || p.back() != DIRECTORY_SEPARATOR) { p += DIRECTORY_SEPARATOR; } return p; }; - if (getenv("LLAMA_CACHE")) { - cache_directory = std::getenv("LLAMA_CACHE"); - } else { + cache_directory = common_get_env("LLAMA_CACHE"); + if (cache_directory.empty()) { #if defined(__linux__) || defined(__FreeBSD__) || defined(_AIX) || \ defined(__OpenBSD__) || defined(__NetBSD__) - if (std::getenv("XDG_CACHE_HOME")) { - cache_directory = std::getenv("XDG_CACHE_HOME"); - } else if (std::getenv("HOME")) { - cache_directory = std::getenv("HOME") + std::string("/.cache/"); + const std::string xdg_cache_home = common_get_env("XDG_CACHE_HOME"); + const std::string home = common_get_env("HOME"); + if (!xdg_cache_home.empty()) { + cache_directory = xdg_cache_home; + } else if (!home.empty()) { + cache_directory = home + "/.cache/"; } else { #if defined(__linux__) /* no $HOME is defined, fallback to getpwuid */ @@ -1047,9 +1048,16 @@ std::string fs_get_cache_directory() { #endif /* defined(__linux__) */ } #elif defined(__APPLE__) - cache_directory = std::getenv("HOME") + std::string("/Library/Caches/"); + cache_directory = common_get_env("HOME"); + if (cache_directory.empty()) { + throw std::runtime_error("Failed to find $HOME directory"); + } + cache_directory += "/Library/Caches/"; #elif defined(_WIN32) - cache_directory = std::getenv("LOCALAPPDATA"); + cache_directory = common_get_env("LOCALAPPDATA"); + if (cache_directory.empty()) { + throw std::runtime_error("Failed to find %LOCALAPPDATA% directory"); + } #elif defined(__EMSCRIPTEN__) GGML_ABORT("not implemented on this platform"); #else @@ -1061,6 +1069,51 @@ std::string fs_get_cache_directory() { return ensure_trailing_slash(cache_directory); } +std::string fs_get_config_directory() { + std::string config_directory = ""; + auto ensure_trailing_slash = [](std::string p) { + if (p.empty() || p.back() != DIRECTORY_SEPARATOR) { + p += DIRECTORY_SEPARATOR; + } + return p; + }; +#if defined(__linux__) || defined(__FreeBSD__) || defined(_AIX) || \ + defined(__OpenBSD__) || defined(__NetBSD__) || defined(__APPLE__) + const std::string xdg_config_home = common_get_env("XDG_CONFIG_HOME"); + const std::string home = common_get_env("HOME"); + if (!xdg_config_home.empty()) { + config_directory = xdg_config_home; + } else if (!home.empty()) { + config_directory = home + "/.config/"; + } else { +#if defined(__linux__) + /* no $HOME is defined, fallback to getpwuid */ + struct passwd *pw = getpwuid(getuid()); + if ((!pw) || (!pw->pw_dir)) { + throw std::runtime_error("Failed to find $HOME directory"); + } + + config_directory = std::string(pw->pw_dir) + std::string("/.config/"); +#else + throw std::runtime_error("Failed to find $HOME directory"); +#endif + } +#elif defined(_WIN32) + config_directory = common_get_env("APPDATA"); + if (config_directory.empty()) { + throw std::runtime_error("Failed to find %APPDATA% directory"); + } +#elif defined(__EMSCRIPTEN__) + // caller decides what to do when there is no config directory + throw std::runtime_error("not implemented on this platform"); +#else +# error Unknown architecture +#endif + config_directory = ensure_trailing_slash(config_directory); + config_directory += "llama.cpp"; + return ensure_trailing_slash(config_directory); +} + std::string fs_get_cache_file(const std::string & filename) { GGML_ASSERT(filename.find(DIRECTORY_SEPARATOR) == std::string::npos); std::string cache_directory = fs_get_cache_directory(); diff --git a/common/common.h b/common/common.h index d485d4fb4..b16bd3bb6 100644 --- a/common/common.h +++ b/common/common.h @@ -881,6 +881,7 @@ bool fs_is_directory(const std::string & path); std::string fs_get_cache_directory(); std::string fs_get_cache_file(const std::string & filename); +std::string fs_get_config_directory(); struct common_file_info { std::string path; diff --git a/common/preset.cpp b/common/preset.cpp index eb0c60b09..0b29af883 100644 --- a/common/preset.cpp +++ b/common/preset.cpp @@ -322,6 +322,8 @@ common_presets common_preset_context::load_from_ini(const std::string & path, co preset.options[opt] = value; } LOG_DBG("accepted option: %s = %s\n", key.c_str(), preset.options[opt].c_str()); + } else if (ignore_unknown_keys) { + LOG_WRN("ignoring option '%s' from %s: not supported by this program\n", key.c_str(), path.c_str()); } else { throw std::runtime_error(string_format( "option '%s' not recognized in preset '%s'", diff --git a/common/preset.h b/common/preset.h index 52935ebde..d8fc3915b 100644 --- a/common/preset.h +++ b/common/preset.h @@ -59,6 +59,10 @@ struct common_preset_context { bool filter_allowed_keys = false; std::set allowed_keys; + // if true, options unknown to the current example are skipped instead of being an error + // used for config files shared by all binaries, where each binary only knows a subset of options + bool ignore_unknown_keys = false; + // if only_remote_allowed is true, only accept whitelisted keys common_preset_context(llama_example ex); diff --git a/docs/preset.md b/docs/preset.md index 85762a420..3d85467e8 100644 --- a/docs/preset.md +++ b/docs/preset.md @@ -4,7 +4,7 @@ The INI preset feature, introduced in [PR#17859](https://github.com/ggml-org/llama.cpp/pull/17859), allows users to create reusable and shareable parameter configurations for llama.cpp. -### Using Presets with the Server +## Using Presets with the Server When running multiple models on the server (router mode), INI preset files can be used to configure model-specific parameters. Please refer to the [server documentation](../tools/server/README.md) for more details. @@ -93,3 +93,18 @@ llama-server -hf user/repo:gpt-oss-120b-hf ``` Please make sure to provide the correct `hf-repo` for each child preset. Otherwise, you may get error: `The specified tag is not a valid quantization scheme.` + +## System-level config + +The system-level config, added in PR [#26118](https://github.com/ggml-org/llama.cpp/pull/26118), allows sharing the same set of options among multiple tools and examples. Unlike the sections above, it is not limited to the server. + +These files are loaded on startup if present. A later file overrides an earlier one: +1. System-wide: `/etc/llama.cpp/config.ini` (or `%PROGRAMDATA%\llama.cpp\config.ini` on Windows) +2. User-level: `$XDG_CONFIG_HOME/llama.cpp/config.ini`, `~/.config/llama.cpp/config.ini` by default (or `%APPDATA%\llama.cpp\config.ini` on Windows) + +The config file is applied first, then its options are overridden by ENV variables, CLI arguments and model presets (in router mode). + +Note: +- Only the `[*]` and default sections are used; options written before any section header belong to "default. Named sections are ignored +- Tool-specific options can be specified, but will be ignored (with a warning) if the example doesn't support it
Example: if you specify `port = 1234`, only `llama-server` will use it, other examples will ignore it +- `model` or `hf-repo` are not recommended to be configured system-level, because it may introduce conflicts
Example: a `hf-repo` in the config file still takes effect when you pass `-m` on the command line, so you may load a different model than expected From e21152dc9636c6d2db6edc9b4531dfc5b2d1cba3 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 13 Aug 2026 06:53:56 +0200 Subject: [PATCH 12/41] ui: Constants refactor (#26908) * refactor: Constants * refactor: Constants/Enums cleanup * refactor: Constant objects instead of multiple single value constants * refactor: Cleanup constants --- tools/ui/pwa-assets-dark.config.ts | 2 +- tools/ui/pwa-assets.config.ts | 2 +- tools/ui/scripts/vite-plugin-build-info.ts | 2 +- .../ui/scripts/vite-plugin-relativize-base.ts | 2 +- tools/ui/scripts/vite-plugin-splash-screen.ts | 9 +- .../actions/ActionIconCopyToClipboard.svelte | 2 +- ...hatAttachmentsListItemThumbnailFile.svelte | 2 +- ...hatAttachmentsPreviewCurrentItemPdf.svelte | 2 +- ...hatAttachmentsPreviewThumbnailStrip.svelte | 2 +- .../ChatFormActionAddButton.svelte | 3 +- .../ChatFormActionAddDropdown.svelte | 2 +- .../ChatFormActionAddMcpServersSubmenu.svelte | 3 +- .../ChatFormActionAddReasoningSubmenu.svelte | 2 +- .../ChatFormActionAddSheet.svelte | 8 +- .../ChatFormActionAddToolsSubmenu.svelte | 3 +- .../ChatFormActionRecord.svelte | 2 +- .../ChatFormActions/ChatFormActions.svelte | 3 +- .../ChatForm/ChatFormContenteditable.svelte | 6 +- .../ChatFormMentionPicker.svelte | 10 +- .../ChatForm/ChatFormWorkingDirectory.svelte | 23 +--- .../ChatMessage/ChatMessage.svelte | 4 +- .../ChatMessageToolCallBlockDefault.svelte | 2 +- ...essageToolCallBlockExecShellCommand.svelte | 3 +- .../ChatMessageToolCallBlockReadFile.svelte | 4 +- .../ChatMessageToolCallBlockReadMedia.svelte | 2 +- ...atMessageToolCallBlockSearchResults.svelte | 2 +- .../ChatMessageToolCall/ToolCallBlock.svelte | 5 +- .../ChatMessageToolCall/parsers/read-file.ts | 10 +- .../ChatMessageToolCall/parsers/read-media.ts | 2 +- .../ChatMessageToolCall/parsers/write-file.ts | 9 +- .../ChatMessageActionCard.svelte | 2 +- .../ChatMessageReasoningBlock.svelte | 2 +- .../ChatScreenActionScrollDown.svelte | 2 +- .../ChatScreen/ChatScreenServerError.svelte | 2 +- .../content/CollapsibleContentBlock.svelte | 2 +- .../MarkdownContent/MarkdownContent.svelte | 49 +++---- .../plugins/rehype/code-block-utils.ts | 22 ++-- .../plugins/rehype/enhance-code-blocks.ts | 6 +- .../plugins/rehype/enhance-svg-blocks.ts | 35 ++--- .../plugins/rehype/file-badge.ts | 2 +- .../MarkdownContent/plugins/rehype/svg-pre.ts | 8 +- .../app/content/MermaidPreview.svelte | 4 +- .../app/content/MermaidPreviewControls.svelte | 2 +- .../app/content/SyntaxHighlightedCode.svelte | 2 +- .../dialogs/DialogMcpResourcePreview.svelte | 12 +- .../dialogs/DialogMcpResourcesBrowser.svelte | 2 +- .../app/dialogs/DialogMcpServerAddNew.svelte | 9 +- .../dialogs/DialogModelNotAvailable.svelte | 3 +- .../components/app/forms/SearchInput.svelte | 2 +- .../app/mcp/McpActiveServersAvatars.svelte | 3 +- .../app/mcp/McpResourcePreview.svelte | 2 +- .../McpResourcesBrowserHeader.svelte | 2 +- .../McpResourcesBrowserServerItem.svelte | 2 +- .../mcp/McpServerCard/McpServerCard.svelte | 2 +- .../components/app/mcp/McpServerForm.svelte | 16 +-- .../app/misc/HorizontalScrollCarousel.svelte | 2 +- .../app/models/ModelsSelectorOption.svelte | 2 +- .../SidebarNavigationActions.svelte | 2 +- .../SidebarNavigationConversationItem.svelte | 3 +- .../app/server/ServerErrorSplash.svelte | 8 +- .../components/app/server/ServerStatus.svelte | 2 +- .../SettingsChat/SettingsChatFields.svelte | 3 +- .../SettingsChatImportExportSection.svelte | 2 +- .../SettingsChat/SettingsChatToolsTab.svelte | 4 +- .../SettingsChatDesktopSidebar.svelte | 2 +- .../settings/SettingsChatMobileHeader.svelte | 2 +- .../src/lib/components/pwa/PwaMetaTags.svelte | 3 +- .../{agentic.ts => agentic.constants.ts} | 16 +-- ...ndpoints.ts => api-endpoints.constants.ts} | 0 .../constants/{app.ts => app.constants.ts} | 0 .../ui/src/lib/constants/attachment-labels.ts | 4 - ...t-menu.ts => attachment-menu.constants.ts} | 25 +--- ...uto-scroll.ts => auto-scroll.constants.ts} | 0 ...ction.ts => binary-detection.constants.ts} | 0 ...n-tools.ts => built-in-tools.constants.ts} | 14 +- tools/ui/src/lib/constants/cache.constants.ts | 44 +++++++ tools/ui/src/lib/constants/cache.ts | 54 -------- .../{chat-form.ts => chat-form.constants.ts} | 0 .../{cli-flags.ts => cli-flags.constants.ts} | 0 .../src/lib/constants/code-block.constants.ts | 50 ++++++++ tools/ui/src/lib/constants/code-blocks.ts | 8 -- tools/ui/src/lib/constants/code.ts | 27 ---- .../constants/content-detection.constants.ts | 20 +++ ...up.ts => context-gauge-popup.constants.ts} | 0 ...text-keys.ts => context-keys.constants.ts} | 0 ...ctions.ts => control-actions.constants.ts} | 2 - ...rt.ts => conversation-import.constants.ts} | 0 ...ss-classes.ts => css-classes.constants.ts} | 0 .../{database.ts => database.constants.ts} | 2 +- ...-blocks.ts => diagram-blocks.constants.ts} | 0 .../{error.ts => error.constants.ts} | 0 .../lib/constants/floating-ui-constraints.ts | 2 - ...{formatters.ts => formatters.constants.ts} | 0 .../ui/src/lib/constants/headers.constants.ts | 35 +++++ .../{icons.ts => icons.constants.ts} | 0 tools/ui/src/lib/constants/image-size.ts | 3 - tools/ui/src/lib/constants/image.constants.ts | 32 +++++ tools/ui/src/lib/constants/index.ts | 120 +++++++++--------- tools/ui/src/lib/constants/jpeg-exif.ts | 30 ----- ...-pairs.ts => key-value-pairs.constants.ts} | 0 ...ction.ts => latex-protection.constants.ts} | 0 ...eral-html.ts => literal-html.constants.ts} | 5 - .../src/lib/constants/markdown.constants.ts | 17 +++ tools/ui/src/lib/constants/markdown.ts | 5 - ...e-size.ts => max-bundle-size.constants.ts} | 0 .../{mcp-form.ts => mcp-form.constants.ts} | 0 ...-resource.ts => mcp-resource.constants.ts} | 0 .../constants/{mcp.ts => mcp.constants.ts} | 64 +++------- ...on-badge.ts => mention-badge.constants.ts} | 0 ...-blocks.ts => mermaid-blocks.constants.ts} | 0 .../lib/constants/message-export.constants.ts | 24 ++++ tools/ui/src/lib/constants/message-export.ts | 23 ---- .../src/lib/constants/model-id.constants.ts | 43 +++++++ tools/ui/src/lib/constants/model-id.ts | 46 ------- ...-loading.ts => model-loading.constants.ts} | 0 ...h-display.ts => path-display.constants.ts} | 0 .../{precision.ts => precision.constants.ts} | 0 tools/ui/src/lib/constants/processing-info.ts | 8 -- .../constants/{pwa.ts => pwa.constants.ts} | 2 +- .../lib/constants/reasoning-effort-tokens.ts | 12 -- ...ffort.ts => reasoning-effort.constants.ts} | 11 ++ ...s => recommended-mcp-servers.constants.ts} | 0 .../{routes.ts => routes.constants.ts} | 0 .../ui/src/lib/constants/sandbox.constants.ts | 13 ++ ...ngs-keys.ts => settings-keys.constants.ts} | 0 ...stry.ts => settings-registry.constants.ts} | 24 ++-- .../constants/special-characters.constants.ts | 16 +++ .../{storage.ts => storage.constants.ts} | 0 .../constants/{sse.ts => stream.constants.ts} | 8 ++ tools/ui/src/lib/constants/stream.ts | 3 - ...s.ts => supported-file-types.constants.ts} | 0 .../src/lib/constants/svg-blocks.constants.ts | 57 +++++++++ tools/ui/src/lib/constants/svg-blocks.ts | 49 ------- ...er.ts => table-html-restorer.constants.ts} | 0 ...ation.ts => title-generation.constants.ts} | 0 tools/ui/src/lib/constants/tools.ts | 22 ---- tools/ui/src/lib/constants/tooltip-config.ts | 1 - .../lib/constants/{ui.ts => ui.constants.ts} | 43 +++++-- ...-template.ts => uri-template.constants.ts} | 26 +--- .../constants/{url.ts => url.constants.ts} | 0 tools/ui/src/lib/constants/viewport.ts | 1 - .../constants/working-directory.constants.ts | 50 ++++++++ .../ui/src/lib/constants/working-directory.ts | 49 ------- tools/ui/src/lib/enums/attachment.enums.ts | 10 ++ tools/ui/src/lib/enums/index.ts | 7 +- .../lib/hooks/use-chat-form-pickers.svelte.ts | 3 +- .../hooks/use-keyboard-shortcuts.svelte.ts | 2 +- tools/ui/src/lib/hooks/use-pwa.svelte.ts | 3 +- .../lib/hooks/use-reasoning-menu.svelte.ts | 3 +- .../hooks/use-settings-navigation.svelte.ts | 2 +- tools/ui/src/lib/services/chat.service.ts | 21 +-- tools/ui/src/lib/services/index.ts | 2 +- tools/ui/src/lib/services/mcp.service.ts | 14 +- .../ui/src/lib/services/migration.service.ts | 5 +- tools/ui/src/lib/services/models.service.ts | 50 +++----- tools/ui/src/lib/services/router.service.ts | 2 +- tools/ui/src/lib/services/tools.service.ts | 10 +- tools/ui/src/lib/stores/agentic.svelte.ts | 5 +- tools/ui/src/lib/stores/chat.svelte.ts | 20 +-- .../ui/src/lib/stores/conversations.svelte.ts | 32 ++--- .../ui/src/lib/stores/mcp-resources.svelte.ts | 7 +- tools/ui/src/lib/stores/mcp.svelte.ts | 17 +-- tools/ui/src/lib/stores/models.svelte.ts | 7 +- tools/ui/src/lib/stores/tools.svelte.ts | 2 +- tools/ui/src/lib/stores/viewport.svelte.ts | 2 +- tools/ui/src/lib/types/chat.d.ts | 47 ++++++- tools/ui/src/lib/types/index.ts | 10 +- tools/ui/src/lib/types/navigation.d.ts | 14 ++ tools/ui/src/lib/types/tools.d.ts | 10 ++ tools/ui/src/lib/utils/agentic.ts | 34 ++--- tools/ui/src/lib/utils/api-fetch.ts | 2 +- tools/ui/src/lib/utils/api-headers.ts | 22 ++-- tools/ui/src/lib/utils/api-key-validation.ts | 6 +- tools/ui/src/lib/utils/built-in-tools.ts | 13 ++ tools/ui/src/lib/utils/cache-ttl.ts | 10 +- tools/ui/src/lib/utils/cap-img-size.ts | 5 +- .../lib/{constants => utils}/chat-commands.ts | 17 +-- tools/ui/src/lib/utils/code.ts | 27 ++-- .../lib/utils/contenteditable-tokenizer.ts | 2 +- tools/ui/src/lib/utils/cors-proxy.ts | 10 +- tools/ui/src/lib/utils/glob-search.ts | 13 +- tools/ui/src/lib/utils/heic-to-jpeg.ts | 4 +- tools/ui/src/lib/utils/index.ts | 11 +- tools/ui/src/lib/utils/jpeg-orientation.ts | 32 ++--- tools/ui/src/lib/utils/mcp.ts | 47 +++---- tools/ui/src/lib/utils/mention-badge.ts | 7 +- tools/ui/src/lib/utils/path-display.ts | 6 +- .../sandbox.ts => utils/sandbox-tool.ts} | 19 +-- tools/ui/src/lib/utils/sanitize-svg.ts | 10 +- tools/ui/src/lib/utils/stream-identity.ts | 4 +- tools/ui/src/lib/utils/uri-template.ts | 42 +++--- tools/ui/src/lib/utils/working-directory.ts | 36 +++--- tools/ui/src/routes/+error.svelte | 3 +- tools/ui/src/routes/+layout.svelte | 16 ++- tools/ui/src/routes/search/+page.svelte | 2 +- .../tests/client/apikey-splash.svelte.test.ts | 2 +- .../chat-form-enter-code-block.svelte.test.ts | 2 +- ...ettings-registry-invariants.svelte.test.ts | 3 +- ...tings-render-keys-migration.svelte.test.ts | 2 +- .../client/ui-settings-sync.svelte.test.ts | 2 +- tools/ui/tests/unit/agentic-strip.test.ts | 2 +- .../tests/unit/mcp-override-fallback.test.ts | 3 +- .../ui/tests/unit/mcp-servers-default.test.ts | 4 +- tools/ui/tests/unit/mcp-service.test.ts | 8 +- .../unit/parse-mcp-server-settings.test.ts | 2 +- tools/ui/tests/unit/sanitize-headers.test.ts | 8 +- tools/ui/tests/unit/uri-template.test.ts | 8 +- tools/ui/tests/unit/working-directory.test.ts | 10 +- tools/ui/vite.config.ts | 2 +- 209 files changed, 1101 insertions(+), 1137 deletions(-) rename tools/ui/src/lib/constants/{agentic.ts => agentic.constants.ts} (77%) rename tools/ui/src/lib/constants/{api-endpoints.ts => api-endpoints.constants.ts} (100%) rename tools/ui/src/lib/constants/{app.ts => app.constants.ts} (100%) delete mode 100644 tools/ui/src/lib/constants/attachment-labels.ts rename tools/ui/src/lib/constants/{attachment-menu.ts => attachment-menu.constants.ts} (75%) rename tools/ui/src/lib/constants/{auto-scroll.ts => auto-scroll.constants.ts} (100%) rename tools/ui/src/lib/constants/{binary-detection.ts => binary-detection.constants.ts} (100%) rename tools/ui/src/lib/constants/{built-in-tools.ts => built-in-tools.constants.ts} (82%) create mode 100644 tools/ui/src/lib/constants/cache.constants.ts delete mode 100644 tools/ui/src/lib/constants/cache.ts rename tools/ui/src/lib/constants/{chat-form.ts => chat-form.constants.ts} (100%) rename tools/ui/src/lib/constants/{cli-flags.ts => cli-flags.constants.ts} (100%) create mode 100644 tools/ui/src/lib/constants/code-block.constants.ts delete mode 100644 tools/ui/src/lib/constants/code-blocks.ts delete mode 100644 tools/ui/src/lib/constants/code.ts create mode 100644 tools/ui/src/lib/constants/content-detection.constants.ts rename tools/ui/src/lib/constants/{context-gauge-popup.ts => context-gauge-popup.constants.ts} (100%) rename tools/ui/src/lib/constants/{context-keys.ts => context-keys.constants.ts} (100%) rename tools/ui/src/lib/constants/{control-actions.ts => control-actions.constants.ts} (73%) rename tools/ui/src/lib/constants/{conversation-import.ts => conversation-import.constants.ts} (100%) rename tools/ui/src/lib/constants/{css-classes.ts => css-classes.constants.ts} (100%) rename tools/ui/src/lib/constants/{database.ts => database.constants.ts} (93%) rename tools/ui/src/lib/constants/{diagram-blocks.ts => diagram-blocks.constants.ts} (100%) rename tools/ui/src/lib/constants/{error.ts => error.constants.ts} (100%) delete mode 100644 tools/ui/src/lib/constants/floating-ui-constraints.ts rename tools/ui/src/lib/constants/{formatters.ts => formatters.constants.ts} (100%) create mode 100644 tools/ui/src/lib/constants/headers.constants.ts rename tools/ui/src/lib/constants/{icons.ts => icons.constants.ts} (100%) delete mode 100644 tools/ui/src/lib/constants/image-size.ts create mode 100644 tools/ui/src/lib/constants/image.constants.ts delete mode 100644 tools/ui/src/lib/constants/jpeg-exif.ts rename tools/ui/src/lib/constants/{key-value-pairs.ts => key-value-pairs.constants.ts} (100%) rename tools/ui/src/lib/constants/{latex-protection.ts => latex-protection.constants.ts} (100%) rename tools/ui/src/lib/constants/{literal-html.ts => literal-html.constants.ts} (56%) create mode 100644 tools/ui/src/lib/constants/markdown.constants.ts delete mode 100644 tools/ui/src/lib/constants/markdown.ts rename tools/ui/src/lib/constants/{max-bundle-size.ts => max-bundle-size.constants.ts} (100%) rename tools/ui/src/lib/constants/{mcp-form.ts => mcp-form.constants.ts} (100%) rename tools/ui/src/lib/constants/{mcp-resource.ts => mcp-resource.constants.ts} (100%) rename tools/ui/src/lib/constants/{mcp.ts => mcp.constants.ts} (57%) rename tools/ui/src/lib/constants/{mention-badge.ts => mention-badge.constants.ts} (100%) rename tools/ui/src/lib/constants/{mermaid-blocks.ts => mermaid-blocks.constants.ts} (100%) create mode 100644 tools/ui/src/lib/constants/message-export.constants.ts delete mode 100644 tools/ui/src/lib/constants/message-export.ts create mode 100644 tools/ui/src/lib/constants/model-id.constants.ts delete mode 100644 tools/ui/src/lib/constants/model-id.ts rename tools/ui/src/lib/constants/{model-loading.ts => model-loading.constants.ts} (100%) rename tools/ui/src/lib/constants/{path-display.ts => path-display.constants.ts} (100%) rename tools/ui/src/lib/constants/{precision.ts => precision.constants.ts} (100%) delete mode 100644 tools/ui/src/lib/constants/processing-info.ts rename tools/ui/src/lib/constants/{pwa.ts => pwa.constants.ts} (99%) delete mode 100644 tools/ui/src/lib/constants/reasoning-effort-tokens.ts rename tools/ui/src/lib/constants/{reasoning-effort.ts => reasoning-effort.constants.ts} (71%) rename tools/ui/src/lib/constants/{recommended-mcp-servers.ts => recommended-mcp-servers.constants.ts} (100%) rename tools/ui/src/lib/constants/{routes.ts => routes.constants.ts} (100%) create mode 100644 tools/ui/src/lib/constants/sandbox.constants.ts rename tools/ui/src/lib/constants/{settings-keys.ts => settings-keys.constants.ts} (100%) rename tools/ui/src/lib/constants/{settings-registry.ts => settings-registry.constants.ts} (97%) create mode 100644 tools/ui/src/lib/constants/special-characters.constants.ts rename tools/ui/src/lib/constants/{storage.ts => storage.constants.ts} (100%) rename tools/ui/src/lib/constants/{sse.ts => stream.constants.ts} (53%) delete mode 100644 tools/ui/src/lib/constants/stream.ts rename tools/ui/src/lib/constants/{supported-file-types.ts => supported-file-types.constants.ts} (100%) create mode 100644 tools/ui/src/lib/constants/svg-blocks.constants.ts delete mode 100644 tools/ui/src/lib/constants/svg-blocks.ts rename tools/ui/src/lib/constants/{table-html-restorer.ts => table-html-restorer.constants.ts} (100%) rename tools/ui/src/lib/constants/{title-generation.ts => title-generation.constants.ts} (100%) delete mode 100644 tools/ui/src/lib/constants/tools.ts delete mode 100644 tools/ui/src/lib/constants/tooltip-config.ts rename tools/ui/src/lib/constants/{ui.ts => ui.constants.ts} (57%) rename tools/ui/src/lib/constants/{uri-template.ts => uri-template.constants.ts} (71%) rename tools/ui/src/lib/constants/{url.ts => url.constants.ts} (100%) delete mode 100644 tools/ui/src/lib/constants/viewport.ts create mode 100644 tools/ui/src/lib/constants/working-directory.constants.ts delete mode 100644 tools/ui/src/lib/constants/working-directory.ts create mode 100644 tools/ui/src/lib/types/navigation.d.ts create mode 100644 tools/ui/src/lib/utils/built-in-tools.ts rename tools/ui/src/lib/{constants => utils}/chat-commands.ts (65%) rename tools/ui/src/lib/{constants/sandbox.ts => utils/sandbox-tool.ts} (84%) diff --git a/tools/ui/pwa-assets-dark.config.ts b/tools/ui/pwa-assets-dark.config.ts index 1446b47a8..4d8114ee7 100644 --- a/tools/ui/pwa-assets-dark.config.ts +++ b/tools/ui/pwa-assets-dark.config.ts @@ -1,5 +1,5 @@ import { writeThemeFavicons } from './scripts/favicon-colorize'; -import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa'; +import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa.constants'; import { defineConfig } from '@vite-pwa/assets-generator/config'; writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, { diff --git a/tools/ui/pwa-assets.config.ts b/tools/ui/pwa-assets.config.ts index 5fed0a595..f9f8662a2 100644 --- a/tools/ui/pwa-assets.config.ts +++ b/tools/ui/pwa-assets.config.ts @@ -4,7 +4,7 @@ import { PWA_ASSET_GENERATOR, PWA_GENERATOR_DEVICES, THEME_COLORS -} from './src/lib/constants/pwa'; +} from './src/lib/constants/pwa.constants'; import { SplashOrientation } from './src/lib/enums/splash.enums'; import { combinePresetAndAppleSplashScreens, diff --git a/tools/ui/scripts/vite-plugin-build-info.ts b/tools/ui/scripts/vite-plugin-build-info.ts index 800238630..ec864e8d0 100644 --- a/tools/ui/scripts/vite-plugin-build-info.ts +++ b/tools/ui/scripts/vite-plugin-build-info.ts @@ -1,4 +1,4 @@ -import { BUILD_CONFIG } from '../src/lib/constants/pwa'; +import { BUILD_CONFIG } from '../src/lib/constants/pwa.constants'; import { existsSync, writeFileSync } from 'node:fs'; import { resolve } from 'path'; import type { Plugin } from 'vite'; diff --git a/tools/ui/scripts/vite-plugin-relativize-base.ts b/tools/ui/scripts/vite-plugin-relativize-base.ts index f8eac1d66..0e47741ae 100644 --- a/tools/ui/scripts/vite-plugin-relativize-base.ts +++ b/tools/ui/scripts/vite-plugin-relativize-base.ts @@ -1,4 +1,4 @@ -import { BUILD_CONFIG } from '../src/lib/constants/pwa'; +import { BUILD_CONFIG } from '../src/lib/constants/pwa.constants'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'path'; import type { Plugin } from 'vite'; diff --git a/tools/ui/scripts/vite-plugin-splash-screen.ts b/tools/ui/scripts/vite-plugin-splash-screen.ts index 45d931bab..62b7a063a 100644 --- a/tools/ui/scripts/vite-plugin-splash-screen.ts +++ b/tools/ui/scripts/vite-plugin-splash-screen.ts @@ -1,5 +1,10 @@ -import { NEWLINE, TAB } from '../src/lib/constants/code'; -import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa'; +import { + APPLE_DEVICES, + BUILD_CONFIG, + REGEX_PATTERNS, + SPLASH_LINK +} from '../src/lib/constants/pwa.constants'; +import { NEWLINE, TAB } from '../src/lib/constants/special-characters.constants'; import { SplashOrientation } from '../src/lib/enums/splash.enums'; import type { SplashDimensions } from '../src/lib/types'; import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; diff --git a/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte b/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte index 7655f18e8..2d54df89d 100644 --- a/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte +++ b/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte @@ -1,7 +1,7 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte index ae5d91bee..cae37d6f8 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte @@ -1,7 +1,7 @@ diff --git a/tools/ui/src/lib/constants/agentic.ts b/tools/ui/src/lib/constants/agentic.constants.ts similarity index 77% rename from tools/ui/src/lib/constants/agentic.ts rename to tools/ui/src/lib/constants/agentic.constants.ts index 582572a29..e57104e8a 100644 --- a/tools/ui/src/lib/constants/agentic.ts +++ b/tools/ui/src/lib/constants/agentic.constants.ts @@ -5,22 +5,14 @@ export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/; // JSON detection: trimmed content opens with an object or array literal. export const TOOL_RESULT_JSON_OPEN_REGEX = /^[[{]/; -// Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level. -export const MARKDOWN_CODE_FENCE_REGEX = /^(```|~~~)/m; -export const MARKDOWN_ATX_HEADING_REGEX = /^#{1,6}\s+\S/; -export const MARKDOWN_BLOCKQUOTE_REGEX = /^>\s+\S/; -export const MARKDOWN_LIST_BULLET_REGEX = /^\s*[-*+]\s+\S/; -export const MARKDOWN_LIST_NUMBERED_REGEX = /^\s*\d+[.)]\s+\S/; -export const MARKDOWN_LINK_REGEX = /\[[^\]\n]+\]\([^)\s]+\)/; -export const MARKDOWN_BOLD_REGEX = /\*\*[^*\n]+\*\*|__[^_\n]+__/; -export const MARKDOWN_TABLE_SEPARATOR_REGEX = /^\s*\|?[\s:|-]+\|?\s*$/; - // Search-summary wire format used by file-glob and grep tools: // // --- // Total matches: N -export const SEARCH_SUMMARY_SEPARATOR = '---\n'; -export const SEARCH_SUMMARY_TOTAL_REGEX = /Total matches:\s*(\d+)/; +export const SEARCH_SUMMARY = { + SEPARATOR: '---\n', + TOTAL_REGEX: /Total matches:\s*(\d+)/ +} as const; // Separator rendered between stats in the tool-result footer (e.g. between a // result message and the byte/edit count). Plain ASCII spaces bracket a hyphen diff --git a/tools/ui/src/lib/constants/api-endpoints.ts b/tools/ui/src/lib/constants/api-endpoints.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/api-endpoints.ts rename to tools/ui/src/lib/constants/api-endpoints.constants.ts diff --git a/tools/ui/src/lib/constants/app.ts b/tools/ui/src/lib/constants/app.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/app.ts rename to tools/ui/src/lib/constants/app.constants.ts diff --git a/tools/ui/src/lib/constants/attachment-labels.ts b/tools/ui/src/lib/constants/attachment-labels.ts deleted file mode 100644 index be9999c0f..000000000 --- a/tools/ui/src/lib/constants/attachment-labels.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const ATTACHMENT_LABEL_FILE = 'File'; -export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File'; -export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt'; -export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource'; diff --git a/tools/ui/src/lib/constants/attachment-menu.ts b/tools/ui/src/lib/constants/attachment-menu.constants.ts similarity index 75% rename from tools/ui/src/lib/constants/attachment-menu.ts rename to tools/ui/src/lib/constants/attachment-menu.constants.ts index 1cd7f9ba5..62e03bea6 100644 --- a/tools/ui/src/lib/constants/attachment-menu.ts +++ b/tools/ui/src/lib/constants/attachment-menu.constants.ts @@ -1,33 +1,12 @@ import { FolderOpen, MessageSquare, Zap } from '@lucide/svelte'; -import { FILE_TYPE_ICONS } from '$lib/constants/icons'; +import { FILE_TYPE_ICONS } from '$lib/constants'; import { AttachmentAction, AttachmentItemEnabledWhen, AttachmentItemVisibleWhen, AttachmentMenuItemId } from '$lib/enums'; -import type { Component } from 'svelte'; - -export interface AttachmentMenuItem { - /** Unique identifier for the item */ - id: AttachmentMenuItemId; - /** Display label */ - label: string; - /** Lucide icon component */ - icon: Component; - /** Extra CSS class applied to the item (e.g. for test selectors) */ - class?: string; - /** Whether the item requires a specific modality to be enabled */ - enabledWhen?: AttachmentItemEnabledWhen; - /** Tooltip shown when the item is disabled */ - disabledTooltip?: string; - /** Callback key on the Props interface to invoke when clicked */ - action: AttachmentAction; - /** Whether the item is only shown when a specific capability is present */ - visibleWhen?: AttachmentItemVisibleWhen; - /** Whether this item has a tooltip even when enabled (uses dynamic text) */ - hasEnabledTooltip?: boolean; -} +import type { AttachmentMenuItem } from '$lib/types'; /** * File attachment menu items shown in both the desktop dropdown and mobile sheet. diff --git a/tools/ui/src/lib/constants/auto-scroll.ts b/tools/ui/src/lib/constants/auto-scroll.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/auto-scroll.ts rename to tools/ui/src/lib/constants/auto-scroll.constants.ts diff --git a/tools/ui/src/lib/constants/binary-detection.ts b/tools/ui/src/lib/constants/binary-detection.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/binary-detection.ts rename to tools/ui/src/lib/constants/binary-detection.constants.ts diff --git a/tools/ui/src/lib/constants/built-in-tools.ts b/tools/ui/src/lib/constants/built-in-tools.constants.ts similarity index 82% rename from tools/ui/src/lib/constants/built-in-tools.ts rename to tools/ui/src/lib/constants/built-in-tools.constants.ts index 5bd24ffe8..679c61459 100644 --- a/tools/ui/src/lib/constants/built-in-tools.ts +++ b/tools/ui/src/lib/constants/built-in-tools.constants.ts @@ -20,13 +20,7 @@ import { Terminal } from '@lucide/svelte'; import { BuiltInTool, ToolSource } from '$lib/enums'; -import type { Component } from 'svelte'; - -export interface BuiltinToolUiEntry { - icon: Component; - label: string; - source: ToolSource.BUILTIN | ToolSource.FRONTEND; -} +import type { BuiltinToolUiEntry } from '$lib/types'; export const BUILTIN_TOOL_UI: Readonly> = { [BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN }, @@ -56,9 +50,3 @@ export const BUILTIN_TOOL_UI: Readonly> }, [BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN } } as const; - -export function getBuiltinToolUi(toolName: string | undefined): BuiltinToolUiEntry | null { - if (!toolName) return null; - - return (BUILTIN_TOOL_UI as Record)[toolName] ?? null; -} diff --git a/tools/ui/src/lib/constants/cache.constants.ts b/tools/ui/src/lib/constants/cache.constants.ts new file mode 100644 index 000000000..b60792d99 --- /dev/null +++ b/tools/ui/src/lib/constants/cache.constants.ts @@ -0,0 +1,44 @@ +/** + * Cache configuration constants + */ + +/** + * Default cache limits when no per-cache overrides are given. + */ +export const CACHE = { + /** Default maximum number of entries in a cache */ + DEFAULT_MAX_ENTRIES: 100, + /** Default TTL (Time-To-Live) for cache entries in milliseconds (5 minutes) */ + DEFAULT_TTL_MS: 5 * 60 * 1000 +} as const; + +/** + * TTL and size for the model props cache. + * Props don't change frequently, so we can cache them longer. + */ +export const MODEL_PROPS_CACHE = { + /** Maximum number of model props to cache */ + MAX_ENTRIES: 50, + /** TTL for model props cache entries in milliseconds (10 minutes) */ + TTL_MS: 10 * 60 * 1000 +} as const; + +/** + * TTL and size for the MCP resource cache. + */ +export const MCP_RESOURCE_CACHE = { + /** Maximum number of MCP resources to cache */ + MAX_ENTRIES: 50, + /** TTL for MCP resource cache entries in milliseconds (5 minutes) */ + TTL_MS: 5 * 60 * 1000 +} as const; + +/** + * Limits for pruning inactive conversation states held in memory. + */ +export const INACTIVE_CONVERSATION = { + /** Maximum age (in ms) for inactive conversation states before cleanup (30 minutes) */ + MAX_AGE_MS: 30 * 60 * 1000, + /** Maximum number of inactive conversation states to keep in memory */ + MAX_STATES: 10 +} as const; diff --git a/tools/ui/src/lib/constants/cache.ts b/tools/ui/src/lib/constants/cache.ts deleted file mode 100644 index 07fe86834..000000000 --- a/tools/ui/src/lib/constants/cache.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Cache configuration constants - */ - -/** - * Default TTL (Time-To-Live) for cache entries in milliseconds - * @default 5 minutes - */ -export const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; - -/** - * Default maximum number of entries in a cache - * @default 100 - */ -export const DEFAULT_CACHE_MAX_ENTRIES = 100; - -/** - * TTL for model props cache in milliseconds - * Props don't change frequently, so we can cache them longer - * @default 10 minutes - */ -export const MODEL_PROPS_CACHE_TTL_MS = 10 * 60 * 1000; - -/** - * Maximum number of model props to cache - * @default 50 - */ -export const MODEL_PROPS_CACHE_MAX_ENTRIES = 50; - -/** - * Maximum number of MCP resources to cache - * @default 50 - */ -export const MCP_RESOURCE_CACHE_MAX_ENTRIES = 50; - -/** - * TTL for MCP resource cache entries in milliseconds - * @default 5 minutes - */ -export const MCP_RESOURCE_CACHE_TTL_MS = 5 * 60 * 1000; - -/** - * Maximum number of inactive conversation states to keep in memory - * States for conversations beyond this limit will be cleaned up - * @default 10 - */ -export const MAX_INACTIVE_CONVERSATION_STATES = 10; - -/** - * Maximum age (in ms) for inactive conversation states before cleanup - * States older than this will be removed during cleanup - * @default 30 minutes - */ -export const INACTIVE_CONVERSATION_STATE_MAX_AGE_MS = 30 * 60 * 1000; diff --git a/tools/ui/src/lib/constants/chat-form.ts b/tools/ui/src/lib/constants/chat-form.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/chat-form.ts rename to tools/ui/src/lib/constants/chat-form.constants.ts diff --git a/tools/ui/src/lib/constants/cli-flags.ts b/tools/ui/src/lib/constants/cli-flags.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/cli-flags.ts rename to tools/ui/src/lib/constants/cli-flags.constants.ts diff --git a/tools/ui/src/lib/constants/code-block.constants.ts b/tools/ui/src/lib/constants/code-block.constants.ts new file mode 100644 index 000000000..05db575f9 --- /dev/null +++ b/tools/ui/src/lib/constants/code-block.constants.ts @@ -0,0 +1,50 @@ +// Constants for the markdown code-block renderer: language/fence handling and CSS classes. + +/** Parsing and escaping helpers for the markdown code-block renderer. */ +export const CODE_BLOCK = { + AMPERSAND_REGEX: /&/g, + /** Language fallback used when no language is specified. */ + DEFAULT_LANGUAGE: 'text', + /** Matches opening/closing markdown code fences. */ + FENCE_PATTERN: /^```|\n```/g, + GT_REGEX: />/g, + /** Matches the language specifier at the start of a code fence. */ + LANG_PATTERN: /^(\w*)\n?/, + LT_REGEX: //g; -export const FENCE_PATTERN = /^```|\n```/g; - -// Whitespace-only empty lines (between start of string and first non-empty line). -// Used by trimCodePadding to drop leading/trailing phantom blank rows from LLM -// payload wrappers without touching internal blank lines. -export const TRIM_LEADING_PADDING_REGEX = /^(?:[ \t]*\n)+/; -export const TRIM_TRAILING_PADDING_REGEX = /(?:\n[ \t]*)+$/; - -// Matches either Unix or Windows path separators so `String.split(REGEX)` can -// recover the trailing file-name segment from either `/foo/bar.txt` or -// `C:\foo\bar.txt`. Used wherever a parameter accepts a user-supplied path. -export const FILE_PATH_SEPARATOR_REGEX = /[\\/]/; - -// Separates a file name from its extension, e.g. the '.' in `cover.png`. -export const FILE_EXTENSION_SEPARATOR = '.'; - -// Matches the `text:` prefix that file-type identifiers use to denote a -// plain-text language (e.g. `text:typescript`). Used by tool-call renderers -// to recover the underlying highlight.js language. -export const TEXT_LANGUAGE_PREFIX_REGEX = /^text:/; diff --git a/tools/ui/src/lib/constants/content-detection.constants.ts b/tools/ui/src/lib/constants/content-detection.constants.ts new file mode 100644 index 000000000..c5c05819a --- /dev/null +++ b/tools/ui/src/lib/constants/content-detection.constants.ts @@ -0,0 +1,20 @@ +/** + * String patterns for detecting content kind from MIME types and URIs. + * Used with startsWith/includes checks, not as discriminated values. + */ + +export const MIME_TYPE_PREFIXES = { + IMAGE: 'image/', + TEXT: 'text' +} as const; + +export const MIME_TYPE_SUBSTRINGS = { + JAVASCRIPT: 'javascript', + JSON: 'json', + TYPESCRIPT: 'typescript' +} as const; + +export const URI_PATTERNS = { + DATABASE_KEYWORD: 'database', + DATABASE_SCHEME: 'db://' +} as const; diff --git a/tools/ui/src/lib/constants/context-gauge-popup.ts b/tools/ui/src/lib/constants/context-gauge-popup.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/context-gauge-popup.ts rename to tools/ui/src/lib/constants/context-gauge-popup.constants.ts diff --git a/tools/ui/src/lib/constants/context-keys.ts b/tools/ui/src/lib/constants/context-keys.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/context-keys.ts rename to tools/ui/src/lib/constants/context-keys.constants.ts diff --git a/tools/ui/src/lib/constants/control-actions.ts b/tools/ui/src/lib/constants/control-actions.constants.ts similarity index 73% rename from tools/ui/src/lib/constants/control-actions.ts rename to tools/ui/src/lib/constants/control-actions.constants.ts index 935ae9542..c8ebf701b 100644 --- a/tools/ui/src/lib/constants/control-actions.ts +++ b/tools/ui/src/lib/constants/control-actions.constants.ts @@ -3,5 +3,3 @@ export const CONTROL_ACTION = { END_REASONING: 'reasoning_end' } as const; - -export type ControlAction = (typeof CONTROL_ACTION)[keyof typeof CONTROL_ACTION]; diff --git a/tools/ui/src/lib/constants/conversation-import.ts b/tools/ui/src/lib/constants/conversation-import.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/conversation-import.ts rename to tools/ui/src/lib/constants/conversation-import.constants.ts diff --git a/tools/ui/src/lib/constants/css-classes.ts b/tools/ui/src/lib/constants/css-classes.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/css-classes.ts rename to tools/ui/src/lib/constants/css-classes.constants.ts diff --git a/tools/ui/src/lib/constants/database.ts b/tools/ui/src/lib/constants/database.constants.ts similarity index 93% rename from tools/ui/src/lib/constants/database.ts rename to tools/ui/src/lib/constants/database.constants.ts index 95e698f40..f2c961039 100644 --- a/tools/ui/src/lib/constants/database.ts +++ b/tools/ui/src/lib/constants/database.constants.ts @@ -5,7 +5,7 @@ * naming changes. */ -import { STORAGE_APP_NAME } from './storage'; +import { STORAGE_APP_NAME } from './storage.constants'; /** IndexedDB database name */ export const DB_NAME = STORAGE_APP_NAME; diff --git a/tools/ui/src/lib/constants/diagram-blocks.ts b/tools/ui/src/lib/constants/diagram-blocks.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/diagram-blocks.ts rename to tools/ui/src/lib/constants/diagram-blocks.constants.ts diff --git a/tools/ui/src/lib/constants/error.ts b/tools/ui/src/lib/constants/error.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/error.ts rename to tools/ui/src/lib/constants/error.constants.ts diff --git a/tools/ui/src/lib/constants/floating-ui-constraints.ts b/tools/ui/src/lib/constants/floating-ui-constraints.ts deleted file mode 100644 index 003fc77ac..000000000 --- a/tools/ui/src/lib/constants/floating-ui-constraints.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VIEWPORT_GUTTER = 8; -export const MENU_OFFSET = 6; diff --git a/tools/ui/src/lib/constants/formatters.ts b/tools/ui/src/lib/constants/formatters.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/formatters.ts rename to tools/ui/src/lib/constants/formatters.constants.ts diff --git a/tools/ui/src/lib/constants/headers.constants.ts b/tools/ui/src/lib/constants/headers.constants.ts new file mode 100644 index 000000000..f40df0571 --- /dev/null +++ b/tools/ui/src/lib/constants/headers.constants.ts @@ -0,0 +1,35 @@ +/** HTTP header handling for API and MCP requests. */ +export const HEADERS = { + /** Canonical casing for the Authorization header (RFC 7235) */ + AUTHORIZATION: 'Authorization', + /** Bearer scheme prefix used for Authorization headers (RFC 6750) */ + BEARER: 'Bearer ', + /** Content-Type HTTP header name */ + CONTENT_TYPE: 'Content-Type', + /** Partial-redaction rules for MCP headers: header name -> visible trailing chars */ + PARTIAL_REDACT: new Map([['mcp-session-id', 5]]), + + /** Header names whose values should be redacted in diagnostic logs */ + REDACTED: new Set([ + 'authorization', + 'api-key', + 'cookie', + 'mcp-session-id', + 'proxy-authorization', + 'set-cookie', + 'x-auth-token', + 'x-api-key' + ]), + + /** Header carrying the stream-session identity (conversation id, optionally with a model suffix) */ + X_CONVERSATION_ID_HEADER: 'X-Conversation-Id', + + /** Header asking the server to encode a tool's output differently, e.g. read_file returning base64. */ + X_RESP_TYPE_HEADER: 'x-resp-type', + + /** Header carrying the working directory a tool call runs in; the model cannot override it */ + X_TOOL_CWD_HEADER: 'x-tool-cwd' +}; + +/** `X_RESP_TYPE_HEADER` value that makes read_file return raw bytes as base64 instead of text. */ +export const RESP_TYPE_BASE64 = 'base64'; diff --git a/tools/ui/src/lib/constants/icons.ts b/tools/ui/src/lib/constants/icons.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/icons.ts rename to tools/ui/src/lib/constants/icons.constants.ts diff --git a/tools/ui/src/lib/constants/image-size.ts b/tools/ui/src/lib/constants/image-size.ts deleted file mode 100644 index 8a7f921fa..000000000 --- a/tools/ui/src/lib/constants/image-size.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const MEGAPIXELS_TO_PIXELS = 1_000_000; - -export const HEIC_JPEG_QUALITY = 0.85; diff --git a/tools/ui/src/lib/constants/image.constants.ts b/tools/ui/src/lib/constants/image.constants.ts new file mode 100644 index 000000000..53a90eaa4 --- /dev/null +++ b/tools/ui/src/lib/constants/image.constants.ts @@ -0,0 +1,32 @@ +/** Image handling constants */ + +export const IMAGE = { + /** JPEG quality used when transcoding HEIC images. */ + HEIC_JPEG_QUALITY: 0.85, + /** Unit conversion: pixels per megapixel. */ + MEGAPIXELS_TO_PIXELS: 1_000_000 +} as const; + +/** + * JPEG and EXIF binary format constants for orientation parsing. + */ +export const EXIF = { + /** APP1 segment marker byte, carries the EXIF payload */ + APP1_MARKER: 0xe1, + /** "Exif" signature opening the APP1 payload, big endian uint32 */ + EXIF_SIGNATURE: 0x45786966, + /** Size in bytes of one IFD directory entry */ + IFD_ENTRY_SIZE: 12, + /** JPEG start of image marker */ + JPEG_SOI_MARKER: 0xffd8, + /** EXIF tag id holding the orientation value */ + ORIENTATION_TAG: 0x0112, + /** Bytes of file prefix to scan, the APP1 EXIF segment sits near the start */ + SCAN_BYTE_LIMIT: 128 * 1024, + /** Start of scan marker byte, compressed data begins and no EXIF follows */ + SOS_MARKER: 0xda, + /** TIFF byte order mark for little endian ("II") */ + TIFF_LITTLE_ENDIAN: 0x4949, + /** TIFF magic number following the byte order mark */ + TIFF_MAGIC: 42 +} as const; diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts index 357a33a62..17e5f4d00 100644 --- a/tools/ui/src/lib/constants/index.ts +++ b/tools/ui/src/lib/constants/index.ts @@ -1,67 +1,61 @@ // Central constants export file // All constants should be imported from '$lib/constants' -export * from './agentic'; -export * from './api-endpoints'; -export * from './app'; -export * from './attachment-labels'; -export * from './database'; -export * from './reasoning-effort'; -export * from './reasoning-effort-tokens'; -export * from './recommended-mcp-servers'; -export * from './storage'; -export * from './attachment-menu'; -export * from './auto-scroll'; -export * from './context-gauge-popup'; -export * from './conversation-import'; -export * from './binary-detection'; -export * from './built-in-tools'; -export * from './cache'; -export * from './chat-form'; -export * from './chat-commands'; -export * from './cli-flags'; -export * from './code-blocks'; -export * from './icons'; -export * from './code'; -export * from './context-keys'; -export * from './control-actions'; -export * from './css-classes'; -export * from './floating-ui-constraints'; -export * from './formatters'; -export * from './key-value-pairs'; -export * from './icons'; -export * from './latex-protection'; -export * from './literal-html'; -export * from './markdown'; -export * from './mermaid-blocks'; -export * from './svg-blocks'; -export * from './diagram-blocks'; -export * from './max-bundle-size'; -export * from './mcp'; -export * from './mcp-form'; -export * from './mcp-resource'; -export * from './mention-badge'; -export * from './message-export'; -export * from './path-display'; -export * from './model-id'; -export * from './model-loading'; -export * from './sse'; -export * from './precision'; -export * from './processing-info'; -export * from './pwa'; +export * from './agentic.constants'; +export * from './api-endpoints.constants'; +export * from './app.constants'; +export * from './database.constants'; +export * from './reasoning-effort.constants'; +export * from './recommended-mcp-servers.constants'; +export * from './storage.constants'; +export * from './icons.constants'; +export * from './attachment-menu.constants'; +export * from './auto-scroll.constants'; +export * from './context-gauge-popup.constants'; +export * from './conversation-import.constants'; +export * from './binary-detection.constants'; +export * from './content-detection.constants'; +export * from './built-in-tools.constants'; +export * from './cache.constants'; +export * from './chat-form.constants'; +export * from './cli-flags.constants'; +export * from './code-block.constants'; +export * from './context-keys.constants'; +export * from './control-actions.constants'; +export * from './css-classes.constants'; +export * from './formatters.constants'; +export * from './headers.constants'; +export * from './key-value-pairs.constants'; +export * from './latex-protection.constants'; +export * from './literal-html.constants'; +export * from './markdown.constants'; +export * from './mermaid-blocks.constants'; +export * from './svg-blocks.constants'; +export * from './diagram-blocks.constants'; +export * from './max-bundle-size.constants'; +export * from './error.constants'; +export * from './image.constants'; +export * from './mcp.constants'; +export * from './mcp-form.constants'; +export * from './mcp-resource.constants'; +export * from './mention-badge.constants'; +export * from './message-export.constants'; +export * from './path-display.constants'; +export * from './model-id.constants'; +export * from './model-loading.constants'; +export * from './precision.constants'; +export * from './pwa.constants'; +export * from './routes.constants'; +export * from './sandbox.constants'; +export * from './settings-keys.constants'; +export * from './settings-registry.constants'; +export * from './special-characters.constants'; +export * from './stream.constants'; +export * from './supported-file-types.constants'; +export * from './table-html-restorer.constants'; +export * from './title-generation.constants'; +export * from './ui.constants'; +export * from './uri-template.constants'; +export * from './url.constants'; +export * from './working-directory.constants'; export * from './read-media'; -export * from './routes'; -export * from './sandbox'; -export * from './settings-keys'; -export * from './settings-registry'; -export * from './stream'; -export * from './supported-file-types'; -export * from './table-html-restorer'; -export * from './title-generation'; -export * from './tools'; -export * from './tooltip-config'; -export * from './ui'; -export * from './uri-template'; -export * from './url'; -export * from './viewport'; -export * from './working-directory'; diff --git a/tools/ui/src/lib/constants/jpeg-exif.ts b/tools/ui/src/lib/constants/jpeg-exif.ts deleted file mode 100644 index 5b2591b04..000000000 --- a/tools/ui/src/lib/constants/jpeg-exif.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * JPEG and EXIF binary format constants for orientation parsing. - */ - -/** Bytes of file prefix to scan, the APP1 EXIF segment sits near the start */ -export const EXIF_SCAN_BYTE_LIMIT = 128 * 1024; - -/** JPEG start of image marker */ -export const JPEG_SOI_MARKER = 0xffd8; - -/** APP1 segment marker byte, carries the EXIF payload */ -export const APP1_MARKER = 0xe1; - -/** Start of scan marker byte, compressed data begins and no EXIF follows */ -export const SOS_MARKER = 0xda; - -/** "Exif" signature opening the APP1 payload, big endian uint32 */ -export const EXIF_SIGNATURE = 0x45786966; - -/** TIFF byte order mark for little endian ("II") */ -export const TIFF_LITTLE_ENDIAN = 0x4949; - -/** TIFF magic number following the byte order mark */ -export const TIFF_MAGIC = 42; - -/** EXIF tag id holding the orientation value */ -export const EXIF_ORIENTATION_TAG = 0x0112; - -/** Size in bytes of one IFD directory entry */ -export const IFD_ENTRY_SIZE = 12; diff --git a/tools/ui/src/lib/constants/key-value-pairs.ts b/tools/ui/src/lib/constants/key-value-pairs.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/key-value-pairs.ts rename to tools/ui/src/lib/constants/key-value-pairs.constants.ts diff --git a/tools/ui/src/lib/constants/latex-protection.ts b/tools/ui/src/lib/constants/latex-protection.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/latex-protection.ts rename to tools/ui/src/lib/constants/latex-protection.constants.ts diff --git a/tools/ui/src/lib/constants/literal-html.ts b/tools/ui/src/lib/constants/literal-html.constants.ts similarity index 56% rename from tools/ui/src/lib/constants/literal-html.ts rename to tools/ui/src/lib/constants/literal-html.constants.ts index ed1b0cf0d..8efa6b574 100644 --- a/tools/ui/src/lib/constants/literal-html.ts +++ b/tools/ui/src/lib/constants/literal-html.constants.ts @@ -1,5 +1,3 @@ -export const LINE_BREAK = /\r?\n/; - export const PHRASE_PARENTS = new Set([ 'paragraph', 'heading', @@ -10,6 +8,3 @@ export const PHRASE_PARENTS = new Set([ 'linkReference', 'tableCell' ]); - -export const NBSP = '\u00a0'; -export const TAB_AS_SPACES = NBSP.repeat(4); diff --git a/tools/ui/src/lib/constants/markdown.constants.ts b/tools/ui/src/lib/constants/markdown.constants.ts new file mode 100644 index 000000000..c8289c39a --- /dev/null +++ b/tools/ui/src/lib/constants/markdown.constants.ts @@ -0,0 +1,17 @@ +export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])'; +export const DATA_ERROR_BOUND_ATTR = 'errorBound'; +export const DATA_ERROR_HANDLED_ATTR = 'errorHandled'; +export const BOOL_TRUE_STRING = 'true'; +export const BOOL_FALSE_STRING = 'false'; + +/** Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level. */ +export const MARKDOWN = { + ATX_HEADING_REGEX: /^#{1,6}\s+\S/, + BLOCKQUOTE_REGEX: /^>\s+\S/, + BOLD_REGEX: /\*\*[^*\n]+\*\*|__[^_\n]+__/, + CODE_FENCE_REGEX: /^(```|~~~)/m, + LINK_REGEX: /\[[^\]\n]+\]\([^)\s]+\)/, + LIST_BULLET_REGEX: /^\s*[-*+]\s+\S/, + LIST_NUMBERED_REGEX: /^\s*\d+[.)]\s+\S/, + TABLE_SEPARATOR_REGEX: /^\s*\|?[\s:|-]+\|?\s*$/ +} as const; diff --git a/tools/ui/src/lib/constants/markdown.ts b/tools/ui/src/lib/constants/markdown.ts deleted file mode 100644 index 1cace78a3..000000000 --- a/tools/ui/src/lib/constants/markdown.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])'; -export const DATA_ERROR_BOUND_ATTR = 'errorBound'; -export const DATA_ERROR_HANDLED_ATTR = 'errorHandled'; -export const BOOL_TRUE_STRING = 'true'; -export const BOOL_FALSE_STRING = 'false'; diff --git a/tools/ui/src/lib/constants/max-bundle-size.ts b/tools/ui/src/lib/constants/max-bundle-size.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/max-bundle-size.ts rename to tools/ui/src/lib/constants/max-bundle-size.constants.ts diff --git a/tools/ui/src/lib/constants/mcp-form.ts b/tools/ui/src/lib/constants/mcp-form.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/mcp-form.ts rename to tools/ui/src/lib/constants/mcp-form.constants.ts diff --git a/tools/ui/src/lib/constants/mcp-resource.ts b/tools/ui/src/lib/constants/mcp-resource.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/mcp-resource.ts rename to tools/ui/src/lib/constants/mcp-resource.constants.ts diff --git a/tools/ui/src/lib/constants/mcp.ts b/tools/ui/src/lib/constants/mcp.constants.ts similarity index 57% rename from tools/ui/src/lib/constants/mcp.ts rename to tools/ui/src/lib/constants/mcp.constants.ts index 854cbf9f7..11013d2cb 100644 --- a/tools/ui/src/lib/constants/mcp.ts +++ b/tools/ui/src/lib/constants/mcp.constants.ts @@ -36,11 +36,14 @@ export const DEFAULT_MCP_CONFIG = { export const MCP_SERVER_ID_PREFIX = 'LlamaUI-MCP-Server'; -export const MCP_RECONNECT_INITIAL_DELAY = 1000; -export const MCP_RECONNECT_BACKOFF_MULTIPLIER = 2; -export const MCP_RECONNECT_MAX_DELAY = 30000; -/** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */ -export const MCP_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000; +/** Backoff policy for reconnecting to a dropped MCP server. */ +export const MCP_RECONNECT = { + /** Per-attempt timeout for a single reconnection attempt before giving up and backing off. */ + ATTEMPT_TIMEOUT_MS: 15_000, + BACKOFF_MULTIPLIER: 2, + INITIAL_DELAY: 1000, + MAX_DELAY: 30000 +}; /** Maximum number of MCP server avatars to display in the chat form */ export const MAX_DISPLAYED_MCP_AVATARS = 4; @@ -48,40 +51,20 @@ export const MAX_DISPLAYED_MCP_AVATARS = 4; /** Expected count when two theme-less icons represent a light/dark pair */ export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2; -/** CORS proxy URL query parameter name */ -export const CORS_PROXY_URL_PARAM = 'url'; +/** CORS proxy connection settings */ +export const CORS_PROXY = { + /** Header prefix for headers that should be forwarded by the CORS proxy */ + HEADER_PREFIX: 'x-llama-server-proxy-header-', + /** CORS proxy URL query parameter name */ + URL_PARAM: 'url' +} as const; -/** Header prefix for headers that should be forwarded by the CORS proxy */ -export const CORS_PROXY_HEADER_PREFIX = 'x-llama-server-proxy-header-'; - -/** Number of trailing characters to keep visible when partially redacting mcp-session-id */ -export const MCP_SESSION_ID_VISIBLE_CHARS = 5; - -/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */ -export const MCP_PARTIAL_REDACT_HEADERS = new Map([ - ['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS] -]); - -/** Bearer scheme prefix used for Authorization headers (RFC 6750) */ -export const BEARER_PREFIX = 'Bearer '; - -/** Canonical casing for the Authorization header (RFC 7235) */ -export const AUTHORIZATION_HEADER = 'Authorization'; - -/** Content-Type HTTP header name */ -export const CONTENT_TYPE_HEADER = 'Content-Type'; - -/** Header names whose values should be redacted in diagnostic logs */ -export const REDACTED_HEADERS = new Set([ - 'authorization', - 'api-key', - 'cookie', - 'mcp-session-id', - 'proxy-authorization', - 'set-cookie', - 'x-auth-token', - 'x-api-key' -]); +/** Standard SSE endpoint path indicators */ +export const MCP_SSE = { + ENDPOINT: '/sse', + ENDPOINT_QUERY: '/sse?', + ENDPOINT_SLASH: '/sse/' +} as const; /** Human-readable labels for MCP transport types */ export const MCP_TRANSPORT_LABELS: Record = { @@ -96,8 +79,3 @@ export const MCP_TRANSPORT_ICONS: Record = { [MCPTransportType.STREAMABLE_HTTP]: Globe, [MCPTransportType.WEBSOCKET]: Zap }; - -/** Standard SSE endpoint path indicators */ -export const MCP_SSE_ENDPOINT = '/sse'; -export const MCP_SSE_ENDPOINT_SLASH = '/sse/'; -export const MCP_SSE_ENDPOINT_QUERY = '/sse?'; diff --git a/tools/ui/src/lib/constants/mention-badge.ts b/tools/ui/src/lib/constants/mention-badge.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/mention-badge.ts rename to tools/ui/src/lib/constants/mention-badge.constants.ts diff --git a/tools/ui/src/lib/constants/mermaid-blocks.ts b/tools/ui/src/lib/constants/mermaid-blocks.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/mermaid-blocks.ts rename to tools/ui/src/lib/constants/mermaid-blocks.constants.ts diff --git a/tools/ui/src/lib/constants/message-export.constants.ts b/tools/ui/src/lib/constants/message-export.constants.ts new file mode 100644 index 000000000..f6c576d79 --- /dev/null +++ b/tools/ui/src/lib/constants/message-export.constants.ts @@ -0,0 +1,24 @@ +// Conversation exporter / filename constants + +export const EXPORT_CONV = { + // Producer marker carried by the session record of a JSONL export + HARNESS: 'llama.app', + // Length of the trimmed conversation ID in the filename + ID_TRIM_LENGTH: 8, + // Replacements to the ISO date for use in the export filename + ISO_DATE_TIME_SEPARATOR: 'T', + + ISO_DATE_TIME_SEPARATOR_REPLACEMENT: '_', + + ISO_TIME_SEPARATOR: ':', + ISO_TIME_SEPARATOR_REPLACEMENT: '-', + // Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00 + ISO_TIMESTAMP_SLICE: 19, + + MULTIPLE_UNDERSCORE_REGEX: /_+/g, + // Maximum length of the sanitized conversation name snippet + NAME_SUFFIX_MAX_LENGTH: 20, + // Replacements for making the conversation title filename-friendly + NON_ALPHANUMERIC_REGEX: /[^a-z0-9]/gi, + NONALNUM_REPLACEMENT: '_' +} as const; diff --git a/tools/ui/src/lib/constants/message-export.ts b/tools/ui/src/lib/constants/message-export.ts deleted file mode 100644 index fc4dbe259..000000000 --- a/tools/ui/src/lib/constants/message-export.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Conversation filename constants - -// Length of the trimmed conversation ID in the filename -export const EXPORT_CONV_ID_TRIM_LENGTH = 8; -// Maximum length of the sanitized conversation name snippet -export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20; -// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00 -export const ISO_TIMESTAMP_SLICE_LENGTH = 19; - -// Producer marker carried by the session record of a JSONL export -export const SESSION_HARNESS = 'llama.app'; - -// Replacements for making the conversation title filename-friendly -export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi; -export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_'; -export const MULTIPLE_UNDERSCORE_REGEX = /_+/g; - -// Replacements to the ISO date for use in the export filename -export const ISO_DATE_TIME_SEPARATOR = 'T'; -export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_'; - -export const ISO_TIME_SEPARATOR = ':'; -export const ISO_TIME_SEPARATOR_REPLACEMENT = '-'; diff --git a/tools/ui/src/lib/constants/model-id.constants.ts b/tools/ui/src/lib/constants/model-id.constants.ts new file mode 100644 index 000000000..081a13e0e --- /dev/null +++ b/tools/ui/src/lib/constants/model-id.constants.ts @@ -0,0 +1,43 @@ +/** + * Parsing of `org/ModelName[-tag][:quant]` style model IDs. + */ + +export const MODEL_ID = { + /** + * Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`. + * The leading `A`/`a` distinguishes it from a regular params segment. + */ + ACTIVATED_PARAMS_RE: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/, + + /** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */ + CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i, + /** Container format segments to exclude from tags (every model uses these). */ + IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']), + /** Sentinel value returned by `indexOf` when a substring is not found. */ + NOT_FOUND: -1, + + /** Separates `` from `` in a model ID, e.g. `org/ModelName`. */ + ORG_SEPARATOR: '/', + + /** + * Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`. + * The optional leading `E` covers effective-parameter sizes, e.g. Gemma's + * `E2B`/`E4B` (MatFormer models sized by resident params). + */ + PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/, + + /** + * Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`. + * Case-insensitive to handle both uppercase and lowercase inputs. + */ + QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i, + + /** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */ + QUANTIZATION_SEPARATOR: ':', + + /** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */ + SEGMENT_SEPARATOR: '-', + + /** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */ + WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i +}; diff --git a/tools/ui/src/lib/constants/model-id.ts b/tools/ui/src/lib/constants/model-id.ts deleted file mode 100644 index 4108a2132..000000000 --- a/tools/ui/src/lib/constants/model-id.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** Sentinel value returned by `indexOf` when a substring is not found. */ -export const MODEL_ID_NOT_FOUND = -1; - -/** Separates `` from `` in a model ID, e.g. `org/ModelName`. */ -export const MODEL_ID_ORG_SEPARATOR = '/'; - -/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */ -export const MODEL_ID_SEGMENT_SEPARATOR = '-'; - -/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */ -export const MODEL_ID_QUANTIZATION_SEPARATOR = ':'; - -/** - * Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`. - * Case-insensitive to handle both uppercase and lowercase inputs. - */ -export const MODEL_QUANTIZATION_SEGMENT_RE = - /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i; - -/** - * Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. - */ -export const MODEL_CUSTOM_QUANTIZATION_PREFIX_RE = /^UD$/i; - -/** - * Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`. - * The optional leading `E` covers effective-parameter sizes, e.g. Gemma's - * `E2B`/`E4B` (MatFormer models sized by resident params). - */ -export const MODEL_PARAMS_RE = /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/; - -/** - * Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`. - * The leading `A`/`a` distinguishes it from a regular params segment. - */ -export const MODEL_ACTIVATED_PARAMS_RE = /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/; - -/** - * Container format segments to exclude from tags (every model uses these). - */ -export const MODEL_IGNORED_SEGMENTS = new Set(['GGUF', 'GGML']); - -/** - * Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. - */ -export const MODEL_WEIGHT_EXTENSION_RE = /\.(gguf|ggml)$/i; diff --git a/tools/ui/src/lib/constants/model-loading.ts b/tools/ui/src/lib/constants/model-loading.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/model-loading.ts rename to tools/ui/src/lib/constants/model-loading.constants.ts diff --git a/tools/ui/src/lib/constants/path-display.ts b/tools/ui/src/lib/constants/path-display.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/path-display.ts rename to tools/ui/src/lib/constants/path-display.constants.ts diff --git a/tools/ui/src/lib/constants/precision.ts b/tools/ui/src/lib/constants/precision.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/precision.ts rename to tools/ui/src/lib/constants/precision.constants.ts diff --git a/tools/ui/src/lib/constants/processing-info.ts b/tools/ui/src/lib/constants/processing-info.ts deleted file mode 100644 index 2c3f7dc53..000000000 --- a/tools/ui/src/lib/constants/processing-info.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const PROCESSING_INFO_TIMEOUT = 2000; - -/** - * Statistics units labels - */ -export const STATS_UNITS = { - TOKENS_PER_SECOND: 't/s' -} as const; diff --git a/tools/ui/src/lib/constants/pwa.ts b/tools/ui/src/lib/constants/pwa.constants.ts similarity index 99% rename from tools/ui/src/lib/constants/pwa.ts rename to tools/ui/src/lib/constants/pwa.constants.ts index 4da37c06b..e807f4a97 100644 --- a/tools/ui/src/lib/constants/pwa.ts +++ b/tools/ui/src/lib/constants/pwa.constants.ts @@ -3,7 +3,7 @@ * definitions across the codebase. */ -import { APP_NAME } from './app'; +import { APP_NAME } from './app.constants'; export const MEDIA_QUERIES = { DISPLAY_MODE_STANDALONE: '(display-mode: standalone)', diff --git a/tools/ui/src/lib/constants/reasoning-effort-tokens.ts b/tools/ui/src/lib/constants/reasoning-effort-tokens.ts deleted file mode 100644 index a0c244e5e..000000000 --- a/tools/ui/src/lib/constants/reasoning-effort-tokens.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ReasoningEffort } from '$lib/enums'; - -/** - * Reasoning effort to token budget mapping. - * Maps the ReasoningEffort enum values to concrete token counts for the server. - */ -export const REASONING_EFFORT_TOKENS: Record = { - [ReasoningEffort.HIGH]: 8192, - [ReasoningEffort.LOW]: 512, - [ReasoningEffort.MAX]: -1, // unlimited - [ReasoningEffort.MEDIUM]: 2048 -}; diff --git a/tools/ui/src/lib/constants/reasoning-effort.ts b/tools/ui/src/lib/constants/reasoning-effort.constants.ts similarity index 71% rename from tools/ui/src/lib/constants/reasoning-effort.ts rename to tools/ui/src/lib/constants/reasoning-effort.constants.ts index 4cbb7388b..e8ec5f0e8 100644 --- a/tools/ui/src/lib/constants/reasoning-effort.ts +++ b/tools/ui/src/lib/constants/reasoning-effort.constants.ts @@ -22,3 +22,14 @@ export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [ { label: 'High', value: ReasoningEffort.HIGH }, { hasInfo: true, label: 'Max', value: ReasoningEffort.MAX } ]; + +/** + * Reasoning effort to token budget mapping. + * Maps the ReasoningEffort enum values to concrete token counts for the server. + */ +export const REASONING_EFFORT_TOKENS: Record = { + [ReasoningEffort.HIGH]: 8192, + [ReasoningEffort.LOW]: 512, + [ReasoningEffort.MAX]: -1, // unlimited + [ReasoningEffort.MEDIUM]: 2048 +}; diff --git a/tools/ui/src/lib/constants/recommended-mcp-servers.ts b/tools/ui/src/lib/constants/recommended-mcp-servers.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/recommended-mcp-servers.ts rename to tools/ui/src/lib/constants/recommended-mcp-servers.constants.ts diff --git a/tools/ui/src/lib/constants/routes.ts b/tools/ui/src/lib/constants/routes.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/routes.ts rename to tools/ui/src/lib/constants/routes.constants.ts diff --git a/tools/ui/src/lib/constants/sandbox.constants.ts b/tools/ui/src/lib/constants/sandbox.constants.ts new file mode 100644 index 000000000..9846e471a --- /dev/null +++ b/tools/ui/src/lib/constants/sandbox.constants.ts @@ -0,0 +1,13 @@ +import { BuiltInTool } from '$lib/enums'; + +export const SANDBOX_TOOL_NAME = BuiltInTool.RUN_JAVASCRIPT; + +export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000; + +export const SANDBOX_TIMEOUT_MS_MAX = 30000; + +export const SANDBOX_OUTPUT_MAX_CHARS = 8192; + +export const SANDBOX_EMPTY_OUTPUT = '(no output)'; + +export const SANDBOX_TRUNCATION_NOTICE = '[output truncated]'; diff --git a/tools/ui/src/lib/constants/settings-keys.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/settings-keys.ts rename to tools/ui/src/lib/constants/settings-keys.constants.ts diff --git a/tools/ui/src/lib/constants/settings-registry.ts b/tools/ui/src/lib/constants/settings-registry.constants.ts similarity index 97% rename from tools/ui/src/lib/constants/settings-registry.ts rename to tools/ui/src/lib/constants/settings-registry.constants.ts index ada029a33..e44459957 100644 --- a/tools/ui/src/lib/constants/settings-registry.ts +++ b/tools/ui/src/lib/constants/settings-registry.constants.ts @@ -1,12 +1,9 @@ -import { CLI_FLAGS } from './cli-flags'; -import { DEFAULT_MCP_CONFIG } from './mcp'; -import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes'; -import { SETTINGS_KEYS } from './settings-keys'; -import { TITLE_GENERATION } from './title-generation'; -import { - FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH, - FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH -} from './working-directory'; +import { CLI_FLAGS } from './cli-flags.constants'; +import { DEFAULT_MCP_CONFIG } from './mcp.constants'; +import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes.constants'; +import { SETTINGS_KEYS } from './settings-keys.constants'; +import { TITLE_GENERATION } from './title-generation.constants'; +import { FILE_GLOB_SEARCH_PICKERS } from './working-directory.constants'; import { AlertTriangle, Code, @@ -14,7 +11,6 @@ import { Funnel, ListRestart, Monitor, - Monitor as MonitorIcon, Moon, PencilRuler, Sliders, @@ -53,7 +49,7 @@ const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Co } ]; const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [ - { icon: MonitorIcon, label: 'System', value: ColorMode.SYSTEM }, + { icon: Monitor, label: 'System', value: ColorMode.SYSTEM }, { icon: Sun, label: 'Light', value: ColorMode.LIGHT }, { icon: Moon, label: 'Dark', value: ColorMode.DARK } ]; @@ -106,14 +102,14 @@ const SETTINGS_REGISTRY: Record = { type: SettingsFieldType.INPUT }, { - defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH, + defaultValue: FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH, help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.', isPositiveInteger: true, key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH, label: 'Mention search depth', - max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH, + max: FILE_GLOB_SEARCH_PICKERS.MAX_SEARCH_DEPTH, min: 1, - placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`, + placeholder: `${FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH}`, section: SETTINGS_SECTION_SLUGS.AGENTIC, type: SettingsFieldType.INPUT } diff --git a/tools/ui/src/lib/constants/special-characters.constants.ts b/tools/ui/src/lib/constants/special-characters.constants.ts new file mode 100644 index 000000000..aaeebca33 --- /dev/null +++ b/tools/ui/src/lib/constants/special-characters.constants.ts @@ -0,0 +1,16 @@ +// Control / whitespace / formatting characters that appear literally inside rendered text. + +/** Line feed. */ +export const NEWLINE = '\n'; + +/** Horizontal tab. */ +export const TAB = '\t'; + +/** Non-breaking space. */ +export const NBSP = '\u00a0'; + +/** Non-breaking spaces used to render a tab stop that whitespace collapsing would otherwise squash. */ +export const TAB_AS_SPACES = NBSP.repeat(4); + +/** Matches a CR-terminated or bare LF line break. */ +export const LINE_BREAK = /\r?\n/; diff --git a/tools/ui/src/lib/constants/storage.ts b/tools/ui/src/lib/constants/storage.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/storage.ts rename to tools/ui/src/lib/constants/storage.constants.ts diff --git a/tools/ui/src/lib/constants/sse.ts b/tools/ui/src/lib/constants/stream.constants.ts similarity index 53% rename from tools/ui/src/lib/constants/sse.ts rename to tools/ui/src/lib/constants/stream.constants.ts index 0eb4b6ede..64f67243c 100644 --- a/tools/ui/src/lib/constants/sse.ts +++ b/tools/ui/src/lib/constants/stream.constants.ts @@ -1,3 +1,11 @@ +// grace window after a visibilitychange before we kick a reader whose socket likely died +// while the tab was hidden. covers brief background pauses without thrashing live streams +export const STREAM_VISIBILITY_KICK_MS = 3000; + +// separator joining a conversation id and its per-model stream identity +// suffix (conv::model) used by the server side replay buffer +export const CONVERSATION_ID_SEPARATOR = '::'; + /** * Server-sent events wire format, shared by the chat stream and the * /models/sse status feed (text/event-stream). diff --git a/tools/ui/src/lib/constants/stream.ts b/tools/ui/src/lib/constants/stream.ts deleted file mode 100644 index 67951ee95..000000000 --- a/tools/ui/src/lib/constants/stream.ts +++ /dev/null @@ -1,3 +0,0 @@ -// grace window after a visibilitychange before we kick a reader whose socket likely died -// while the tab was hidden. covers brief background pauses without thrashing live streams -export const STREAM_VISIBILITY_KICK_MS = 3000; diff --git a/tools/ui/src/lib/constants/supported-file-types.ts b/tools/ui/src/lib/constants/supported-file-types.constants.ts similarity index 100% rename from tools/ui/src/lib/constants/supported-file-types.ts rename to tools/ui/src/lib/constants/supported-file-types.constants.ts diff --git a/tools/ui/src/lib/constants/svg-blocks.constants.ts b/tools/ui/src/lib/constants/svg-blocks.constants.ts new file mode 100644 index 000000000..705800c26 --- /dev/null +++ b/tools/ui/src/lib/constants/svg-blocks.constants.ts @@ -0,0 +1,57 @@ +/** + * Constants for rendering svg code blocks inline. + */ +export const SVG = { + // CSS classes applied to the inline svg block and its chrome. + BLOCK_CLASS: 'svg-block', + /** + * Shadow root style for the zoom dialog svg. Lets the svg grow past its + * intrinsic size so pan and zoom have room to work. + */ + DIALOG_SHADOW_STYLE: + ':host{display:inline-block}svg{min-height:min(50vh,12rem);min-width:min(80vw,20rem);max-width:none;max-height:none;height:auto;width:auto;display:block}', + ID_ATTR: 'data-svg-id', + + /** + * Shadow root style for an inline svg block. Mirrors the centered, padded + * sizing the light dom used before the svg moved behind a shadow boundary. + */ + INLINE_SHADOW_STYLE: + ':host{display:block;width:100%;text-align:center}svg{display:block;margin:0 auto;width:auto;height:auto;max-width:100%;max-height:70vh;min-height:8rem;padding:3rem 1rem}', + // Languages that mark a code block as svg content. + LANGUAGE: 'svg', + /** + * Hard size ceiling for a single inline svg block. + * Above this the source is left as raw text instead of being rendered. + */ + MAX_BYTES: 256 * 1024, + + RENDERED_ATTR: 'data-svg-rendered', + /** + * DOMPurify config for untrusted svg coming from model output. + * + * foreignObject and script stay forbidden unconditionally, they are the only + * inline svg vectors that execute arbitrary html or js. Everything else is + * allowed for maximum rendering compatibility: href and xlink:href stay so + * use, image, a and animateMotion work, and DOMPurify still neutralizes + * javascript: and data: uri schemes natively. External resource refs are + * allowed by design on a local first tool, the user browser fetches them. + * + * The sanitized svg is always mounted inside a shadow root (see svg-shadow), + * so an author {/if} diff --git a/tools/ui/src/routes/search/+page.svelte b/tools/ui/src/routes/search/+page.svelte index 424b74f67..cb7f77f95 100644 --- a/tools/ui/src/routes/search/+page.svelte +++ b/tools/ui/src/routes/search/+page.svelte @@ -5,9 +5,7 @@ import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app'; import { ROUTES } from '$lib/constants'; import { RouterService } from '$lib/services/router.service'; - import { chatStore } from '$lib/stores/chat.svelte'; - import { conversations, conversationsStore } from '$lib/stores/conversations.svelte'; - import { isMobile } from '$lib/stores/viewport.svelte'; + import { chatStore, conversationsStore, isMobile } from '$lib/stores'; let searchQuery = $state(''); let searchInputRef = $state(null); @@ -19,7 +17,7 @@ if (query.length === 0) return []; - return conversations().filter((c) => c.name.toLowerCase().includes(query)); + return conversationsStore.conversations.filter((c) => c.name.toLowerCase().includes(query)); }); // Search page is intended for mobile; on desktop the sidebar already exposes @@ -35,7 +33,7 @@ } async function handleEditConversation(id: string) { - const conversation = conversations().find((c) => c.id === id); + const conversation = conversationsStore.conversations.find((c) => c.id === id); if (!conversation) return; @@ -47,7 +45,7 @@ } async function handleDeleteConversation(id: string) { - const conversation = conversations().find((c) => c.id === id); + const conversation = conversationsStore.conversations.find((c) => c.id === id); if (!conversation) return; diff --git a/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts b/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts index ddf1393e7..45af7e0d1 100644 --- a/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts +++ b/tools/ui/tests/client/settings-registry-invariants.svelte.test.ts @@ -1,7 +1,7 @@ import { CONFIG_LOCALSTORAGE_KEY, SETTING_CONFIG_DEFAULT } from '$lib/constants'; import { ParameterSyncService } from '$lib/services/parameter-sync.service'; import { serverStore } from '$lib/stores/server.svelte'; -import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { settingsStore } from '$lib/stores/settings.svelte'; import type { SettingsConfigType } from '$lib/types'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -40,7 +40,7 @@ function mockProps(uiSettings: Record) { const setUser = (key: string, value: Primitive) => settingsStore.updateConfig(key as keyof SettingsConfigType, value as never); -const current = (key: string) => (config() as Record)[key]; +const current = (key: string) => (settingsStore.config as Record)[key]; describe('registry-wide invariants', () => { beforeEach(() => { diff --git a/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts b/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts index 078cc7cbe..32f4ff3dd 100644 --- a/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts +++ b/tools/ui/tests/client/settings-render-keys-migration.svelte.test.ts @@ -6,7 +6,7 @@ import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants'; import { MigrationService } from '$lib/services/migration.service'; -import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { settingsStore } from '$lib/stores/settings.svelte'; import { beforeEach, describe, expect, it } from 'vitest'; const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1'; @@ -33,22 +33,22 @@ describe('renderContentAsRawText unfolding', () => { it('maps raw text to user content as plain text', async () => { await seedConfig({ renderContentAsRawText: true }); - expect(config().renderUserContentAsMarkdown).toBe(false); + expect(settingsStore.config.renderUserContentAsMarkdown).toBe(false); }); it('maps markdown to user content as markdown', async () => { await seedConfig({ renderContentAsRawText: false }); - expect(config().renderUserContentAsMarkdown).toBe(true); + expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true); }); it('leaves thinking on its own default', async () => { await seedConfig({ renderContentAsRawText: true }); - expect(config().renderThinkingAsMarkdown).toBe(true); + expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true); }); it('keeps an explicit user preference over the toggle', async () => { await seedConfig({ renderContentAsRawText: true, renderUserContentAsMarkdown: true }); - expect(config().renderUserContentAsMarkdown).toBe(true); + expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true); }); it('drops the toggle from the persisted config', async () => { @@ -58,7 +58,7 @@ describe('renderContentAsRawText unfolding', () => { it('leaves both surfaces on markdown when nothing is stored', async () => { await seedConfig({}); - expect(config().renderUserContentAsMarkdown).toBe(true); - expect(config().renderThinkingAsMarkdown).toBe(true); + expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true); + expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true); }); }); diff --git a/tools/ui/tests/client/ui-settings-sync.svelte.test.ts b/tools/ui/tests/client/ui-settings-sync.svelte.test.ts index 110011ef0..86e089893 100644 --- a/tools/ui/tests/client/ui-settings-sync.svelte.test.ts +++ b/tools/ui/tests/client/ui-settings-sync.svelte.test.ts @@ -1,6 +1,6 @@ import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants'; import { serverStore } from '$lib/stores/server.svelte'; -import { config, settingsStore } from '$lib/stores/settings.svelte'; +import { settingsStore } from '$lib/stores/settings.svelte'; import { beforeEach, describe, expect, it } from 'vitest'; function mockProps(uiSettings: Record) { @@ -25,7 +25,7 @@ describe('server ui_settings application semantics', () => { settingsStore.syncWithServerDefaults(); - expect(config().theme).toBe('dark'); + expect(settingsStore.config.theme).toBe('dark'); }); it('never reapplies on later loads: the user config diverges freely', () => { @@ -40,8 +40,8 @@ describe('server ui_settings application semantics', () => { settingsStore.syncWithServerDefaults(); settingsStore.syncWithServerDefaults(); - expect(config().theme).toBe('light'); - expect(config().apiKey).toBe('sk-user-key'); + expect(settingsStore.config.theme).toBe('light'); + expect(settingsStore.config.apiKey).toBe('sk-user-key'); const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}'); expect(stored.apiKey).toBe('sk-user-key'); @@ -55,8 +55,8 @@ describe('server ui_settings application semantics', () => { settingsStore.forceSyncWithServerDefaults(); - expect(config().theme).toBe('dark'); - expect(config().apiKey).toBe(''); + expect(settingsStore.config.theme).toBe('dark'); + expect(settingsStore.config.apiKey).toBe(''); }); }); From f2efd64141b9f9003c592375adf287f5fe1e5b80 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 13 Aug 2026 08:13:52 +0200 Subject: [PATCH 16/41] ui: Move `styles/` to `$lib` scope (#26950) * refactor: Move `styles/` to `src/lib` and remove legacy alias * chore: Add newline --- tools/ui/src/app.html | 1 + .../app/content/MarkdownContent/MarkdownContent.svelte | 2 +- tools/ui/src/{ => lib}/styles/katex-custom.scss | 0 tools/ui/svelte.config.js | 3 --- 4 files changed, 2 insertions(+), 4 deletions(-) rename tools/ui/src/{ => lib}/styles/katex-custom.scss (100%) diff --git a/tools/ui/src/app.html b/tools/ui/src/app.html index e1de226dc..ef2787ad1 100644 --- a/tools/ui/src/app.html +++ b/tools/ui/src/app.html @@ -2,6 +2,7 @@ + diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index 04d48f0aa..cadd9f210 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -1,5 +1,5 @@ {#if isMobile.current} - + {#snippet trigger({ disabled, onclick })} {/snippet} {:else} - + {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte index 13e97cc73..d8fad772d 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte @@ -11,6 +11,7 @@ } from '$lib/components/app'; import { Button } from '$lib/components/ui/button'; import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants'; + import { setChatFormActionsContext } from '$lib/contexts'; import { FileTypeCategory, MessageRole } from '$lib/enums'; import { ChatService } from '$lib/services'; import { chatStore, conversationsStore, mcpStore, settingsStore } from '$lib/stores'; @@ -132,6 +133,42 @@ return livePromptTokens > 0 || liveOutputTokens > 0; }); + + setChatFormActionsContext({ + get disabled() { + return disabled; + }, + get hasAudioModality() { + return hasAudioModality; + }, + get hasMcpPromptsSupport() { + return hasMcpPromptsSupport; + }, + get hasMcpResourcesSupport() { + return hasMcpResourcesSupport; + }, + get hasVideoModality() { + return hasVideoModality; + }, + get hasVisionModality() { + return hasVisionModality; + }, + get onFileUpload() { + return onFileUpload; + }, + get onMcpPromptClick() { + return onMcpPromptClick; + }, + get onMcpResourcesClick() { + return onMcpResourcesClick; + }, + get onMcpSettingsClick() { + return () => goto(ROUTES.MCP_SERVERS); + }, + get onSystemPromptClick() { + return onSystemPromptClick; + } + });
{#if showAddButton}
- goto(ROUTES.MCP_SERVERS)} - /> +
{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte index b3e54ada0..78cb88721 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -8,16 +8,21 @@ ChatMessageUser } from '$lib/components/app/chat'; import { REASONING_TAGS, ROUTES, SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants'; - import { getChatActionsContext, setMessageEditContext } from '$lib/contexts'; + import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts'; import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums'; import { DatabaseService } from '$lib/services/database.service'; import { chatStore, conversationsStore, isMobile } from '$lib/stores'; - import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; + import type { + ChatMessageActions, + ChatMessageDeletionInfo, + DatabaseMessageExtraMcpPrompt + } from '$lib/types'; import { deriveAgenticSections } from '$lib/utils'; import { parseFilesToMessageExtras } from '$lib/utils/browser-only'; interface Props { class?: string; + chatActions: ChatMessageActions; message: DatabaseMessage; toolMessages?: DatabaseMessage[]; isLastAssistantMessage?: boolean; @@ -27,6 +32,7 @@ } let { + chatActions, class: className = '', isLastAssistantMessage = false, isLastUserMessage = false, @@ -36,14 +42,7 @@ toolMessages = [] }: Props = $props(); - const chatActions = getChatActionsContext(); - - let deletionInfo = $state<{ - totalCount: number; - userMessages: number; - assistantMessages: number; - messageTypes: string[]; - } | null>(null); + let deletionInfo = $state(null); // The system message placeholder must never surface as editable content; keeping // it in the derived (not just in handleEdit) guards against prop invalidation // reverting the override while editing @@ -112,7 +111,7 @@ let showSaveOnlyOption = $derived(message.role === MessageRole.USER); let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT); - setMessageEditContext({ + setChatMessageEditContext({ cancel: handleCancelEdit, get editedContent() { return editedContent; @@ -166,6 +165,30 @@ startEdit: handleEdit }); + setChatMessageActionsContext({ + confirmDelete: handleConfirmDelete, + copy: handleCopy, + get deletionInfo() { + return deletionInfo; + }, + get forkConversation() { + const isForkableUser = message.role === MessageRole.USER && !mcpPromptExtra; + + return isForkableUser || message.role === MessageRole.ASSISTANT + ? handleForkConversation + : undefined; + }, + navigateToSibling: handleNavigateToSibling, + requestDelete: handleDelete, + setShowDeleteDialog: handleShowDeleteDialogChange, + get showDeleteDialog() { + return showDeleteDialog; + }, + get siblingInfo() { + return siblingInfo; + } + }); + let mcpPromptExtra = $derived.by(() => { if (message.role !== MessageRole.USER) return null; @@ -360,73 +383,22 @@
{#if message.role === MessageRole.SYSTEM} - + {:else if mcpPromptExtra} - + {:else if isSynthetic} {:else if message.role === MessageRole.USER} - + {:else} {/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte index 14ea68099..b92be9fbd 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte @@ -8,7 +8,7 @@ ChatMessageAssistantStatistics, ChatMessageEditForm } from '$lib/components/app'; - import { getMessageEditContext } from '$lib/contexts'; + import { getChatMessageEditContext } from '$lib/contexts'; import { MessageRole } from '$lib/enums'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { chatStore, modelsStore, serverStore, settingsStore } from '$lib/stores'; @@ -17,51 +17,26 @@ interface Props { class?: string; - deletionInfo: { - totalCount: number; - userMessages: number; - assistantMessages: number; - messageTypes: string[]; - } | null; isLastAssistantMessage?: boolean; message: DatabaseMessage; toolMessages?: DatabaseMessage[]; - onCopy: () => void; - onConfirmDelete: () => void; onContinue?: () => void; - onDelete: () => void; - onEdit?: () => void; - onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void; - onNavigateToSibling?: (siblingId: string) => void; onRegenerate: (modelOverride?: string) => void; - onShowDeleteDialogChange: (show: boolean) => void; - showDeleteDialog: boolean; - siblingInfo?: ChatMessageSiblingInfo | null; textareaElement?: HTMLTextAreaElement; } let { class: className = '', - deletionInfo, isLastAssistantMessage = false, message, - onConfirmDelete, onContinue, - onCopy, - onDelete, - onEdit, - onForkConversation, - onNavigateToSibling, onRegenerate, - onShowDeleteDialogChange, - showDeleteDialog, - siblingInfo = null, textareaElement = $bindable(), toolMessages = [] }: Props = $props(); // Get edit context - const editCtx = getMessageEditContext(); + const editCtx = getChatMessageEditContext(); const isAgentic = $derived(hasAgenticContent(message, toolMessages)); const processingState = useProcessingState(); @@ -207,18 +182,8 @@ role={MessageRole.ASSISTANT} justify="start" actionsPosition="left" - {siblingInfo} - {showDeleteDialog} - {deletionInfo} - {onCopy} - {onEdit} {onRegenerate} onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined} - {onForkConversation} - {onDelete} - {onConfirmDelete} - {onNavigateToSibling} - {onShowDeleteDialogChange} showRawOutputSwitch={currentConfig.showRawOutputSwitch} rawOutputEnabled={showRawOutput} onRawOutputToggle={(enabled) => (showRawOutput = enabled)} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte index d3aa7251e..4563b1fa8 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte @@ -4,7 +4,7 @@ ChatMessageEditForm, ChatMessageMcpPromptContent } from '$lib/components/app'; - import { getMessageEditContext } from '$lib/contexts'; + import { getChatMessageEditContext } from '$lib/contexts'; import { McpPromptVariant, MessageRole } from '$lib/enums'; import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; @@ -12,39 +12,12 @@ class?: string; message: DatabaseMessage; mcpPrompt: DatabaseMessageExtraMcpPrompt; - siblingInfo?: ChatMessageSiblingInfo | null; - showDeleteDialog: boolean; - deletionInfo: { - totalCount: number; - userMessages: number; - assistantMessages: number; - messageTypes: string[]; - } | null; - onCopy: () => void; - onEdit: () => void; - onDelete: () => void; - onConfirmDelete: () => void; - onNavigateToSibling?: (siblingId: string) => void; - onShowDeleteDialogChange: (show: boolean) => void; } - let { - class: className = '', - deletionInfo, - mcpPrompt, - message, - onConfirmDelete, - onCopy, - onDelete, - onEdit, - onNavigateToSibling, - onShowDeleteDialogChange, - showDeleteDialog, - siblingInfo = null - }: Props = $props(); + let { class: className = '', mcpPrompt, message }: Props = $props(); // Get edit context - const editCtx = getMessageEditContext(); + const editCtx = getChatMessageEditContext();
- +
{/if} {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte index 2f919f9eb..c6222f568 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSystem/ChatMessageSystem.svelte @@ -4,7 +4,7 @@ import { Button } from '$lib/components/ui/button'; import { Card } from '$lib/components/ui/card'; import { INPUT_CLASSES } from '$lib/constants'; - import { getMessageEditContext } from '$lib/contexts'; + import { getChatMessageEditContext } from '$lib/contexts'; import { KeyboardKey, MessageRole } from '$lib/enums'; import { settingsStore } from '$lib/stores'; import { autoResizeTextarea, isIMEComposing } from '$lib/utils'; @@ -12,39 +12,12 @@ interface Props { class?: string; message: DatabaseMessage; - siblingInfo?: ChatMessageSiblingInfo | null; - showDeleteDialog: boolean; - deletionInfo: { - totalCount: number; - userMessages: number; - assistantMessages: number; - messageTypes: string[]; - } | null; - onCopy: () => void; - onEdit: () => void; - onDelete: () => void; - onConfirmDelete: () => void; - onNavigateToSibling?: (siblingId: string) => void; - onShowDeleteDialogChange: (show: boolean) => void; textareaElement?: HTMLTextAreaElement; } - let { - class: className = '', - deletionInfo, - message, - onConfirmDelete, - onCopy, - onDelete, - onEdit, - onNavigateToSibling, - onShowDeleteDialogChange, - showDeleteDialog, - siblingInfo = null, - textareaElement = $bindable() - }: Props = $props(); + let { class: className = '', message, textareaElement = $bindable() }: Props = $props(); - const editCtx = getMessageEditContext(); + const editCtx = getChatMessageEditContext(); function handleEditKeydown(event: KeyboardEvent) { if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { @@ -218,20 +191,7 @@ {#if message.timestamp}
- +
{/if} {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte index db09bb723..a6fa2e250 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte @@ -12,8 +12,7 @@ import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte'; import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte'; import { BuiltInTool } from '$lib/enums'; - import type { AgenticSection } from '$lib/types'; - import type { DatabaseMessageExtra } from '$lib/types'; + import type { AgenticSection, DatabaseMessageExtra } from '$lib/types'; import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils'; interface Props { diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte index 639bee897..d7103bf97 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte @@ -13,8 +13,7 @@ import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants'; import { AttachmentType } from '$lib/enums'; import { settingsStore, toolsStore } from '$lib/stores'; - import type { AgenticSection, ToolResultLine } from '$lib/types'; - import type { DatabaseMessageExtra } from '$lib/types'; + import type { AgenticSection, DatabaseMessageExtra, ToolResultLine } from '$lib/types'; import { abbreviateHome, type ExecShellExitStatus, diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte index f902ec88d..d217a9c38 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUser.svelte @@ -5,7 +5,7 @@ ChatMessageStatistics, ChatMessageUserBubble } from '$lib/components/app/chat'; - import { getMessageEditContext } from '$lib/contexts'; + import { getChatMessageEditContext } from '$lib/contexts'; import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { chatStore, settingsStore } from '$lib/stores'; @@ -13,44 +13,19 @@ interface Props { class?: string; message: DatabaseMessage; - siblingInfo?: ChatMessageSiblingInfo | null; - deletionInfo: { - totalCount: number; - userMessages: number; - assistantMessages: number; - messageTypes: string[]; - } | null; isLastUserMessage?: boolean; nextAssistantMessage?: DatabaseMessage | null; - showDeleteDialog: boolean; - onEdit: () => void; - onDelete: () => void; - onConfirmDelete: () => void; - onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void; - onShowDeleteDialogChange: (show: boolean) => void; - onNavigateToSibling?: (siblingId: string) => void; - onCopy: () => void; } let { class: className = '', - deletionInfo, isLastUserMessage = false, message, - nextAssistantMessage = null, - onConfirmDelete, - onCopy, - onDelete, - onEdit, - onForkConversation, - onNavigateToSibling, - onShowDeleteDialogChange, - showDeleteDialog, - siblingInfo = null + nextAssistantMessage = null }: Props = $props(); // Get contexts - const editCtx = getMessageEditContext(); + const editCtx = getChatMessageEditContext(); const processingState = useProcessingState(); const currentConfig = $derived(settingsStore.config); @@ -132,21 +107,7 @@ {#if message.timestamp}
- +
{/if} {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte index f3e8c4e91..a072f2e84 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte @@ -1,7 +1,7 @@ @@ -88,18 +70,16 @@ ? 'left-0' : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity" > - {#if siblingInfo && siblingInfo.totalSiblings > 1} - + {#if messageActions.siblingInfo && messageActions.siblingInfo.totalSiblings > 1} + {/if}
- + - {#if onEdit} - - {/if} + {#if role === MessageRole.ASSISTANT && onRegenerate} onRegenerate()} /> @@ -109,11 +89,11 @@ {/if} - {#if onForkConversation} + {#if messageActions.forkConversation} {/if} - +
@@ -129,19 +109,19 @@ 1 - ? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.` + description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1 + ? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.` : 'Are you sure you want to delete this message? This action cannot be undone.'} - confirmText={deletionInfo && deletionInfo.totalCount > 1 - ? `Delete ${deletionInfo.totalCount} Messages` + confirmText={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1 + ? `Delete ${messageActions.deletionInfo.totalCount} Messages` : 'Delete'} cancelText="Cancel" variant="destructive" icon={Trash2} onConfirm={handleConfirmDelete} - onCancel={() => onShowDeleteDialogChange(false)} + onCancel={() => messageActions.setShowDeleteDialog(false)} /> import { ChevronLeft, ChevronRight } from '@lucide/svelte'; import { ActionIcon } from '$lib/components/app'; + import { getChatMessageActionsContext } from '$lib/contexts'; interface Props { class?: string; - siblingInfo: ChatMessageSiblingInfo | null; - onNavigateToSibling?: (siblingId: string) => void; } - let { class: className = '', onNavigateToSibling, siblingInfo }: Props = $props(); + let { class: className = '' }: Props = $props(); + + const messageActions = getChatMessageActionsContext(); + + let siblingInfo = $derived(messageActions.siblingInfo); let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0); let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1); @@ -31,7 +34,7 @@ tooltip="Previous version" disabled={!hasPrevious} class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}" - onclick={() => onNavigateToSibling?.(previousSiblingId!)} + onclick={() => messageActions.navigateToSibling(previousSiblingId!)} /> @@ -43,7 +46,7 @@ tooltip="Next version" disabled={!hasNext} class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}" - onclick={() => onNavigateToSibling?.(nextSiblingId!)} + onclick={() => messageActions.navigateToSibling(nextSiblingId!)} /> {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index 8ce1ead01..584979979 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -9,8 +9,8 @@ } from '$lib/components/app'; import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums'; import { agenticStore, settingsStore } from '$lib/stores'; - import type { AgenticSection } from '$lib/types'; import type { + AgenticSection, ChatMessageAgenticTimings, ChatMessageAgenticTurnStats, DatabaseMessage diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte index feacd0ddd..369b6137b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte @@ -3,12 +3,12 @@ import { ChatForm, DialogConfirmation } from '$lib/components/app'; import { Button } from '$lib/components/ui/button'; import { Switch } from '$lib/components/ui/switch'; - import { getMessageEditContext } from '$lib/contexts'; + import { getChatMessageEditContext } from '$lib/contexts'; import { KeyboardKey, MessageRole } from '$lib/enums'; import { chatStore } from '$lib/stores'; import { processFilesToChatUploaded } from '$lib/utils/browser-only'; - const editCtx = getMessageEditContext(); + const editCtx = getChatMessageEditContext(); let saveWithoutRegenerate = $state(false); let showDiscardDialog = $state(false); diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte index 78c9dcb25..a0861fb96 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte @@ -4,8 +4,7 @@ import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants'; import { AgenticSectionType } from '$lib/enums'; import { settingsStore } from '$lib/stores'; - import type { DatabaseMessageExtra } from '$lib/types'; - import type { AgenticSection } from '$lib/types'; + import type { AgenticSection, DatabaseMessageExtra } from '$lib/types'; interface Props { section: AgenticSection; diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte index 33d971f93..2a8f45ba5 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -1,8 +1,8 @@
diff --git a/tools/ui/src/lib/constants/context-keys.constants.ts b/tools/ui/src/lib/constants/context-keys.constants.ts index 0bd733b37..62ff5413e 100644 --- a/tools/ui/src/lib/constants/context-keys.constants.ts +++ b/tools/ui/src/lib/constants/context-keys.constants.ts @@ -1,3 +1,3 @@ -export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit'; -export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions'; -export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config'; +export const CONTEXT_KEY_CHAT_MESSAGE_EDIT = 'chat-message-edit'; +export const CONTEXT_KEY_CHAT_MESSAGE_ACTIONS = 'chat-message-actions'; +export const CONTEXT_KEY_CHAT_FORM_ACTIONS = 'chat-form-actions'; diff --git a/tools/ui/src/lib/contexts/chat-actions.context.ts b/tools/ui/src/lib/contexts/chat-actions.context.ts deleted file mode 100644 index dffb5f3a3..000000000 --- a/tools/ui/src/lib/contexts/chat-actions.context.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants'; -import { getContext, setContext } from 'svelte'; - -export interface ChatActionsContext { - copy: (message: DatabaseMessage) => void; - delete: (message: DatabaseMessage) => void; - navigateToSibling: (siblingId: string) => void; - editWithBranching: ( - message: DatabaseMessage, - newContent: string, - newExtras?: DatabaseMessageExtra[] - ) => void; - editWithReplacement: ( - message: DatabaseMessage, - newContent: string, - shouldBranch: boolean - ) => void; - editUserMessagePreserveResponses: ( - message: DatabaseMessage, - newContent: string, - newExtras?: DatabaseMessageExtra[] - ) => void; - regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void; - continueAssistantMessage: (message: DatabaseMessage) => void; - forkConversation: ( - message: DatabaseMessage, - options: { name: string; includeAttachments: boolean } - ) => void; -} - -const CHAT_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_ACTIONS); - -export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext { - return setContext(CHAT_ACTIONS_KEY, ctx); -} - -export function getChatActionsContext(): ChatActionsContext { - return getContext(CHAT_ACTIONS_KEY); -} diff --git a/tools/ui/src/lib/contexts/chat-form-actions.context.ts b/tools/ui/src/lib/contexts/chat-form-actions.context.ts new file mode 100644 index 000000000..a49f17447 --- /dev/null +++ b/tools/ui/src/lib/contexts/chat-form-actions.context.ts @@ -0,0 +1,19 @@ +import { CONTEXT_KEY_CHAT_FORM_ACTIONS } from '$lib/constants'; +import type { ChatFormActionsContext } from '$lib/types'; +import { getContext, setContext } from 'svelte'; + +const CHAT_FORM_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_FORM_ACTIONS); + +/** + * Sets the chat form actions context. Call in the parent component (ChatFormActions.svelte). + */ +export function setChatFormActionsContext(ctx: ChatFormActionsContext): ChatFormActionsContext { + return setContext(CHAT_FORM_ACTIONS_KEY, ctx); +} + +/** + * Gets the chat form actions context. Call in child components. + */ +export function getChatFormActionsContext(): ChatFormActionsContext { + return getContext(CHAT_FORM_ACTIONS_KEY); +} diff --git a/tools/ui/src/lib/contexts/chat-message-actions.context.ts b/tools/ui/src/lib/contexts/chat-message-actions.context.ts new file mode 100644 index 000000000..fb075b3b0 --- /dev/null +++ b/tools/ui/src/lib/contexts/chat-message-actions.context.ts @@ -0,0 +1,21 @@ +import { CONTEXT_KEY_CHAT_MESSAGE_ACTIONS } from '$lib/constants'; +import type { ChatMessageActionsContext } from '$lib/types'; +import { getContext, setContext } from 'svelte'; + +const CHAT_MESSAGE_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_MESSAGE_ACTIONS); + +/** + * Sets the per-message actions context. Call this in the parent component (ChatMessage.svelte). + */ +export function setChatMessageActionsContext( + ctx: ChatMessageActionsContext +): ChatMessageActionsContext { + return setContext(CHAT_MESSAGE_ACTIONS_KEY, ctx); +} + +/** + * Gets the per-message actions context. Call this in child components. + */ +export function getChatMessageActionsContext(): ChatMessageActionsContext { + return getContext(CHAT_MESSAGE_ACTIONS_KEY); +} diff --git a/tools/ui/src/lib/contexts/chat-message-edit.context.ts b/tools/ui/src/lib/contexts/chat-message-edit.context.ts new file mode 100644 index 000000000..e9c053036 --- /dev/null +++ b/tools/ui/src/lib/contexts/chat-message-edit.context.ts @@ -0,0 +1,19 @@ +import { CONTEXT_KEY_CHAT_MESSAGE_EDIT } from '$lib/constants'; +import type { ChatMessageEditContext } from '$lib/types'; +import { getContext, setContext } from 'svelte'; + +const CHAT_MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_CHAT_MESSAGE_EDIT); + +/** + * Sets the message edit context. Call this in the parent component (ChatMessage.svelte). + */ +export function setChatMessageEditContext(ctx: ChatMessageEditContext): ChatMessageEditContext { + return setContext(CHAT_MESSAGE_EDIT_KEY, ctx); +} + +/** + * Gets the message edit context. Call this in child components. + */ +export function getChatMessageEditContext(): ChatMessageEditContext { + return getContext(CHAT_MESSAGE_EDIT_KEY); +} diff --git a/tools/ui/src/lib/contexts/chat-settings-config.context.ts b/tools/ui/src/lib/contexts/chat-settings-config.context.ts deleted file mode 100644 index a90f709b4..000000000 --- a/tools/ui/src/lib/contexts/chat-settings-config.context.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CONTEXT_KEY_CHAT_SETTINGS_CONFIG } from '$lib/constants'; -import { getContext, setContext } from 'svelte'; - -export interface ChatSettingsConfigContext { - readonly localConfig: SettingsConfigType; - handleConfigChange: (key: string, value: string | boolean) => void; - handleThemeChange: (theme: string) => void; -} - -const CHAT_SETTINGS_CONFIG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_CONFIG); - -export function setChatSettingsConfigContext( - ctx: ChatSettingsConfigContext -): ChatSettingsConfigContext { - return setContext(CHAT_SETTINGS_CONFIG_KEY, ctx); -} - -export function getChatSettingsConfigContext(): ChatSettingsConfigContext { - return getContext(CHAT_SETTINGS_CONFIG_KEY); -} diff --git a/tools/ui/src/lib/contexts/index.ts b/tools/ui/src/lib/contexts/index.ts index c6719fa9e..4aaec8148 100644 --- a/tools/ui/src/lib/contexts/index.ts +++ b/tools/ui/src/lib/contexts/index.ts @@ -1,19 +1,8 @@ -export { - getMessageEditContext, - setMessageEditContext, - type MessageEditContext, - type MessageEditState, - type MessageEditActions -} from './message-edit.context'; +export { getChatMessageEditContext, setChatMessageEditContext } from './chat-message-edit.context'; export { - getChatActionsContext, - setChatActionsContext, - type ChatActionsContext -} from './chat-actions.context'; + getChatMessageActionsContext, + setChatMessageActionsContext +} from './chat-message-actions.context'; -export { - getChatSettingsConfigContext, - setChatSettingsConfigContext, - type ChatSettingsConfigContext -} from './chat-settings-config.context'; +export { getChatFormActionsContext, setChatFormActionsContext } from './chat-form-actions.context'; diff --git a/tools/ui/src/lib/contexts/message-edit.context.ts b/tools/ui/src/lib/contexts/message-edit.context.ts deleted file mode 100644 index 80f3e6eee..000000000 --- a/tools/ui/src/lib/contexts/message-edit.context.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants'; -import { MessageRole } from '$lib/enums'; -import { getContext, setContext } from 'svelte'; - -export interface MessageEditState { - readonly isEditing: boolean; - readonly editedContent: string; - readonly editedExtras: DatabaseMessageExtra[]; - readonly editedUploadedFiles: ChatUploadedFile[]; - readonly originalContent: string; - readonly originalExtras: DatabaseMessageExtra[]; - readonly showSaveOnlyOption: boolean; - readonly showBranchAfterEditOption: boolean; - readonly shouldBranchAfterEdit: boolean; - readonly messageRole: MessageRole; - readonly rawEditContent?: string; -} - -export interface MessageEditActions { - setContent: (content: string) => void; - setExtras: (extras: DatabaseMessageExtra[]) => void; - setUploadedFiles: (files: ChatUploadedFile[]) => void; - save: () => void; - saveOnly: () => void; - cancel: () => void; - startEdit: () => void; -} - -export interface AssistantEditActions { - setShouldBranchAfterEdit: (value: boolean) => void; -} - -export type MessageEditContext = MessageEditState & - MessageEditActions & - Partial; - -const MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_MESSAGE_EDIT); - -/** - * Sets the message edit context. Call this in the parent component (ChatMessage.svelte). - */ -export function setMessageEditContext(ctx: MessageEditContext): MessageEditContext { - return setContext(MESSAGE_EDIT_KEY, ctx); -} - -/** - * Gets the message edit context. Call this in child components. - */ -export function getMessageEditContext(): MessageEditContext { - return getContext(MESSAGE_EDIT_KEY); -} diff --git a/tools/ui/src/lib/hooks/use-message-edit-context.svelte.ts b/tools/ui/src/lib/hooks/use-chat-message-edit-context.svelte.ts similarity index 91% rename from tools/ui/src/lib/hooks/use-message-edit-context.svelte.ts rename to tools/ui/src/lib/hooks/use-chat-message-edit-context.svelte.ts index 271675404..de2994e73 100644 --- a/tools/ui/src/lib/hooks/use-message-edit-context.svelte.ts +++ b/tools/ui/src/lib/hooks/use-chat-message-edit-context.svelte.ts @@ -1,15 +1,15 @@ -import { setMessageEditContext } from '$lib/contexts'; +import { setChatMessageEditContext } from '$lib/contexts'; import { MessageRole } from '$lib/enums'; import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra'; -interface UseMessageEditContextOptions { +interface UseChatMessageEditContextOptions { getContent: () => string; getExtras: () => DatabaseMessageExtra[]; showSaveOnlyOption?: boolean; onSave: (content: string, extras?: DatabaseMessageExtra[]) => void; } -export function useMessageEditContext(options: UseMessageEditContextOptions) { +export function useChatMessageEditContext(options: UseChatMessageEditContextOptions) { let isEditing = $state(false); let editedContent = $state(''); let editedExtras = $state([]); @@ -45,7 +45,7 @@ export function useMessageEditContext(options: UseMessageEditContextOptions) { isEditing = false; } - setMessageEditContext({ + setChatMessageEditContext({ cancel: handleCancelEdit, get editedContent() { return editedContent; diff --git a/tools/ui/src/lib/types/chat.d.ts b/tools/ui/src/lib/types/chat.d.ts index cb93e7795..f0f3a297e 100644 --- a/tools/ui/src/lib/types/chat.d.ts +++ b/tools/ui/src/lib/types/chat.d.ts @@ -7,7 +7,8 @@ import type { AttachmentMenuItemId, ChatFormCommandAction, ErrorDialogType, - FileMentionEntryType + FileMentionEntryType, + MessageRole } from '$lib/enums'; import type { Component } from 'svelte'; @@ -235,3 +236,111 @@ export interface ChatFormCommand { action: ChatFormCommandAction; disabled: boolean; } + +/** + * Data shown in the message delete confirmation dialog. + */ +export interface ChatMessageDeletionInfo { + totalCount: number; + userMessages: number; + assistantMessages: number; + messageTypes: string[]; +} + +/** + * Conversation-level message operations owned by ChatMessages (store calls + list + * refresh + user-action notification), passed to each ChatMessage as a prop. + */ +export interface ChatMessageActions { + copy: (message: DatabaseMessage) => void; + delete: (message: DatabaseMessage) => void; + navigateToSibling: (siblingId: string) => void; + editWithBranching: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; + editWithReplacement: ( + message: DatabaseMessage, + newContent: string, + shouldBranch: boolean + ) => void; + editUserMessagePreserveResponses: ( + message: DatabaseMessage, + newContent: string, + newExtras?: DatabaseMessageExtra[] + ) => void; + regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void; + continueAssistantMessage: (message: DatabaseMessage) => void; + forkConversation: ( + message: DatabaseMessage, + options: { name: string; includeAttachments: boolean } + ) => void; +} + +/** + * Per-message actions and state. Set once per message in ChatMessage.svelte and + * consumed by its descendants (action icons, branching controls). + */ +export interface ChatMessageActionsContext { + readonly siblingInfo: ChatMessageSiblingInfo | null; + readonly deletionInfo: ChatMessageDeletionInfo | null; + readonly showDeleteDialog: boolean; + copy: () => void; + requestDelete: () => void; + confirmDelete: () => void; + setShowDeleteDialog: (show: boolean) => void; + navigateToSibling: (siblingId: string) => void; + forkConversation?: (options: { name: string; includeAttachments: boolean }) => void; +} + +export interface ChatMessageEditState { + readonly isEditing: boolean; + readonly editedContent: string; + readonly editedExtras: DatabaseMessageExtra[]; + readonly editedUploadedFiles: ChatUploadedFile[]; + readonly originalContent: string; + readonly originalExtras: DatabaseMessageExtra[]; + readonly showSaveOnlyOption: boolean; + readonly showBranchAfterEditOption: boolean; + readonly shouldBranchAfterEdit: boolean; + readonly messageRole: MessageRole; + readonly rawEditContent?: string; +} + +export interface ChatMessageEditActions { + setContent: (content: string) => void; + setExtras: (extras: DatabaseMessageExtra[]) => void; + setUploadedFiles: (files: ChatUploadedFile[]) => void; + save: () => void; + saveOnly: () => void; + cancel: () => void; + startEdit: () => void; +} + +export interface ChatMessageAssistantEditActions { + setShouldBranchAfterEdit: (value: boolean) => void; +} + +export type ChatMessageEditContext = ChatMessageEditState & + ChatMessageEditActions & + Partial; + +/** + * Actions and capability flags for the ChatForm add-menu. Set once in + * ChatFormActions.svelte and consumed by its deep descendants (the add sheet, + * dropdown and MCP servers submenu) to avoid relaying them through props. + */ +export interface ChatFormActionsContext { + readonly disabled: boolean; + readonly hasAudioModality: boolean; + readonly hasVideoModality: boolean; + readonly hasVisionModality: boolean; + readonly hasMcpPromptsSupport: boolean; + readonly hasMcpResourcesSupport: boolean; + onFileUpload?: () => void; + onSystemPromptClick?: () => void; + onMcpPromptClick?: () => void; + onMcpResourcesClick?: () => void; + onMcpSettingsClick?: () => void; +} diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 917c9516b..34767e2ae 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -44,6 +44,14 @@ export type { ChatUploadedFile, ChatAttachmentDisplayItem, ChatMessageSiblingInfo, + ChatMessageActions, + ChatMessageActionsContext, + ChatMessageDeletionInfo, + ChatMessageEditContext, + ChatMessageEditState, + ChatMessageEditActions, + ChatMessageAssistantEditActions, + ChatFormActionsContext, ChatMessagePromptProgress, ChatMessageTimings, ChatMessageAgenticTimings, diff --git a/tools/ui/tests/stories/ChatMessage.stories.svelte b/tools/ui/tests/stories/ChatMessage.stories.svelte index 023ba1ac4..84fee2ea1 100644 --- a/tools/ui/tests/stories/ChatMessage.stories.svelte +++ b/tools/ui/tests/stories/ChatMessage.stories.svelte @@ -1,6 +1,7 @@ - +
- {#if useContenteditable} - { - pickers.handleInput(); - onValueChange?.(value); - }} - onPaste={handlePaste} - {disabled} - {placeholder} - /> - {:else} - { - pickers.handleInput(); - onValueChange?.(value); - }} - onPaste={handlePaste} - {disabled} - {placeholder} - /> - {/if} + { + pickers.handleInput(); + onValueChange?.(value); + }} + onPaste={handlePaste} + {disabled} + {placeholder} + {useContenteditable} + /> {#if mcpResourceStore.hasAttachments} {#if toolsStore.hasEnabledCwdTools} - - import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte'; - import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte'; + import ChatFormCurrentWorkingDirectoryChip from './ChatFormCurrentWorkingDirectoryChip.svelte'; + import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte'; import { FolderOpen } from '@lucide/svelte'; import SearchInput from '$lib/components/app/forms/SearchInput.svelte'; import * as Popover from '$lib/components/ui/popover'; @@ -250,7 +250,7 @@ // user cancelled - silently ignore; other errors are logged if (err instanceof DOMException && err.name === 'AbortError') return; - console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err); + console.error('[ChatFormCurrentWorkingDirectory] showDirectoryPicker failed:', err); } } @@ -331,7 +331,7 @@ onclick={onOpen} {disabled} > - {searchUnavailableMessage}
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)} - + import ChatFormInputBasic from './ChatFormInputBasic.svelte'; + import ChatFormInputRich from './ChatFormInputRich.svelte'; + + interface Props { + class?: string; + disabled?: boolean; + onInput?: () => void; + onKeydown?: (event: KeyboardEvent) => void; + onPaste?: (event: ClipboardEvent) => void; + placeholder?: string; + value?: string; + useContenteditable?: boolean; + } + + let { + class: className = '', + disabled = false, + onInput, + onKeydown, + onPaste, + placeholder = 'Ask anything...', + useContenteditable = false, + value = $bindable('') + }: Props = $props(); + + let basicRef: ChatFormInputBasic | undefined = $state(); + let richRef: ChatFormInputRich | undefined = $state(); + + // The two renderers share one imperative handle (focus/caret/height), so + // the parent can drive whichever variant is mounted through this one. + export function getElement() { + return useContenteditable ? richRef?.getElement() : basicRef?.getElement(); + } + + export function focus() { + if (useContenteditable) richRef?.focus(); + else basicRef?.focus(); + } + + export function resetHeight() { + if (useContenteditable) richRef?.resetHeight(); + else basicRef?.resetHeight(); + } + + export function getCaretOffset(): number { + return useContenteditable + ? (richRef?.getCaretOffset() ?? 0) + : (basicRef?.getCaretOffset() ?? 0); + } + + export function setCaretOffset(offset: number) { + if (useContenteditable) richRef?.setCaretOffset(offset); + else basicRef?.setCaretOffset(offset); + } + + +{#if useContenteditable} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte similarity index 100% rename from tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte rename to tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte similarity index 100% rename from tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte rename to tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte similarity index 97% rename from tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte rename to tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte index ba4430401..f25c3c95c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte @@ -2,7 +2,7 @@ import { CODE_BLOCK } from '$lib/constants'; import { ColorMode } from '$lib/enums'; import { isMobile } from '$lib/stores'; - import type { ContentEditableToken } from '$lib/types'; + import type { ChatFormInputRichToken } from '$lib/types'; import type { SourceHistoryEntry } from '$lib/utils'; import { badgeAwareWordJump, @@ -64,7 +64,7 @@ rootElement.dataset.empty = source.length === 0 ? 'true' : 'false'; } - function renderTokens(tokens: ContentEditableToken[]) { + function renderTokens(tokens: ChatFormInputRichToken[]) { if (!rootElement) return; const caret = rangeToTextOffset(rootElement, safeRange()); @@ -127,7 +127,7 @@ } function highlightCodeBlocks(root: HTMLElement) { - for (const el of root.querySelectorAll('code[data-code-token="block"]')) { + for (const el of root.querySelectorAll('code[data-code-token="code_block"]')) { highlightCodeBlockElement(el); } } @@ -151,7 +151,7 @@ } while (node && node !== rootElement) { - if (node instanceof HTMLElement && node.dataset.codeToken === 'block') { + if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') { const caret = rangeToTextOffset(rootElement, range); if (highlightCodeBlockElement(node)) { @@ -404,7 +404,7 @@ let node: Node | null = container.parentNode; while (node && node !== rootElement) { - if (node instanceof HTMLElement && node.dataset.codeToken === 'block') { + if (node instanceof HTMLElement && node.dataset.codeToken === 'code_block') { const tail = document.createRange(); tail.setStart(container, offset); @@ -462,7 +462,7 @@ const first = rootElement.firstChild; - if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false; + if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'code_block') return false; const range = safeRange(); @@ -507,7 +507,7 @@ const second = first.nextSibling; - if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return; + if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'code_block') return; const range = safeRange(); const onHatch = @@ -787,7 +787,7 @@ } -
+
/* pre-wrap is load-bearing: without it Chromium collapses \n in text nodes and converts them to spaces while typing */ - .chat-form-contenteditable { + .chat-form-input-rich { white-space: pre-wrap; } - .chat-form-contenteditable:global([data-empty='true'])::before { + .chat-form-input-rich:global([data-empty='true'])::before { content: attr(data-placeholder); color: var(--muted-foreground); pointer-events: none; } /* Inline code - mirrors markdown-content.css */ - .chat-form-contenteditable :global(code[data-code-token='inline']) { + .chat-form-input-rich :global(code[data-code-token='code_inline']) { background: var(--muted); color: var(--muted-foreground); padding: 0.125rem 0.375rem; @@ -835,7 +835,7 @@ } /* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */ - .chat-form-contenteditable :global(code[data-code-token='block']) { + .chat-form-input-rich :global(code[data-code-token='code_block']) { display: block; margin: 0.25rem 0; padding: 0.75rem 1rem; diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte similarity index 100% rename from tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte rename to tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte similarity index 100% rename from tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte rename to tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte index 7ba97cf9b..dbe03e2e0 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte @@ -1,7 +1,7 @@ - -