diff --git a/common/arg.cpp b/common/arg.cpp index edab6b2bf..6a216eb65 100644 --- a/common/arg.cpp +++ b/common/arg.cpp @@ -3309,6 +3309,16 @@ common_params_context common_params_parser_init(common_params & params, llama_ex params.server_tools = parse_csv_row(value); } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS")); + add_opt(common_arg( + {"--tools-runtime"}, "OPTION", + "experimental: run tools in a separate runtime environment (default: none, use host environment)\n" + "available options:\n" + " 'docker:': spin up a new Docker container and reuse it for all invocations, clean up on server exit\n" + " 'docker-container:': use an existing Docker container by ID, won't stop on server exit\n", + [](common_params & params, const std::string & value) { + params.server_tools_runtime = value; + } + ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS_RUNTIME")); add_opt(common_arg( {"--mcp-servers-config"}, "PATH", "experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)\n" diff --git a/common/common.h b/common/common.h index c9c0c42a2..2a1927dc1 100644 --- a/common/common.h +++ b/common/common.h @@ -656,6 +656,7 @@ struct common_params { // enable built-in tools std::vector server_tools; + std::string server_tools_runtime; // MCP server configs (Cursor-compatible JSON) std::string mcp_servers_config; // path to JSON file with MCP server definitions diff --git a/conversion/__init__.py b/conversion/__init__.py index 1f781a790..3b8bebdae 100644 --- a/conversion/__init__.py +++ b/conversion/__init__.py @@ -103,6 +103,7 @@ TEXT_MODEL_MAP: dict[str, str] = { "GraniteMoeForCausalLM": "granite", "GraniteMoeHybridForCausalLM": "granite", "GraniteMoeSharedForCausalLM": "granite", + "GraniteSwitchForCausalLM": "granite", "GraniteSpeechForConditionalGeneration": "granite", "GraniteSpeechPlusForConditionalGeneration": "granite", "Grok1ForCausalLM": "grok", diff --git a/conversion/granite.py b/conversion/granite.py index 8367ed225..956342e6d 100644 --- a/conversion/granite.py +++ b/conversion/granite.py @@ -123,6 +123,166 @@ class GraniteMoeModel(GraniteModel): yield from super().modify_tensors(data_torch, name, bid) +@ModelBase.register("GraniteSwitchForCausalLM") +class GraniteSwitchModel(GraniteMoeModel): + """Dense, all-attention Granite with N per-token embedded LoRA adapters, stacked + over the adapter dim with a zero adapter at slot 0 (N = num_adapters + 1).""" + model_arch = gguf.MODEL_ARCH.GRANITE_SWITCH + + # permute q/k per-slice below (NORM-rope layout), not via the parent's auto-permute + undo_permute = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # the weightless switch reserves one cache slot: one fewer block than num_hidden_layers + self.block_count = self.block_count - 1 + self.tensor_map = gguf.get_tensor_name_map(self.model_arch, self.block_count) + + self._n_adapters = int(self.hparams["num_adapters"]) + self._max_lora_rank = int(self.hparams["max_lora_rank"]) + self._n_slots = self._n_adapters + 1 # +1 for the zero slot at index 0 + + n_head = int(self.hparams["num_attention_heads"]) + n_kv_head = int(self.hparams["num_key_value_heads"]) + head_dim = ( + self.hparams.get("projection_head_dim") + or self.hparams.get("head_dim") + or (self.hparams["hidden_size"] // n_head) + ) + self._n_head = n_head + self._n_kv_head = n_kv_head + self._head_dim = int(head_dim) + self._q_size = n_head * self._head_dim + self._kv_size = n_kv_head * self._head_dim + + def set_gguf_parameters(self): + super().set_gguf_parameters() + + # dense: pin expert_used_count to 0 (config carries a leftover num_experts_per_tok) + if not self.hparams.get("num_local_experts"): + self.gguf_writer.add_expert_used_count(0) + + self.gguf_writer.add_adapter_count(self._n_adapters) + self.gguf_writer.add_adapter_lora_rank(self._max_lora_rank) + self.gguf_writer.add_adapter_token_ids_activate(self.hparams["adapter_token_ids"]) + self.gguf_writer.add_adapter_token_ids_substitute(self.hparams["adapter_substitute_token_ids"]) + router_gain = float(self.hparams.get("control_token_gain", 15.0)) + self.gguf_writer.add_adapter_router_gain(router_gain) + logger.info("gguf: (graniteswitch) num_adapters=%s max_lora_rank=%s n_slots=%s router_gain=%s", self._n_adapters, self._max_lora_rank, self._n_slots, router_gain) + + def _lora_a(self, data: Tensor) -> Tensor: + # on-disk A: [n_adapters, 1, max_rank, in] -> [n_adapters+1, max_rank, in] + a = data.squeeze(1) + zero = torch.zeros_like(a[:1]) + return torch.cat([zero, a], dim=0).contiguous() + + def _lora_b(self, data: Tensor, permute_n_head: int | None = None) -> Tensor: + # on-disk B: [n_adapters, 1, out, max_rank] -> [n_adapters+1, out, max_rank] + b = data.squeeze(1) + if permute_n_head is not None: + # permute each adapter's B output rows to match the permuted q/k base + b = torch.stack([self.permute(b[i], permute_n_head, permute_n_head) for i in range(b.shape[0])], dim=0) + zero = torch.zeros_like(b[:1]) + return torch.cat([zero, b], dim=0).contiguous() + + def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]: + T = gguf.MODEL_TENSOR + + # skip the weightless switch + control-token buffers (rebuilt at load time) + bare = name.split(".")[-1] + if ( + name.startswith("model.switch.") or name.startswith("switch.") + or bare in ("adapter_token_ids", "control_to_substitute_lut") + ): + return + + if "self_attn.qkv_proj" in name: + if name.endswith("base_layer.weight"): + # fused [q|k|v] rows: permute q/k row-blocks for ggml's NORM-rope layout + q, k, v = data_torch.split([self._q_size, self._kv_size, self._kv_size], dim=0) + q = self.permute(q, self._n_head, self._n_head) + k = self.permute(k, self._n_kv_head, self._n_kv_head) + fused = torch.cat([q, k, v], dim=0) + yield (self.format_tensor_name(T.ATTN_QKV, bid), fused) + return + if "lora_A_slices." in name: + slot = int(name.rsplit(".", 1)[1]) + key = {0: T.ATTN_Q, 1: T.ATTN_K, 2: T.ATTN_V}[slot] + yield (self.format_tensor_name(key, bid, suffix=".lora_a"), self._lora_a(data_torch)) + return + if "lora_B_slices." in name: + slot = int(name.rsplit(".", 1)[1]) + key, ph = { + 0: (T.ATTN_Q, self._n_head), + 1: (T.ATTN_K, self._n_kv_head), + 2: (T.ATTN_V, None), + }[slot] + yield (self.format_tensor_name(key, bid, suffix=".lora_b"), self._lora_b(data_torch, ph)) + return + raise ValueError(f"Unexpected qkv_proj tensor: {name}") + + if "self_attn.o_proj" in name: + if name.endswith("base_layer.weight"): + yield (self.format_tensor_name(T.ATTN_OUT, bid), data_torch) + return + if name.endswith("lora_A"): + yield (self.format_tensor_name(T.ATTN_OUT, bid, suffix=".lora_a"), self._lora_a(data_torch)) + return + if name.endswith("lora_B"): + yield (self.format_tensor_name(T.ATTN_OUT, bid, suffix=".lora_b"), self._lora_b(data_torch)) + return + raise ValueError(f"Unexpected o_proj tensor: {name}") + + if "shared_mlp.input_linear" in name: + ffn = self.hparams["shared_intermediate_size"] + if name.endswith("base_layer.weight"): + gate, up = data_torch.split([ffn, ffn], dim=0) + yield (self.format_tensor_name(T.FFN_GATE, bid), gate) + yield (self.format_tensor_name(T.FFN_UP, bid), up) + return + if "lora_A_slices." in name: + slot = int(name.rsplit(".", 1)[1]) + key = {0: T.FFN_GATE, 1: T.FFN_UP}[slot] + yield (self.format_tensor_name(key, bid, suffix=".lora_a"), self._lora_a(data_torch)) + return + if "lora_B_slices." in name: + slot = int(name.rsplit(".", 1)[1]) + key = {0: T.FFN_GATE, 1: T.FFN_UP}[slot] + yield (self.format_tensor_name(key, bid, suffix=".lora_b"), self._lora_b(data_torch)) + return + raise ValueError(f"Unexpected shared_mlp.input_linear tensor: {name}") + + if "shared_mlp.output_linear" in name: + if name.endswith("base_layer.weight"): + yield (self.format_tensor_name(T.FFN_DOWN, bid), data_torch) + return + if name.endswith("lora_A"): + yield (self.format_tensor_name(T.FFN_DOWN, bid, suffix=".lora_a"), self._lora_a(data_torch)) + return + if name.endswith("lora_B"): + yield (self.format_tensor_name(T.FFN_DOWN, bid, suffix=".lora_b"), self._lora_b(data_torch)) + return + raise ValueError(f"Unexpected shared_mlp.output_linear tensor: {name}") + + if bid is not None and ".layers." in name and ( + "input_layernorm" in name or "post_attention_layernorm" in name + ): + key = T.ATTN_NORM if "input_layernorm" in name else T.FFN_NORM + yield (self.format_tensor_name(key, bid), data_torch) + return + + if name in ("model.embed_tokens.weight", "embed_tokens.weight"): + yield (self.format_tensor_name(T.TOKEN_EMBD), data_torch) + return + if name in ("model.norm.weight", "norm.weight"): + yield (self.format_tensor_name(T.OUTPUT_NORM), data_torch) + return + if name == "lm_head.weight": + return # tied to token_embd + + raise ValueError(f"graniteswitch: unhandled tensor {name!r} (bid={bid})") + + @ModelBase.register("GraniteMoeHybridForCausalLM", "BambaForCausalLM") class GraniteHybridModel(Mamba2Model, GraniteMoeModel): """GraniteHybrid is a hybrid SSM + Attention model that uses Mamba2 SSM diff --git a/ggml/src/ggml-cpu/spacemit/ime.cpp b/ggml/src/ggml-cpu/spacemit/ime.cpp index 9563ea3e4..29d683270 100644 --- a/ggml/src/ggml-cpu/spacemit/ime.cpp +++ b/ggml/src/ggml-cpu/spacemit/ime.cpp @@ -195,6 +195,7 @@ template class tensor_ case GGML_TYPE_Q4_K: case GGML_TYPE_Q6_K: case GGML_TYPE_Q8_0: + case GGML_TYPE_Q5_0: case GGML_TYPE_Q5_1: case GGML_TYPE_Q5_K: //case GGML_TYPE_MXFP4: @@ -214,6 +215,7 @@ template class tensor_ case GGML_TYPE_Q4_K: case GGML_TYPE_Q6_K: case GGML_TYPE_Q8_0: + case GGML_TYPE_Q5_0: case GGML_TYPE_Q5_1: case GGML_TYPE_Q5_K: //case GGML_TYPE_MXFP4: diff --git a/ggml/src/ggml-cuda/cpy.cu b/ggml/src/ggml-cuda/cpy.cu index eb5eb0eb4..fd7ffc0bc 100644 --- a/ggml/src/ggml-cuda/cpy.cu +++ b/ggml/src/ggml-cuda/cpy.cu @@ -253,9 +253,9 @@ static void ggml_cpy_f32_q8_0_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK8_0 == 0); - const int64_t num_blocks = ne / QK8_0; + const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -264,9 +264,9 @@ static void ggml_cpy_q8_0_f32_cuda( const int64_t ne00, const int64_t ne01, const int64_t ne02, const int64_t nb00, const int64_t nb01, const int64_t nb02, const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32<<>> + cpy_q_f32<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -276,9 +276,9 @@ static void ggml_cpy_f32_q4_0_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK4_0 == 0); - const int64_t num_blocks = ne / QK4_0; + const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -289,9 +289,9 @@ static void ggml_cpy_q4_0_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK4_0><<>>( + cpy_q_f32, QK4_0><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -302,9 +302,9 @@ static void ggml_cpy_f32_q4_1_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK4_1 == 0); - const int64_t num_blocks = ne / QK4_1; + const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -315,9 +315,9 @@ static void ggml_cpy_q4_1_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK4_1><<>>( + cpy_q_f32, QK4_1><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -328,9 +328,9 @@ static void ggml_cpy_f32_q5_0_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK5_0 == 0); - const int64_t num_blocks = ne / QK5_0; + const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -341,9 +341,9 @@ static void ggml_cpy_q5_0_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK5_0><<>>( + cpy_q_f32, QK5_0><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -354,9 +354,9 @@ static void ggml_cpy_f32_q5_1_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK5_1 == 0); - const int64_t num_blocks = ne / QK5_1; + const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -367,9 +367,9 @@ static void ggml_cpy_q5_1_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK5_1><<>>( + cpy_q_f32, QK5_1><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -380,9 +380,9 @@ static void ggml_cpy_f32_iq4_nl_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK4_NL == 0); - const int64_t num_blocks = ne / QK4_NL; + const int64_t num_blocks = (ne/QK4_NL + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } diff --git a/ggml/src/ggml-cuda/ggml-cuda.cu b/ggml/src/ggml-cuda/ggml-cuda.cu index ba73cf9b8..6a8e77fbd 100644 --- a/ggml/src/ggml-cuda/ggml-cuda.cu +++ b/ggml/src/ggml-cuda/ggml-cuda.cu @@ -2659,6 +2659,52 @@ static bool ggml_cuda_should_fuse_rope_set_rows(const ggml_tensor * rope, return true; } +static bool ggml_cuda_should_fuse_rms_norm_mul_rope(const ggml_tensor * rms_norm, + const ggml_tensor * mul, + const ggml_tensor * rope) { + if (rms_norm->op != GGML_OP_RMS_NORM || mul->op != GGML_OP_MUL || rope->op != GGML_OP_ROPE) { + return false; + } + + if (rms_norm->src[0]->type != GGML_TYPE_F32 || rms_norm->type != GGML_TYPE_F32 || + mul->src[0]->type != GGML_TYPE_F32 || mul->src[1]->type != GGML_TYPE_F32 || + mul->type != GGML_TYPE_F32 || rope->type != GGML_TYPE_F32) { + return false; + } + + if (rope->src[0] != mul) { + return false; + } + + //if rms norm is the B operand, then we don't handle broadcast + if (rms_norm == mul->src[1] && !ggml_are_same_shape(mul->src[0], rms_norm)) { + return false; + } + + if (!ggml_are_same_shape(rms_norm, mul)) { + return false; + } + + //rms_norm kernel assumes contiguous rows + if (!ggml_is_contiguous_rows(rms_norm->src[0]) || + !ggml_is_contiguous_rows(mul->src[0]) || !ggml_is_contiguous_rows(mul->src[1])) { + return false; + } + + // the fused kernel handles the norm/neox rope modes only + const int mode = ((const int32_t *) rope->op_params)[2]; + if (mode != GGML_ROPE_TYPE_NORMAL && mode != GGML_ROPE_TYPE_NEOX) { + return false; + } + + const int n_dims = ((const int32_t *) rope->op_params)[1]; + if (n_dims % 2 != 0 || rope->src[0]->ne[0] % 2 != 0) { + return false; + } + + return true; +} + // match gated_delta_net + the strided cpy that scatters its state snapshots into the cache // (slot i -> rollback group i, slot 0 newest), so the kernel can write them and skip the cpy. static int ggml_cuda_try_gdn_cache_fusion( @@ -2988,6 +3034,36 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph, } } + std::initializer_list rms_norm_mul_rope_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE }; + std::initializer_list rms_norm_mul_rope_set_rows_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }; + + if (is_equal(rms_norm_mul_rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 4 })) { + const ggml_tensor * rms_norm = cgraph->nodes[node_idx]; + const ggml_tensor * mul = cgraph->nodes[node_idx + 1]; + const ggml_tensor * rope = cgraph->nodes[node_idx + 2]; + const ggml_tensor * view = cgraph->nodes[node_idx + 3]; + const ggml_tensor * set_rows = cgraph->nodes[node_idx + 4]; + + if (ggml_check_edges(cgraph, node_idx, {{1, 0, 0}, {2, 0, 1}, {3, 0, 2}, {4, 0, 3}}) && + ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope) && + ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) { + int out_nodes[] = { node_idx + 4 }; + return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1); + } + } + + if (is_equal(rms_norm_mul_rope_ops, ops) && ggml_can_fuse(cgraph, node_idx, ops)) { + const ggml_tensor * rms_norm = cgraph->nodes[node_idx]; + const ggml_tensor * mul = cgraph->nodes[node_idx + 1]; + const ggml_tensor * rope = cgraph->nodes[node_idx + 2]; + + if (ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope)) { + int out_nodes[] = { node_idx + 2 }; + return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1); + } + return false; + } + std::initializer_list rope_set_rows_ops = { GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }; if (is_equal(rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 2 })) { @@ -2996,7 +3072,8 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph, const ggml_tensor * set_rows = cgraph->nodes[node_idx + 2]; if (ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) { - return true; + int out_nodes[] = { node_idx + 2 }; + return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1); } } @@ -3848,6 +3925,16 @@ static int ggml_cuda_try_fuse(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph return fused_node_count - 1; } + if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }, {})) { + ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], cgraph->nodes[i + 4]); + return 4; + } + + if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE }, {})) { + ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], nullptr); + return 2; + } + if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ADD }, {})) { ggml_cuda_op_rms_norm_fused_add(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2]); return 2; diff --git a/ggml/src/ggml-cuda/rope.cu b/ggml/src/ggml-cuda/rope.cu index e20a5cb6b..504c6b818 100644 --- a/ggml/src/ggml-cuda/rope.cu +++ b/ggml/src/ggml-cuda/rope.cu @@ -670,3 +670,238 @@ void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst) void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rope, ggml_tensor * set_rows) { ggml_cuda_op_rope_impl(ctx, rope, set_rows); } + +// fused RMS_NORM + MUL + ROPE (+ VIEW + SET_ROWS) +// one block per row: block_reduce gives the norm scale, then each thread applies mul and rope to the elements it owns +template +static __global__ void rms_norm_mul_rope_f32( + const float * x, D * dst, const int ncols, + const int64_t s01, const int64_t s02, const int64_t s03, + const int64_t s1, const int64_t s2, const int64_t s3, + const float eps, + const float * mul, + const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03, + const uint3 mul_ncols_packed, const uint3 mul_nrows_packed, + const uint3 mul_nchannels_packed, const uint3 mul_nsamples_packed, + const int n_dims, const int32_t * pos, + const float freq_scale, const float ext_factor, const float attn_factor, + const rope_corr_dims corr_dims, const float theta_scale, + const float * freq_factors, + const int64_t * row_indices, const int set_rows_stride, + const bool is_neox) { + ggml_cuda_pdl_lc(); + const int row = blockIdx.x; + const int channel = blockIdx.y; + const int sample = blockIdx.z; + const int tid = threadIdx.x; + + x += sample*s03 + channel*s02 + row*s01; + + const uint32_t mul_row = fastmodulo(row, mul_nrows_packed); + const uint32_t mul_channel = fastmodulo(channel, mul_nchannels_packed); + const uint32_t mul_sample = fastmodulo(sample, mul_nsamples_packed); + mul += mul_sample*mul_s03 + mul_channel*mul_s02 + mul_row*mul_s01; + + float tmp = 0.0f; + + ggml_cuda_pdl_sync(); + for (int col = tid; col < ncols; col += block_size) { + const float xi = x[col]; + tmp += xi * xi; + } + + extern __shared__ float s_sum[]; + tmp = block_reduce(tmp, s_sum); + + const float scale = rsqrtf(tmp/ncols + eps); + + int64_t idst = sample*s3 + channel*s2 + row*s1; + if (set_rows_stride != 0) { + idst = row*s1 + row_indices[channel]*set_rows_stride; + } + dst += idst; + + for (int i0 = 2*tid; i0 < ncols; i0 += 2*block_size) { + int ix0; + int ix1; + if (is_neox && i0 < n_dims) { + ix0 = i0/2; + ix1 = i0/2 + n_dims/2; + } else { + ix0 = i0 + 0; + ix1 = i0 + 1; + } + + const float x0 = scale * x[ix0] * mul[fastmodulo(ix0, mul_ncols_packed)]; + const float x1 = scale * x[ix1] * mul[fastmodulo(ix1, mul_ncols_packed)]; + + if (i0 >= n_dims) { + dst[ix0] = ggml_cuda_cast(x0); + dst[ix1] = ggml_cuda_cast(x1); + continue; + } + + const float theta_base = pos[channel]*powf(theta_scale, i0/2.0f); + const float freq_factor = has_ff ? freq_factors[i0/2] : 1.0f; + + float cos_theta; + float sin_theta; + rope_yarn(theta_base/freq_factor, freq_scale, corr_dims, i0, ext_factor, attn_factor, cos_theta, sin_theta); + + dst[ix0] = ggml_cuda_cast(x0*cos_theta - x1*sin_theta); + dst[ix1] = ggml_cuda_cast(x0*sin_theta + x1*cos_theta); + } +} + +template +static void rms_norm_mul_rope_cuda( + const float * x, D * dst, + const int ncols, const int nrows, const int nchannels, const int nsamples, + const int64_t s01, const int64_t s02, const int64_t s03, + const int64_t s1, const int64_t s2, const int64_t s3, + const float eps, + const float * mul, + const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03, + const uint32_t mul_ncols, const uint32_t mul_nrows, + const uint32_t mul_nchannels, const uint32_t mul_nsamples, + const int n_dims, const int32_t * pos, + const float freq_scale, const float freq_base, const float ext_factor, const float attn_factor, + const rope_corr_dims corr_dims, + const float * freq_factors, + const int64_t * row_indices, const int set_rows_stride, + const bool is_neox, cudaStream_t stream) { + GGML_ASSERT(ncols % 2 == 0); + + const dim3 blocks_num(nrows, nchannels, nsamples); + + const float theta_scale = powf(freq_base, -2.0f/n_dims); + + const uint3 mul_ncols_packed = init_fastdiv_values(mul_ncols); + const uint3 mul_nrows_packed = init_fastdiv_values(mul_nrows); + const uint3 mul_nchannels_packed = init_fastdiv_values(mul_nchannels); + const uint3 mul_nsamples_packed = init_fastdiv_values(mul_nsamples); + + if (ncols < 1024) { + const dim3 block_dims(256, 1, 1); + const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream}; + if (freq_factors == nullptr) { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, false, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } else { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, true, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } + } else { + const dim3 block_dims(1024, 1, 1); + const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream}; + if (freq_factors == nullptr) { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, false, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } else { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, true, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } + } +} + +void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx, + ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows) { + const ggml_tensor * x = rms_norm->src[0]; + const ggml_tensor * mul_src = mul->src[0] == rms_norm ? mul->src[1] : mul->src[0]; + + float eps = 0.0f; + memcpy(&eps, rms_norm->op_params, sizeof(float)); + GGML_ASSERT(eps >= 0.0f); + + GGML_ASSERT(x->type == GGML_TYPE_F32); + GGML_ASSERT(mul_src->type == GGML_TYPE_F32); + GGML_ASSERT(rope->type == GGML_TYPE_F32); + + void * dst_d = rope->data; + ggml_type dst_type = rope->type; + const int64_t * row_indices = nullptr; + int set_rows_stride = 0; + + if (set_rows != nullptr) { + dst_d = set_rows->data; + dst_type = set_rows->type; + row_indices = (const int64_t *) set_rows->src[1]->data; + set_rows_stride = set_rows->nb[1] / ggml_type_size(set_rows->type); + } + + const int n_dims = ((const int32_t *) rope->op_params)[1]; + const int mode = ((const int32_t *) rope->op_params)[2]; + const int n_ctx_orig = ((const int32_t *) rope->op_params)[4]; + + float freq_base; + float freq_scale; + float ext_factor; + float attn_factor; + float beta_fast; + float beta_slow; + + memcpy(&freq_base, (const int32_t *) rope->op_params + 5, sizeof(float)); + memcpy(&freq_scale, (const int32_t *) rope->op_params + 6, sizeof(float)); + memcpy(&ext_factor, (const int32_t *) rope->op_params + 7, sizeof(float)); + memcpy(&attn_factor, (const int32_t *) rope->op_params + 8, sizeof(float)); + memcpy(&beta_fast, (const int32_t *) rope->op_params + 9, sizeof(float)); + memcpy(&beta_slow, (const int32_t *) rope->op_params + 10, sizeof(float)); + + const bool is_neox = mode & GGML_ROPE_TYPE_NEOX; + + const int32_t * pos = (const int32_t *) rope->src[1]->data; + + const float * freq_factors = rope->src[2] != nullptr ? (const float *) rope->src[2]->data : nullptr; + + rope_corr_dims corr_dims; + ggml_rope_yarn_corr_dims(n_dims, n_ctx_orig, freq_base, beta_fast, beta_slow, corr_dims.v); + + const size_t ts0 = ggml_type_size(x->type); + GGML_ASSERT(x->nb[0] == ts0); + const int64_t s01 = x->nb[1] / ts0; + const int64_t s02 = x->nb[2] / ts0; + const int64_t s03 = x->nb[3] / ts0; + + const size_t ts_mul = ggml_type_size(mul_src->type); + GGML_ASSERT(mul_src->nb[0] == ts_mul); + const int64_t mul_s01 = mul_src->nb[1] / ts_mul; + const int64_t mul_s02 = mul_src->nb[2] / ts_mul; + const int64_t mul_s03 = mul_src->nb[3] / ts_mul; + + const size_t ts_dst = ggml_type_size(rope->type); + const int64_t s1 = rope->nb[1] / ts_dst; + const int64_t s2 = rope->nb[2] / ts_dst; + const int64_t s3 = rope->nb[3] / ts_dst; + + cudaStream_t stream = ctx.stream(); + + if (dst_type == GGML_TYPE_F32) { + rms_norm_mul_rope_cuda((const float *) x->data, (float *) dst_d, + x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps, + (const float *) mul_src->data, mul_s01, mul_s02, mul_s03, + mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3], + n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims, + freq_factors, row_indices, set_rows_stride, is_neox, stream); + } else if (dst_type == GGML_TYPE_F16) { + rms_norm_mul_rope_cuda((const float *) x->data, (half *) dst_d, + x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps, + (const float *) mul_src->data, mul_s01, mul_s02, mul_s03, + mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3], + n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims, + freq_factors, row_indices, set_rows_stride, is_neox, stream); + } else { + GGML_ABORT("fatal error"); + } +} diff --git a/ggml/src/ggml-cuda/rope.cuh b/ggml/src/ggml-cuda/rope.cuh index 72af086cd..7ce2d71c5 100644 --- a/ggml/src/ggml-cuda/rope.cuh +++ b/ggml/src/ggml-cuda/rope.cuh @@ -7,3 +7,5 @@ void ggml_cuda_op_rope(ggml_backend_cuda_context & ctx, ggml_tensor * dst); void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst); void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * dst, ggml_tensor * set_rows); + +void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows); diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py index 8516222cc..304100f7f 100644 --- a/gguf-py/gguf/constants.py +++ b/gguf-py/gguf/constants.py @@ -164,6 +164,13 @@ class Keys: NORM_BEFORE_RESIDUAL = "{arch}.norm_before_residual" NORM_BEFORE_FC = "{arch}.norm_before_fc" + class Adapters: + COUNT = "{arch}.adapters.count" + TOKEN_IDS_ACTIVATE = "{arch}.adapters.token_ids_activate" + TOKEN_IDS_SUBSTITUTE = "{arch}.adapters.token_ids_substitute" + LORA_RANK = "{arch}.adapters.lora_rank" + ROUTER_GAIN = "{arch}.adapters.router_gain" + class Attention: HEAD_COUNT = "{arch}.attention.head_count" HEAD_COUNT_KV = "{arch}.attention.head_count_kv" @@ -527,6 +534,7 @@ class MODEL_ARCH(IntEnum): GRANITE = auto() GRANITE_MOE = auto() GRANITE_HYBRID = auto() + GRANITE_SWITCH = auto() CHAMELEON = auto() WAVTOKENIZER_DEC = auto() PLM = auto() @@ -1198,6 +1206,7 @@ MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = { MODEL_ARCH.GRANITE: "granite", MODEL_ARCH.GRANITE_MOE: "granitemoe", MODEL_ARCH.GRANITE_HYBRID: "granitehybrid", + MODEL_ARCH.GRANITE_SWITCH: "graniteswitch", MODEL_ARCH.CHAMELEON: "chameleon", MODEL_ARCH.WAVTOKENIZER_DEC: "wavtokenizer-dec", MODEL_ARCH.PLM: "plm", @@ -3972,6 +3981,21 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = { MODEL_TENSOR.FFN_DOWN, MODEL_TENSOR.FFN_UP, ], + MODEL_ARCH.GRANITE_SWITCH: [ + MODEL_TENSOR.TOKEN_EMBD, + MODEL_TENSOR.OUTPUT_NORM, + MODEL_TENSOR.OUTPUT, + MODEL_TENSOR.ATTN_NORM, + MODEL_TENSOR.ATTN_QKV, + MODEL_TENSOR.ATTN_Q, + MODEL_TENSOR.ATTN_K, + MODEL_TENSOR.ATTN_V, + MODEL_TENSOR.ATTN_OUT, + MODEL_TENSOR.FFN_NORM, + MODEL_TENSOR.FFN_GATE, + MODEL_TENSOR.FFN_DOWN, + MODEL_TENSOR.FFN_UP, + ], MODEL_ARCH.CHAMELEON: [ MODEL_TENSOR.TOKEN_EMBD, MODEL_TENSOR.OUTPUT_NORM, diff --git a/gguf-py/gguf/gguf_writer.py b/gguf-py/gguf/gguf_writer.py index 39da9f2c0..81ae07c11 100644 --- a/gguf-py/gguf/gguf_writer.py +++ b/gguf-py/gguf/gguf_writer.py @@ -906,6 +906,21 @@ class GGUFWriter: def add_embedding_scale(self, value: float) -> None: self.add_float32(Keys.LLM.EMBEDDING_SCALE.format(arch=self.arch), value) + def add_adapter_count(self, count: int) -> None: + self.add_uint32(Keys.Adapters.COUNT.format(arch=self.arch), count) + + def add_adapter_token_ids_activate(self, ids: Sequence[int]) -> None: + self.add_array(Keys.Adapters.TOKEN_IDS_ACTIVATE.format(arch=self.arch), ids) + + def add_adapter_token_ids_substitute(self, ids: Sequence[int]) -> None: + self.add_array(Keys.Adapters.TOKEN_IDS_SUBSTITUTE.format(arch=self.arch), ids) + + def add_adapter_lora_rank(self, rank: int) -> None: + self.add_uint32(Keys.Adapters.LORA_RANK.format(arch=self.arch), rank) + + def add_adapter_router_gain(self, gain: float) -> None: + self.add_float32(Keys.Adapters.ROUTER_GAIN.format(arch=self.arch), gain) + def add_wkv_head_size(self, size: int) -> None: self.add_uint32(Keys.WKV.HEAD_SIZE.format(arch=self.arch), size) diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp index 836cfade2..b8231ba6f 100644 --- a/src/llama-arch.cpp +++ b/src/llama-arch.cpp @@ -100,6 +100,7 @@ static const std::map LLM_ARCH_NAMES = { { LLM_ARCH_GRANITE, "granite" }, { LLM_ARCH_GRANITE_MOE, "granitemoe" }, { LLM_ARCH_GRANITE_HYBRID, "granitehybrid" }, + { LLM_ARCH_GRANITE_SWITCH, "graniteswitch" }, { LLM_ARCH_CHAMELEON, "chameleon" }, { LLM_ARCH_WAVTOKENIZER_DEC, "wavtokenizer-dec" }, { LLM_ARCH_PLM, "plm" }, @@ -220,6 +221,11 @@ static const std::map LLM_KV_NAMES = { { LLM_KV_TIME_DECAY_EXTRA_DIM, "%s.time_decay_extra_dim" }, { LLM_KV_RESIDUAL_SCALE, "%s.residual_scale" }, { LLM_KV_EMBEDDING_SCALE, "%s.embedding_scale" }, + { LLM_KV_ADAPTER_COUNT, "%s.adapters.count" }, + { LLM_KV_ADAPTER_TOKEN_IDS_ACTIVATE, "%s.adapters.token_ids_activate" }, + { LLM_KV_ADAPTER_TOKEN_IDS_SUBSTITUTE, "%s.adapters.token_ids_substitute" }, + { LLM_KV_ADAPTER_LORA_RANK, "%s.adapters.lora_rank" }, + { LLM_KV_ADAPTER_ROUTER_GAIN, "%s.adapters.router_gain" }, { LLM_KV_TOKEN_SHIFT_COUNT, "%s.token_shift_count" }, { LLM_KV_INTERLEAVE_MOE_LAYER_STEP, "%s.interleave_moe_layer_step" }, { LLM_KV_FULL_ATTENTION_INTERVAL, "%s.full_attention_interval" }, diff --git a/src/llama-arch.h b/src/llama-arch.h index 49c2a6ac3..47adc3d68 100644 --- a/src/llama-arch.h +++ b/src/llama-arch.h @@ -105,6 +105,7 @@ enum llm_arch { LLM_ARCH_GRANITE, LLM_ARCH_GRANITE_MOE, LLM_ARCH_GRANITE_HYBRID, + LLM_ARCH_GRANITE_SWITCH, LLM_ARCH_CHAMELEON, LLM_ARCH_WAVTOKENIZER_DEC, LLM_ARCH_PLM, @@ -225,6 +226,11 @@ enum llm_kv { LLM_KV_TIME_DECAY_EXTRA_DIM, LLM_KV_RESIDUAL_SCALE, LLM_KV_EMBEDDING_SCALE, + LLM_KV_ADAPTER_COUNT, + LLM_KV_ADAPTER_TOKEN_IDS_ACTIVATE, + LLM_KV_ADAPTER_TOKEN_IDS_SUBSTITUTE, + LLM_KV_ADAPTER_LORA_RANK, + LLM_KV_ADAPTER_ROUTER_GAIN, LLM_KV_TOKEN_SHIFT_COUNT, LLM_KV_INTERLEAVE_MOE_LAYER_STEP, LLM_KV_FULL_ATTENTION_INTERVAL, diff --git a/src/llama-context.cpp b/src/llama-context.cpp index 0dbc299ec..dc70dd8dc 100644 --- a/src/llama-context.cpp +++ b/src/llama-context.cpp @@ -3612,8 +3612,9 @@ llama_context * llama_init_from_model( model->hparams.pooling_type, params.pooling_type); } + // router_layer >= 0 means n_layer_nextn is repurposed for a router layer, not real MTP if (params.ctx_type == LLAMA_CONTEXT_TYPE_MTP && - model->hparams.n_layer_nextn == 0) { + (model->hparams.n_layer_nextn == 0 || model->hparams.router_layer >= 0)) { LLAMA_LOG_WARN("%s: context type MTP requested but model doesn't contain MTP layers\n", __func__); return nullptr; } diff --git a/src/llama-hparams.cpp b/src/llama-hparams.cpp index 846d4c69a..781277f3f 100644 --- a/src/llama-hparams.cpp +++ b/src/llama-hparams.cpp @@ -277,6 +277,16 @@ bool llama_hparams::has_kv(uint32_t il) const { return true; } +bool llama_hparams::has_rope(uint32_t il) const { + // the router layer stores adapter routing signal, not positional info, + // so it must not be RoPE-shifted + if (router_layer >= 0 && (int32_t) il == router_layer) { + return false; + } + + return true; +} + uint32_t llama_hparams::n_layer() const { return n_layer_all - n_layer_nextn; } diff --git a/src/llama-hparams.h b/src/llama-hparams.h index 6e8336c98..57de80824 100644 --- a/src/llama-hparams.h +++ b/src/llama-hparams.h @@ -53,6 +53,10 @@ struct llama_hparams { uint32_t n_embd; uint32_t n_layer_all; uint32_t n_layer_nextn = 0; + + // granite-switch: index of the single-head "router" KV layer that encodes + // per-token adapter selection. -1 when the model has no such layer. + int32_t router_layer = -1; uint32_t n_expert = 0; uint32_t n_expert_used = 0; uint32_t n_rel_attn_bkts = 0; @@ -371,6 +375,8 @@ struct llama_hparams { bool has_kv(uint32_t il) const; + bool has_rope(uint32_t il) const; + // number of effective layers (excludes nextn layers) uint32_t n_layer() const; diff --git a/src/llama-kv-cache.cpp b/src/llama-kv-cache.cpp index 46bbc992a..865f720fe 100644 --- a/src/llama-kv-cache.cpp +++ b/src/llama-kv-cache.cpp @@ -1936,6 +1936,10 @@ ggml_cgraph * llama_kv_cache::build_graph_shift(llm_graph_result * res, llama_co for (const auto & layer : layers) { const uint32_t il = layer.il; + if (!hparams.has_rope(il)) { + continue; + } + const int64_t n_head_kv = hparams.n_head_kv(il); const int64_t n_embd_k_gqa = hparams.n_embd_k_gqa(il); diff --git a/src/llama-model-loader.cpp b/src/llama-model-loader.cpp index 753bf7b31..748c86c4b 100644 --- a/src/llama-model-loader.cpp +++ b/src/llama-model-loader.cpp @@ -938,10 +938,11 @@ static bool weight_buft_supported(const llama_hparams & hparams, ggml_tensor * w } break; case GGML_OP_MUL_MAT_ID: { - const int n_expert_used = hparams.n_expert_used; - GGML_ASSERT(n_expert_used > 0); - ggml_tensor * b = ggml_new_tensor_3d(ctx, GGML_TYPE_F32, w->ne[0], n_expert_used, 512); - ggml_tensor * ids = ggml_new_tensor_2d(ctx, GGML_TYPE_I32, n_expert_used, 512); + // Used for either MoE expert routing or embedded adapter routing + const int n_ids_used = hparams.router_layer >= 0 ? 1 : hparams.n_expert_used; + GGML_ASSERT(n_ids_used > 0); + ggml_tensor * b = ggml_new_tensor_3d(ctx, GGML_TYPE_F32, w->ne[0], n_ids_used, 512); + ggml_tensor * ids = ggml_new_tensor_2d(ctx, GGML_TYPE_I32, n_ids_used, 512); op_tensor = ggml_mul_mat_id(ctx, w, b, ids); } break; case GGML_OP_ADD: @@ -1124,15 +1125,14 @@ struct ggml_tensor * llama_model_loader::create_tensor( return nullptr; } - // tensors with "bias" suffix are always used with GGML_OP_ADD or GGML_OP_ADD_ID + // tensors with "bias" suffix are always used with GGML_OP_ADD or GGML_OP_ADD_ID; + // embedded-adapter ".lora_a"/".lora_b" tensors are always used with GGML_OP_MUL_MAT_ID ggml_op op; - bool bias = tn.suffix != nullptr && strcmp(tn.suffix, "bias") == 0; - if (bias) { - if (info.op == GGML_OP_MUL_MAT_ID) { - op = GGML_OP_ADD_ID; - } else { - op = GGML_OP_ADD; - } + if (tn.suffix != nullptr && strcmp(tn.suffix, "bias") == 0) { + op = info.op == GGML_OP_MUL_MAT_ID ? GGML_OP_ADD_ID : GGML_OP_ADD; + } else if (hparams.router_layer >= 0 && tn.suffix != nullptr && + (strcmp(tn.suffix, "lora_a") == 0 || strcmp(tn.suffix, "lora_b") == 0)) { + op = GGML_OP_MUL_MAT_ID; } else { op = info.op; } diff --git a/src/llama-model-saver.cpp b/src/llama-model-saver.cpp index 3812c594e..248d1ed38 100644 --- a/src/llama-model-saver.cpp +++ b/src/llama-model-saver.cpp @@ -213,7 +213,7 @@ void llama_model_saver::add_kv_from_model() { add_kv(LLM_KV_FEED_FORWARD_LENGTH, hparams.n_ff_arr, true); add_kv(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp); add_kv(LLM_KV_EXPERT_SHARED_FEED_FORWARD_LENGTH, hparams.n_ff_shexp); - add_kv(LLM_KV_EXPERT_SHARED_FEED_FORWARD_LENGTH, hparams.n_ff_chexp); + add_kv(LLM_KV_EXPERT_CHUNK_FEED_FORWARD_LENGTH, hparams.n_ff_chexp); add_kv(LLM_KV_SWIGLU_CLAMP_EXP, hparams.swiglu_clamp_exp); add_kv(LLM_KV_SWIGLU_CLAMP_SHEXP, hparams.swiglu_clamp_shexp); add_kv(LLM_KV_USE_PARALLEL_RESIDUAL, hparams.use_par_res); diff --git a/src/llama-model.cpp b/src/llama-model.cpp index 6cb4cf833..1ba795763 100644 --- a/src/llama-model.cpp +++ b/src/llama-model.cpp @@ -91,6 +91,7 @@ #include "models/gptneox.cpp" #include "models/granite-hybrid.cpp" #include "models/granite-moe.cpp" +#include "models/granite-switch.cpp" #include "models/granite.cpp" #include "models/grok.cpp" #include "models/grovemoe.cpp" @@ -376,6 +377,8 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params return new llama_model_granite(params); case LLM_ARCH_GRANITE_MOE: return new llama_model_granite_moe(params); + case LLM_ARCH_GRANITE_SWITCH: + return new llama_model_granite_switch(params); case LLM_ARCH_MINICPM: return new llama_model_minicpm(params); case LLM_ARCH_GRANITE_HYBRID: @@ -2054,6 +2057,7 @@ void llama_model::print_info() const { arch == LLM_ARCH_GRANITE || arch == LLM_ARCH_GRANITE_MOE || arch == LLM_ARCH_GRANITE_HYBRID || + arch == LLM_ARCH_GRANITE_SWITCH || arch == LLM_ARCH_NEMOTRON_H_MOE) { LLAMA_LOG_INFO("%s: f_embedding_scale = %f\n", __func__, hparams.f_embedding_scale); LLAMA_LOG_INFO("%s: f_residual_scale = %f\n", __func__, hparams.f_residual_scale); @@ -2738,6 +2742,7 @@ llama_rope_type llama_model_rope_type(const llama_model * model) { case LLM_ARCH_GRANITE: case LLM_ARCH_GRANITE_MOE: case LLM_ARCH_GRANITE_HYBRID: + case LLM_ARCH_GRANITE_SWITCH: case LLM_ARCH_CHAMELEON: case LLM_ARCH_BAILINGMOE: case LLM_ARCH_NEO_BERT: diff --git a/src/llama-model.h b/src/llama-model.h index 6b9e94a0a..1dd090438 100644 --- a/src/llama-model.h +++ b/src/llama-model.h @@ -223,6 +223,24 @@ struct llama_layer_nextn { struct ggml_tensor * shared_head_norm = nullptr; }; +struct llama_layer_switch_lora { + struct ggml_tensor * a_q = nullptr; + struct ggml_tensor * b_q = nullptr; + struct ggml_tensor * a_k = nullptr; + struct ggml_tensor * b_k = nullptr; + struct ggml_tensor * a_v = nullptr; + struct ggml_tensor * b_v = nullptr; + struct ggml_tensor * a_o = nullptr; + struct ggml_tensor * b_o = nullptr; + + struct ggml_tensor * a_gate = nullptr; + struct ggml_tensor * b_gate = nullptr; + struct ggml_tensor * a_up = nullptr; + struct ggml_tensor * b_up = nullptr; + struct ggml_tensor * a_down = nullptr; + struct ggml_tensor * b_down = nullptr; +}; + struct llama_layer { // normalization struct ggml_tensor * attn_norm = nullptr; @@ -533,6 +551,8 @@ struct llama_layer { struct llama_layer_shortconv shortconv; struct llama_layer_nextn nextn; + + struct llama_layer_switch_lora switch_lora; }; struct llama_device { diff --git a/src/models/granite-switch.cpp b/src/models/granite-switch.cpp new file mode 100644 index 000000000..80f6b86ed --- /dev/null +++ b/src/models/granite-switch.cpp @@ -0,0 +1,426 @@ +#include "models.h" + +#include + +void llama_model_granite_switch::load_arch_hparams(llama_model_loader & ml) { + ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps); + ml.get_key(LLM_KV_LOGIT_SCALE, hparams.f_logit_scale); + ml.get_key(LLM_KV_RESIDUAL_SCALE, hparams.f_residual_scale, false); + ml.get_key(LLM_KV_EMBEDDING_SCALE, hparams.f_embedding_scale, false); + ml.get_key(LLM_KV_ATTENTION_SCALE, hparams.f_attention_scale, false); + + bool rope_finetuned = true; + ml.get_key(LLM_KV_ROPE_SCALING_FINETUNED, rope_finetuned, false); + hparams.rope_finetuned = rope_finetuned; + + switch (hparams.n_layer()) { + case 40: type = hparams.n_embd == 4096 ? LLM_TYPE_8B : LLM_TYPE_3B; break; + case 64: type = LLM_TYPE_30B; break; + default: type = LLM_TYPE_UNKNOWN; + } + + ml.get_key(LLM_KV_EXPERT_SHARED_FEED_FORWARD_LENGTH, hparams.n_ff_shexp, /* required */ false); + + ml.get_key(LLM_KV_ADAPTER_COUNT, n_adapters); + ml.get_key(LLM_KV_ADAPTER_LORA_RANK, max_lora_rank); + ml.get_key(LLM_KV_ADAPTER_ROUTER_GAIN, router_gain, /* required */ false); + + // bound counts that size tensors + if (n_adapters > 4096) { + throw std::runtime_error(format("graniteswitch: invalid adapter count %u", n_adapters)); + } + if (max_lora_rank > 4096) { + throw std::runtime_error(format("graniteswitch: invalid lora rank %u", max_lora_rank)); + } + + std::vector token_ids; + std::vector substitute_ids; + ml.get_arr(LLM_KV_ADAPTER_TOKEN_IDS_ACTIVATE, token_ids); + ml.get_arr(LLM_KV_ADAPTER_TOKEN_IDS_SUBSTITUTE, substitute_ids); + + if (token_ids.size() != n_adapters || substitute_ids.size() != n_adapters) { + throw std::runtime_error(format( + "graniteswitch: adapter token id arrays (%zu activate, %zu substitute) do not match adapter count %u", + token_ids.size(), substitute_ids.size(), n_adapters)); + } + + adapter_token_to_slot.clear(); + adapter_token_to_substitute.clear(); + for (uint32_t i = 0; i < n_adapters; ++i) { + // adapter i -> stacked slot i+1 (slot 0 is the base/zero delta) + adapter_token_to_slot[token_ids[i]] = (int32_t) (i + 1); + adapter_token_to_substitute[token_ids[i]] = substitute_ids[i]; + } + + // extra single-head attention layer at the END (index n_real) holds the router + // K/V. reusing n_layer_nextn keeps n_layer() == n_real, so the regular layers + // keep their indices and the KV cache shift/defrag skips the router layer. + // n_layer_nextn is repurposed here (no MTP): it leaks as 1 into the + // llama_model_n_layer_nextn() getter and a re-saved nextn_predict_layers + const uint32_t n_real = hparams.n_layer(); + if (n_real >= LLAMA_MAX_LAYERS) { + throw std::runtime_error(format("graniteswitch: block count %u exceeds LLAMA_MAX_LAYERS", n_real)); + } + hparams.router_layer = (int32_t) n_real; + hparams.n_layer_all = n_real + 1; + hparams.n_layer_nextn = 1; + + hparams.n_head_arr[n_real] = 1; + hparams.n_head_kv_arr[n_real] = 1; + hparams.n_ff_arr[n_real] = 0; +} + +void llama_model_granite_switch::load_arch_tensors(llama_model_loader &) { + LLAMA_LOAD_LOCALS; + + const int64_t n_slots = (int64_t) n_adapters + 1; // slot 0 = base/zero delta + const int64_t n_rank = (int64_t) max_lora_rank; + const int64_t n_embd_q = n_embd_head_k * n_head; + const int64_t n_embd_kv = n_embd_k_gqa; + + tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0); + + // substitute ids index tok_embd rows directly; range-check against n_vocab + for (const auto & kv : adapter_token_to_substitute) { + const llama_token sub = kv.second; + if (sub < 0 || (int64_t) sub >= n_vocab) { + throw std::runtime_error(format( + "graniteswitch: substitute token id %d out of range [0, %d)", sub, (int) n_vocab)); + } + } + + output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0); + output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED); + if (output == NULL) { + output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED); + } + + for (int i = 0; i < n_layer; ++i) { + auto & layer = layers[i]; + + layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0); + + layer.wqkv = create_tensor(tn(LLM_TENSOR_ATTN_QKV, "weight", i), {n_embd, n_embd_q + 2*n_embd_kv}, 0); + layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_q, n_embd}, 0); + + layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0); + + layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0); + layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0); + layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0); + + auto & sl = layer.switch_lora; + + sl.a_q = create_tensor(tn(LLM_TENSOR_ATTN_Q, "lora_a", i), {n_embd, n_rank, n_slots}, 0); + sl.b_q = create_tensor(tn(LLM_TENSOR_ATTN_Q, "lora_b", i), {n_rank, n_embd_q, n_slots}, 0); + sl.a_k = create_tensor(tn(LLM_TENSOR_ATTN_K, "lora_a", i), {n_embd, n_rank, n_slots}, 0); + sl.b_k = create_tensor(tn(LLM_TENSOR_ATTN_K, "lora_b", i), {n_rank, n_embd_kv, n_slots}, 0); + sl.a_v = create_tensor(tn(LLM_TENSOR_ATTN_V, "lora_a", i), {n_embd, n_rank, n_slots}, 0); + sl.b_v = create_tensor(tn(LLM_TENSOR_ATTN_V, "lora_b", i), {n_rank, n_embd_kv, n_slots}, 0); + + sl.a_o = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "lora_a", i), {n_embd_q, n_rank, n_slots}, 0); + sl.b_o = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "lora_b", i), {n_rank, n_embd, n_slots}, 0); + + sl.a_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "lora_a", i), {n_embd, n_rank, n_slots}, 0); + sl.b_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "lora_b", i), {n_rank, n_ff, n_slots}, 0); + sl.a_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "lora_a", i), {n_embd, n_rank, n_slots}, 0); + sl.b_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "lora_b", i), {n_rank, n_ff, n_slots}, 0); + sl.a_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "lora_a", i), { n_ff, n_rank, n_slots}, 0); + sl.b_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "lora_b", i), {n_rank, n_embd, n_slots}, 0); + } +} + +class llm_graph_input_switch : public llm_graph_input_i { +public: + llm_graph_input_switch(const llama_model_granite_switch & smodel) : smodel(smodel) {} + virtual ~llm_graph_input_switch() = default; + + void set_input(const llama_ubatch * ubatch) override; + + ggml_tensor * sub_tokens = nullptr; // I32 [n_tokens] adapter-substituted token ids + ggml_tensor * router_ksig = nullptr; // F32 [n_tokens] router K signal (+/-gain) + ggml_tensor * router_vval = nullptr; // F32 [n_tokens] router V value (adapter slot / 0) + ggml_tensor * router_q = nullptr; // F32 [n_tokens] router Q value (constant 1.0) + + const llama_model_granite_switch & smodel; +}; + +// K dim-0 is +gain for an adapter token, -gain otherwise; the causal softmax then +// lets a single visible adapter token dominate so the readback recovers its slot. +void llm_graph_input_switch::set_input(const llama_ubatch * ubatch) { + if (!ubatch->token) { + return; + } + + const int64_t n_tokens = ubatch->n_tokens; + + std::vector sub (n_tokens); + std::vector ksig(n_tokens); + std::vector vval(n_tokens); + std::vector q (n_tokens, 1.0f); + + for (int64_t i = 0; i < n_tokens; ++i) { + const llama_token tok = ubatch->token[i]; + + const auto it = smodel.adapter_token_to_slot.find(tok); + if (it != smodel.adapter_token_to_slot.end()) { + ksig[i] = +smodel.router_gain; + vval[i] = (float) it->second; + } else { + ksig[i] = -smodel.router_gain; + vval[i] = 0.0f; + } + + const auto sit = smodel.adapter_token_to_substitute.find(tok); + sub[i] = (sit != smodel.adapter_token_to_substitute.end()) + ? (int32_t) sit->second + : (int32_t) tok; + } + + ggml_backend_tensor_set(sub_tokens, sub.data(), 0, n_tokens*ggml_element_size(sub_tokens)); + ggml_backend_tensor_set(router_ksig, ksig.data(), 0, n_tokens*ggml_element_size(router_ksig)); + ggml_backend_tensor_set(router_vval, vval.data(), 0, n_tokens*ggml_element_size(router_vval)); + ggml_backend_tensor_set(router_q, q.data(), 0, n_tokens*ggml_element_size(router_q)); +} + +std::unique_ptr llama_model_granite_switch::build_arch_graph(const llm_graph_params & params) const { + return std::make_unique(*this, params); +} + +// per-token switched LoRA delta: B_a*(A_a*x), adapter selected per token via ids. +// cur: {n_in, n_tokens}, ids: {n_tokens} -> {n_out, n_tokens} +ggml_tensor * llama_model_granite_switch::graph::build_switched_lora_delta( + ggml_tensor * lora_a, + ggml_tensor * lora_b, + ggml_tensor * cur, + ggml_tensor * ids) { + const int64_t n_in = cur->ne[0]; + const int64_t n_tokens = cur->ne[1]; + + ggml_tensor * x = ggml_reshape_3d(ctx0, cur, n_in, 1, n_tokens); + ggml_tensor * ids2 = ggml_reshape_2d(ctx0, ids, 1, n_tokens); + + ggml_tensor * a = ggml_mul_mat_id(ctx0, lora_a, x, ids2); // {max_rank, 1, n_tokens} + ggml_tensor * d = ggml_mul_mat_id(ctx0, lora_b, a, ids2); // {n_out, 1, n_tokens} + + return ggml_reshape_2d(ctx0, d, d->ne[0], n_tokens); +} + +ggml_tensor * llama_model_granite_switch::graph::build_switched_lora_mm( + ggml_tensor * w, + ggml_tensor * lora_a, + ggml_tensor * lora_b, + ggml_tensor * cur, + ggml_tensor * ids) { + ggml_tensor * base = ggml_mul_mat(ctx0, w, cur); + ggml_tensor * delta = build_switched_lora_delta(lora_a, lora_b, cur, ids); + return ggml_add(ctx0, base, delta); +} + +llama_model_granite_switch::graph::graph( + const llama_model & model, + const llm_graph_params & params) + : llm_graph_context(params) { + + const auto & smodel = static_cast(model); + + // TODO: support raw embedding input (multimodal / pre-embedded tokens) when needed + GGML_ASSERT(ubatch.token && "granite-switch requires token input"); + + const int64_t n_embd_head = hparams.n_embd_head_v(); + GGML_ASSERT(n_embd_head == hparams.n_embd_head_k()); + GGML_ASSERT(n_embd_head == n_rot); + + auto inp_switch = std::make_unique(smodel); + inp_switch->sub_tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, n_tokens); + inp_switch->router_ksig = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, n_tokens); + inp_switch->router_vval = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, n_tokens); + inp_switch->router_q = ggml_new_tensor_1d(ctx0, GGML_TYPE_F32, n_tokens); + ggml_set_input(inp_switch->sub_tokens); + ggml_set_input(inp_switch->router_ksig); + ggml_set_input(inp_switch->router_vval); + ggml_set_input(inp_switch->router_q); + ggml_tensor * sub_tokens = inp_switch->sub_tokens; + ggml_tensor * router_ksig = inp_switch->router_ksig; + ggml_tensor * router_vval = inp_switch->router_vval; + ggml_tensor * router_q = inp_switch->router_q; + res->add_input(std::move(inp_switch)); + + // embed the substituted ids directly; build_inp_embd would embed the raw tokens + ggml_tensor * inpL = ggml_get_rows(ctx0, model.tok_embd, sub_tokens); + if (hparams.f_embedding_scale != 0.0f) { + inpL = ggml_scale(ctx0, inpL, hparams.f_embedding_scale); + } + cb(inpL, "inp_embd", -1); + + ggml_tensor * inp_pos = nullptr; + if (hparams.rope_finetuned) { + inp_pos = build_inp_pos(); + } + auto * inp_attn = build_attn_inp_kv(); + + // single causal head at layer R recovers the adapter index in-graph: only dim 0 + // carries signal (Q[0]=1, K[0]=+/-gain, V[0]=slot/0), the rest is zero-padded. + const int R = hparams.router_layer; + GGML_ASSERT(R >= 0); + auto router_lane = [&](ggml_tensor * sig1d) { + ggml_tensor * t = ggml_reshape_3d(ctx0, sig1d, 1, 1, n_tokens); + return ggml_pad(ctx0, t, (int) n_embd_head - 1, 0, 0, 0); + }; + ggml_tensor * Qr = router_lane(router_q); + ggml_tensor * Kr = router_lane(router_ksig); + ggml_tensor * Vr = router_lane(router_vval); + + ggml_tensor * router_out = build_attn(inp_attn, + nullptr, nullptr, nullptr, + Qr, Kr, Vr, nullptr, nullptr, nullptr, /*kq_scale=*/1.0f, /*il=*/R); + cb(router_out, "router_out", R); + + // row 0 of router_out is the attended slot; clamp+round to an I32 index + ggml_tensor * slot_f = ggml_cont(ctx0, + ggml_view_2d(ctx0, router_out, 1, n_tokens, router_out->nb[1], 0)); + slot_f = ggml_reshape_1d(ctx0, slot_f, n_tokens); + slot_f = ggml_clamp(ctx0, slot_f, 0.0f, (float) smodel.n_adapters); + slot_f = ggml_round(ctx0, slot_f); + ggml_tensor * adapter_ids = ggml_cast(ctx0, slot_f, GGML_TYPE_I32); + cb(adapter_ids, "adapter_ids", -1); + + ggml_tensor * inp_out_ids = build_inp_out_ids(); + + ggml_tensor * cur; + + for (int il = 0; il < n_layer; ++il) { + ggml_tensor * inpSA = inpL; + + cur = build_norm(inpL, model.layers[il].attn_norm, NULL, LLM_NORM_RMS, il); + cb(cur, "attn_norm", il); + + cur = build_attention_layer(cur, inp_pos, adapter_ids, inp_attn, model, n_embd_head, il); + + if (il == n_layer - 1 && inp_out_ids) { + cur = ggml_get_rows(ctx0, cur, inp_out_ids); + inpSA = ggml_get_rows(ctx0, inpSA, inp_out_ids); + // keep adapter_ids aligned to the kept rows (2D round-trip for get_rows) + const int64_t n_out = inp_out_ids->ne[0]; + adapter_ids = ggml_get_rows(ctx0, + ggml_reshape_2d(ctx0, adapter_ids, 1, adapter_ids->ne[0]), inp_out_ids); + adapter_ids = ggml_reshape_1d(ctx0, adapter_ids, n_out); + } + + cur = build_layer_ffn(cur, inpSA, adapter_ids, model, il); + + inpL = cur; + } + + cur = inpL; + + cur = build_norm(cur, model.output_norm, NULL, LLM_NORM_RMS, -1); + cb(cur, "result_norm", -1); + res->t_embd = cur; + + cur = build_lora_mm(model.output, cur, model.output_s); + + cur = ggml_scale(ctx0, cur, 1.0f / hparams.f_logit_scale); + cb(cur, "result_output", -1); + res->t_logits = cur; + + ggml_build_forward_expand(gf, cur); +} + +ggml_tensor * llama_model_granite_switch::graph::build_attention_layer( + ggml_tensor * cur, + ggml_tensor * inp_pos, + ggml_tensor * adapter_ids, + llm_graph_input_attn_kv * inp_attn, + const llama_model & model, + const int64_t n_embd_head, + const int il) { + + const auto & layer = model.layers[il]; + const auto & sl = layer.switch_lora; + + const int64_t n_head = hparams.n_head(il); + const int64_t n_head_kv = hparams.n_head_kv(il); + + ggml_tensor * qkv = ggml_mul_mat(ctx0, layer.wqkv, cur); + cb(qkv, "wqkv", il); + + const int64_t n_embd_q = n_embd_head * n_head; + const int64_t n_embd_kv = n_embd_head * n_head_kv; + + // slice fused qkv into Q/K/V, made contiguous so LoRA deltas can be added + ggml_tensor * Qcur = ggml_cont(ctx0, ggml_view_2d(ctx0, qkv, n_embd_q, qkv->ne[1], qkv->nb[1], 0)); + ggml_tensor * Kcur = ggml_cont(ctx0, ggml_view_2d(ctx0, qkv, n_embd_kv, qkv->ne[1], qkv->nb[1], n_embd_q*ggml_element_size(qkv))); + ggml_tensor * Vcur = ggml_cont(ctx0, ggml_view_2d(ctx0, qkv, n_embd_kv, qkv->ne[1], qkv->nb[1], (n_embd_q + n_embd_kv)*ggml_element_size(qkv))); + + Qcur = ggml_add(ctx0, Qcur, build_switched_lora_delta(sl.a_q, sl.b_q, cur, adapter_ids)); + Kcur = ggml_add(ctx0, Kcur, build_switched_lora_delta(sl.a_k, sl.b_k, cur, adapter_ids)); + Vcur = ggml_add(ctx0, Vcur, build_switched_lora_delta(sl.a_v, sl.b_v, cur, adapter_ids)); + + Qcur = ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head, n_tokens); + Kcur = ggml_reshape_3d(ctx0, Kcur, n_embd_head, n_head_kv, n_tokens); + Vcur = ggml_reshape_3d(ctx0, Vcur, n_embd_head, n_head_kv, n_tokens); + + if (hparams.rope_finetuned) { + ggml_tensor * rope_factors = model.get_rope_factors(cparams, il); + Qcur = ggml_rope_ext(ctx0, Qcur, inp_pos, rope_factors, + n_rot, rope_type, n_ctx_orig, freq_base, freq_scale, + ext_factor, attn_factor, beta_fast, beta_slow); + Kcur = ggml_rope_ext(ctx0, Kcur, inp_pos, rope_factors, + n_rot, rope_type, n_ctx_orig, freq_base, freq_scale, + ext_factor, attn_factor, beta_fast, beta_slow); + } + cb(Qcur, "Qcur", il); + cb(Kcur, "Kcur", il); + cb(Vcur, "Vcur", il); + + const float kq_scale = hparams.f_attention_scale == 0.0f + ? 1.0f/sqrtf(float(n_embd_head)) : hparams.f_attention_scale; + + // wo = nullptr so build_attn returns concatenated heads; o-proj is switched below + ggml_tensor * attn = build_attn(inp_attn, + nullptr, nullptr, nullptr, + Qcur, Kcur, Vcur, nullptr, nullptr, nullptr, kq_scale, il); + cb(attn, "attn_pre_o", il); + + cur = build_switched_lora_mm(layer.wo, sl.a_o, sl.b_o, attn, adapter_ids); + cb(cur, "attn_out", il); + return cur; +} + +ggml_tensor * llama_model_granite_switch::graph::build_layer_ffn( + ggml_tensor * cur, + ggml_tensor * inpSA, + ggml_tensor * adapter_ids, + const llama_model & model, + const int il) { + + const auto & layer = model.layers[il]; + const auto & sl = layer.switch_lora; + + if (hparams.f_residual_scale) { + cur = ggml_scale(ctx0, cur, hparams.f_residual_scale); + } + ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpSA); + cb(ffn_inp, "ffn_inp", il); + + cur = build_norm(ffn_inp, layer.ffn_norm, NULL, LLM_NORM_RMS, il); + cb(cur, "ffn_norm", il); + + ggml_tensor * g = build_switched_lora_mm(layer.ffn_gate, sl.a_gate, sl.b_gate, cur, adapter_ids); + ggml_tensor * u = build_switched_lora_mm(layer.ffn_up, sl.a_up, sl.b_up, cur, adapter_ids); + g = ggml_silu(ctx0, g); + ggml_tensor * gu = ggml_mul(ctx0, g, u); + cur = build_switched_lora_mm(layer.ffn_down, sl.a_down, sl.b_down, gu, adapter_ids); + cb(cur, "ffn_out", il); + + if (hparams.f_residual_scale) { + cur = ggml_scale(ctx0, cur, hparams.f_residual_scale); + } + cur = ggml_add(ctx0, cur, ffn_inp); + + cur = build_cvec(cur, il); + cb(cur, "l_out", il); + + return cur; +} diff --git a/src/models/models.h b/src/models/models.h index ad3dadaf3..a8908da42 100644 --- a/src/models/models.h +++ b/src/models/models.h @@ -1596,6 +1596,56 @@ struct llama_model_granite_moe : public llama_model_base { }; +struct llama_model_granite_switch : public llama_model_base { + llama_model_granite_switch(const struct llama_model_params & params) : llama_model_base(params) {} + void load_arch_hparams(llama_model_loader & ml) override; + void load_arch_tensors(llama_model_loader & ml) override; + + uint32_t n_adapters = 0; + uint32_t max_lora_rank = 0; + float router_gain = 15.0f; + + std::unordered_map adapter_token_to_slot; + std::unordered_map adapter_token_to_substitute; + + struct graph : public llm_graph_context { + graph(const llama_model & model, const llm_graph_params & params); + + private: + ggml_tensor * build_switched_lora_delta( + ggml_tensor * lora_a, + ggml_tensor * lora_b, + ggml_tensor * cur, + ggml_tensor * ids); + + ggml_tensor * build_switched_lora_mm( + ggml_tensor * w, + ggml_tensor * lora_a, + ggml_tensor * lora_b, + ggml_tensor * cur, + ggml_tensor * ids); + + ggml_tensor * build_attention_layer( + ggml_tensor * cur, + ggml_tensor * inp_pos, + ggml_tensor * adapter_ids, + llm_graph_input_attn_kv * inp_attn, + const llama_model & model, + const int64_t n_embd_head, + const int il); + + ggml_tensor * build_layer_ffn( + ggml_tensor * cur, + ggml_tensor * inpSA, + ggml_tensor * adapter_ids, + const llama_model & model, + const int il); + }; + + std::unique_ptr build_arch_graph(const llm_graph_params & params) const override; +}; + + struct llama_model_minicpm : public llama_model_base { llama_model_minicpm(const struct llama_model_params & params) : llama_model_base(params) {} void load_arch_hparams(llama_model_loader & ml) override; diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md index 45bcdcca7..31408f426 100644 --- a/tools/server/README-dev.md +++ b/tools/server/README-dev.md @@ -201,6 +201,7 @@ Invoke a tool call, request body is a JSON object with: Headers: - `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself +- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Only `docker-container:` is supported for now, using an already-running container Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string): diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 2fcb2a3c8..27c663fdd 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -10,12 +10,15 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include #if defined(_WIN32) # ifndef NOMINMAX @@ -71,6 +74,7 @@ json server_tool::to_json() const { {"permissions", json{ {"write", permission_write} }}, + {"uses_cwd", uses_cwd}, {"definition", get_definition()}, }; } @@ -127,6 +131,13 @@ static int entry_depth(const std::string & rel) { return 1 + (int) std::count(rel.begin(), rel.end(), '/'); } +// directories that a listing reports but never descends into: they can be enormous +// lowercase only, the local walker case-folds a name before the lookup +static const char * const SERVER_TOOL_JUNK_DIR_NAMES[] = { + ".git", ".svn", ".hg", "node_modules", "__pycache__", + ".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode", +}; + class tools_io { public: struct exec_result { @@ -165,6 +176,85 @@ public: const std::function & on_chunk = nullptr) const = 0; }; +// shared subprocess execution helper, used by both the local and the docker-backed tools_io implementations. +// combine_stderr=false when the raw stdout bytes must not be tainted by stderr, e.g. reading file contents. +static tools_io::exec_result run_subprocess( + const std::vector & args, + size_t max_output, + int timeout_secs, + const std::function & on_chunk, + bool combine_stderr, + const std::string & cwd = "") { + tools_io::exec_result res; + + common_subproc proc; + + int options = subprocess_option_no_window + | subprocess_option_inherit_environment + | subprocess_option_search_user_path; + if (combine_stderr) { + options |= subprocess_option_combined_stdout_stderr; + } + + if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) { + res.output = "failed to spawn process"; + return res; + } + + std::atomic done{false}; + std::atomic timed_out{false}; + + std::thread timeout_thread([&]() { + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs); + while (!done.load()) { + if (std::chrono::steady_clock::now() >= deadline) { + timed_out.store(true); + proc.terminate(); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + }); + + FILE * f = proc.stdout_file(); + std::string output; + bool truncated = false; + if (f) { + char buf[4096]; + while (fgets(buf, sizeof(buf), f) != nullptr) { + if (!truncated) { + size_t len = strlen(buf); + if (output.size() + len <= max_output) { + output.append(buf, len); + if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) { + proc.terminate(); + break; + } + } else { + size_t remaining = max_output - output.size(); + output.append(buf, remaining); + if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining))); + truncated = true; + } + } + } + } + + done.store(true); + if (timeout_thread.joinable()) { + timeout_thread.join(); + } + + res.exit_code = proc.join(); + + res.output = console_output_to_utf8(output); + res.timed_out = timed_out.load(); + if (truncated) { + res.output += "\n[output truncated]"; + } + return res; +} + class tools_io_basic : public tools_io { public: // cwd, if non-empty, is used to resolve relative paths and as the working directory for run() @@ -276,72 +366,7 @@ public: size_t max_output, int timeout_secs, const std::function & on_chunk = nullptr) const override { - exec_result res; - - common_subproc proc; - - int options = subprocess_option_no_window - | subprocess_option_combined_stdout_stderr - | subprocess_option_inherit_environment - | subprocess_option_search_user_path; - - if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) { - res.output = "failed to spawn process"; - return res; - } - - std::atomic done{false}; - std::atomic timed_out{false}; - - std::thread timeout_thread([&]() { - auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs); - while (!done.load()) { - if (std::chrono::steady_clock::now() >= deadline) { - timed_out.store(true); - proc.terminate(); - return; - } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - }); - - FILE * f = proc.stdout_file(); - std::string output; - bool truncated = false; - if (f) { - char buf[4096]; - while (fgets(buf, sizeof(buf), f) != nullptr) { - if (!truncated) { - size_t len = strlen(buf); - if (output.size() + len <= max_output) { - output.append(buf, len); - if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) { - proc.terminate(); - break; - } - } else { - size_t remaining = max_output - output.size(); - output.append(buf, remaining); - if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining))); - truncated = true; - } - } - } - } - - done.store(true); - if (timeout_thread.joinable()) { - timeout_thread.join(); - } - - res.exit_code = proc.join(); - - res.output = console_output_to_utf8(output); - res.timed_out = timed_out.load(); - if (truncated) { - res.output += "\n[output truncated]"; - } - return res; + return run_subprocess(args, max_output, timeout_secs, on_chunk, /*combine_stderr=*/true, cwd); } private: @@ -384,10 +409,8 @@ private: } static const std::unordered_set & junk_dir_names() { - static const std::unordered_set names = { - ".git", ".svn", ".hg", "node_modules", "__pycache__", - ".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode", - }; + static const std::unordered_set names( + std::begin(SERVER_TOOL_JUNK_DIR_NAMES), std::end(SERVER_TOOL_JUNK_DIR_NAMES)); return names; } @@ -450,9 +473,274 @@ private: } }; +// timeout for auxiliary isolate calls (stat/mkdir/ls/cp helpers); exec_shell_command uses its own +// caller-controlled timeout instead, enforced separately in run() +static constexpr int SERVER_TOOL_ISOLATE_EXEC_TIMEOUT = 15; // seconds +static constexpr size_t SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE = 64 * 1024 * 1024; // 64 MB + +// runs every tools_io operation as a command inside an isolate: a container, a remote host, ... +// the isolate is created, mounted, and torn down externally by the caller +// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout +class tools_io_isolate : public tools_io { +public: + // cwd, if non-empty, is used to resolve relative paths and as the working directory for run() + explicit tools_io_isolate(std::string cwd = "") : cwd(std::move(cwd)) {} + + // resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged. + // isolate paths are always POSIX-style ('/'), regardless of host OS. + std::string resolve(const std::string & path) const override { + if (cwd.empty() || (!path.empty() && path[0] == '/')) { + return path; + } + return cwd + "/" + path; + } + + bool is_directory(const std::string & path) const override { + return shell_test("-d", resolve(path)); + } + + bool is_regular_file(const std::string & path) const override { + return shell_test("-f", resolve(path)); + } + + bool file_size(const std::string & path, uintmax_t & out_size) const override { + auto res = exec({"sh", "-c", "wc -c < \"$1\"", "_", resolve(path)}, 64, true); + if (res.exit_code != 0 || res.timed_out) return false; + try { + size_t pos; + out_size = (uintmax_t) std::stoull(res.output, &pos); + } catch (...) { + return false; + } + return true; + } + + bool read_file(const std::string & path, std::string & out) const override { + // combine_stderr=false: stderr must not be spliced into raw file bytes + auto res = exec({"cat", "--", resolve(path)}, SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE, false); + if (res.exit_code != 0 || res.timed_out) return false; + out = res.output; + return true; + } + + bool write_file(const std::string & path, const std::string & content) const override { + std::string abs_path = resolve(path); + + std::error_code ec; + fs::path tmp_dir = fs::temp_directory_path(ec); + if (ec) return false; + + static std::atomic tmp_counter{0}; + fs::path tmp = tmp_dir / string_format( + "llama-tools-io-isolate-%zu-%llu.tmp", + std::hash{}(std::this_thread::get_id()), + (unsigned long long) tmp_counter.fetch_add(1)); + + { + std::ofstream f(tmp, std::ios::binary); + if (!f) return false; + f << content; + if (!f) return false; + } + + bool ok = shell_run({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\"", "_", abs_path}); + if (ok) { + ok = upload(tmp.string(), abs_path); + } + + std::error_code rm_ec; + fs::remove(tmp, rm_ec); + return ok; + } + + list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override { + list_result out; + + const std::string abs_base = resolve(base); + if (!is_directory(base)) { + out.err = "path does not exist or is not a directory"; + return out; + } + + // git ls-files cannot list directories; use the walker when they are requested + if (kind == list_kind::files) { + auto res = exec( + {"sh", "-c", "cd \"$1\" && git ls-files --cached --others --exclude-standard", "_", abs_base}, + SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true); + + if (res.exit_code == 0 && !res.timed_out) { + for (const auto & rel : split_lines(res.output, /*strip_dot_slash=*/false)) { + if (max_depth > 0 && entry_depth(rel) > max_depth) continue; + out.entries.push_back({rel, false}); + } + return out; + } + } + + if (kind == list_kind::dirs || kind == list_kind::all) { + for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/true, out.truncated)) { + out.entries.push_back({std::move(rel), true}); + } + } + if (kind == list_kind::files || kind == list_kind::all) { + for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/false, out.truncated)) { + out.entries.push_back({std::move(rel), false}); + } + } + + return out; + } + + // wraps the command with an in-isolate `timeout`, since killing the host-side client + // does not kill the process tree running inside the isolate + exec_result run( + const std::vector & args, + size_t max_output, + int timeout_secs, + const std::function & on_chunk = nullptr) const override { + std::vector inner = {"timeout", std::to_string(timeout_secs) + "s"}; + inner.insert(inner.end(), args.begin(), args.end()); + // small buffer over timeout_secs so the in-isolate `timeout` has a chance to exit cleanly + // before the host-side supervisory timeout forcibly kills the client + return run_subprocess( + build_argv(with_cwd(inner), /*needs_stdin=*/true), + max_output, timeout_secs + 5, on_chunk, true); + } + +protected: + // wrap `inner` (a complete POSIX argv) into the host-side argv that runs it in the isolate + // a transport that re-parses its args in a remote shell (ssh) must join `inner` with shell_quote_join() + virtual std::vector build_argv(const std::vector & inner, bool needs_stdin) const = 0; + + // copy a host file into the isolate, `isolate_path` is absolute and its parent already exists + virtual bool upload(const std::string & host_path, const std::string & isolate_path) const = 0; + + // quote `argv` into a single string that a POSIX shell re-parses into exactly `argv` + static std::string shell_quote_join(const std::vector & argv) { + std::string out; + for (const auto & arg : argv) { + if (!out.empty()) out += ' '; + out += '\''; + for (const char c : arg) { + // a single quote cannot be escaped inside single quotes: close, escape, reopen + if (c == '\'') out += "'\\''"; + else out += c; + } + out += '\''; + } + return out; + } + +private: + std::string cwd; + + // set the working directory in the command itself, docker's `-w` has no equivalent on every transport + // auxiliary calls do not need this, they use the absolute paths from resolve() + std::vector with_cwd(const std::vector & inner) const { + if (cwd.empty()) { + return inner; + } + // 127 is what a shell reports for a command it could not run + std::vector out = {"sh", "-c", "cd \"$1\" || exit 127; shift; exec \"$@\"", "_", cwd}; + out.insert(out.end(), inner.begin(), inner.end()); + return out; + } + + exec_result exec(const std::vector & inner, size_t max_output, bool combine_stderr) const { + return run_subprocess( + build_argv(inner, /*needs_stdin=*/false), + max_output, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, combine_stderr); + } + + bool shell_run(const std::vector & inner) const { + auto res = exec(inner, 4096, true); + return res.exit_code == 0 && !res.timed_out; + } + + bool shell_test(const char * flag, const std::string & path) const { + return shell_run({"sh", "-c", std::string("[ ") + flag + " \"$1\" ]", "_", path}); + } + + static std::vector split_lines(const std::string & text, bool strip_dot_slash) { + std::vector result; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + if (strip_dot_slash && line.rfind("./", 0) == 0) line = line.substr(2); + std::replace(line.begin(), line.end(), '\\', '/'); + result.push_back(line); + } + return result; + } + + // one `find` pass in the isolate. junk directories stay selectable but are never descended into, + // and -mindepth/-maxdepth keep a busybox image working as well as a GNU one + std::vector find_entries(const std::string & abs_base, int max_depth, bool dirs, bool & truncated) const { + std::string prune_expr; + for (const char * n : SERVER_TOOL_JUNK_DIR_NAMES) { + if (!prune_expr.empty()) prune_expr += " -o "; + prune_expr += std::string("-name ") + n; + } + + std::string cmd = "cd \"$1\" && find . -mindepth 1"; + if (max_depth > 0) { + cmd += " -maxdepth " + std::to_string(max_depth); + } + cmd += " \\( " + prune_expr + " \\) -prune"; + cmd += dirs ? " -print -o -type d -print" : " -o -type f -print"; + + auto res = exec({"sh", "-c", cmd, "_", abs_base}, SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true); + truncated = truncated || res.timed_out; + return split_lines(res.output, /*strip_dot_slash=*/true); + } +}; + +// an already-running docker container, driven through `docker exec` and `docker cp` +class tools_io_docker : public tools_io_isolate { +public: + tools_io_docker(std::string container_id, std::string cwd = "") + : tools_io_isolate(std::move(cwd)), container_id(std::move(container_id)) {} + +protected: + std::vector build_argv(const std::vector & inner, bool needs_stdin) const override { + std::vector argv = {"docker", "exec"}; + if (needs_stdin) { + argv.push_back("-i"); + } + argv.push_back(container_id); + argv.insert(argv.end(), inner.begin(), inner.end()); + return argv; + } + + bool upload(const std::string & host_path, const std::string & isolate_path) const override { + auto res = run_subprocess( + {"docker", "cp", host_path, container_id + ":" + isolate_path}, + 4096, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, true); + return res.exit_code == 0 && !res.timed_out; + } + +private: + std::string container_id; +}; + +// runtime spec used by --tools-runtime and the x-tool-runtime header +// this is the only scheme for now, ssh: and podman: can be added next to it +static const std::string SERVER_TOOL_RUNTIME_DOCKER_CONTAINER = "docker-container:"; + +// an empty runtime runs the tools on the host static std::unique_ptr make_tools_io(const json & params) { - std::string cwd = json_value(params, "cwd", std::string()); - return std::make_unique(cwd); + std::string cwd = json_value(params, "cwd", std::string()); + std::string runtime = json_value(params, "runtime", std::string()); + if (runtime.empty()) { + return std::make_unique(cwd); + } + if (runtime.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) { + return std::make_unique(runtime.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()), cwd); + } + // do not fall back to the host, the caller asked for an isolate + throw std::runtime_error("unknown tool runtime: " + runtime); } // no '/' in pattern -> match basename at any depth; else match full relative path @@ -476,6 +764,7 @@ struct server_tool_read_file : server_tool { server_tool_read_file() { name = "read_file"; display_name = "Read file"; + uses_cwd = true; permission_write = false; } @@ -564,6 +853,7 @@ struct server_tool_file_glob_search : server_tool { server_tool_file_glob_search() { name = "file_glob_search"; display_name = "File search"; + uses_cwd = true; permission_write = false; } @@ -678,6 +968,7 @@ struct server_tool_grep_search : server_tool { server_tool_grep_search() { name = "grep_search"; display_name = "Grep search"; + uses_cwd = true; permission_write = false; } @@ -830,6 +1121,7 @@ struct server_tool_exec_shell_command : server_tool { server_tool_exec_shell_command() { name = "exec_shell_command"; display_name = "Execute shell command"; + uses_cwd = true; permission_write = true; support_stream = true; } @@ -861,8 +1153,11 @@ struct server_tool_exec_shell_command : server_tool { timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT); max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE); + // an isolate is always POSIX regardless of host OS, so it always gets `sh -c` #ifdef _WIN32 - std::vector args = {"cmd", "/c", command}; + std::vector args = !json_value(params, "runtime", std::string()).empty() + ? std::vector{"sh", "-c", command} + : std::vector{"cmd", "/c", command}; #else std::vector args = {"sh", "-c", command}; #endif @@ -905,6 +1200,7 @@ struct server_tool_write_file : server_tool { server_tool_write_file() { name = "write_file"; display_name = "Write file"; + uses_cwd = true; permission_write = true; } @@ -947,6 +1243,7 @@ struct server_tool_edit_file : server_tool { server_tool_edit_file() { name = "edit_file"; display_name = "Edit file"; + uses_cwd = true; permission_write = true; } @@ -1335,6 +1632,7 @@ struct server_tool_get_info : server_tool { server_tool_get_info() { name = "get_info"; display_name = "Get Runtime Info"; + uses_cwd = true; permission_write = false; } @@ -1355,19 +1653,29 @@ struct server_tool_get_info : server_tool { json invoke(json params, server_tool::stream *) const override { auto io = make_tools_io(params); + // inside an isolate, we always use the linux command #ifdef _WIN32 - auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); + std::vector args = !json_value(params, "runtime", std::string()).empty() + ? std::vector{"uname", "-a"} + : std::vector{"cmd", "/c", "ver"}; #else - auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); + std::vector args = {"uname", "-a"}; #endif + + auto res = io->run(args, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); // "ver" prints a blank line before the version, so the output is stripped on both ends; // a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name std::string os_info = res.exit_code == 0 && !res.timed_out ? string_strip(res.output) : "unknown"; std::string cwd = json_value(params, "cwd", std::string()); if (cwd.empty()) { - std::error_code ec; - cwd = path_to_utf8(fs::current_path(ec)); + if (json_value(params, "runtime", std::string()).empty()) { + std::error_code ec; + cwd = path_to_utf8(fs::current_path(ec)); + } else { + auto pwd = io->run({"pwd"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); + cwd = pwd.exit_code == 0 && !pwd.timed_out ? string_strip(pwd.output) : "unknown"; + } } return { @@ -1461,6 +1769,103 @@ struct server_mcp_tool : server_tool { } }; +// owns the docker container used as the sandboxed runtime for tool invocations, as configured by +// --tools-runtime. "spawned" mode starts and stops the container itself; "existing" mode just reuses +// a container id the user already has running and never stops it. +struct server_tools_docker_runtime { + server_tools_docker_runtime(const server_tools_docker_runtime &) = delete; + + explicit server_tools_docker_runtime(const std::string & spec) { + static const std::string docker_prefix = "docker:"; + if (spec.rfind(docker_prefix, 0) == 0) { + spawned = true; + image = spec.substr(docker_prefix.size()); + if (image.empty()) { + throw std::runtime_error("--tools-runtime docker: requires an image name"); + } + spawn(); + } else if (spec.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) { + spawned = false; + container_id = spec.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()); + if (container_id.empty()) { + throw std::runtime_error("--tools-runtime docker-container: requires a container id"); + } + } else { + throw std::runtime_error("unknown --tools-runtime option: " + spec); + } + } + + ~server_tools_docker_runtime() { + if (spawned && !container_id.empty()) { + // closing stdin signals the container's shell (its pid 1) to exit; --rm then removes it + proc.close_stdin(); + proc.join(); + } + } + + // container id to use for the next tool call; respawns a spawned container that died on its own, + // or throws if an externally-managed one is no longer reachable + std::string get_container_id() { + std::lock_guard lock(mutex); + if (!spawned) { + if (!is_running(container_id)) { + throw std::runtime_error(string_format( + "docker container \"%s\" is no longer running, restart it to keep using tools", + container_id.c_str())); + } + return container_id; + } + + if (!proc.alive()) { + SRV_WRN("docker tools runtime container \"%s\" died, respawning\n", container_id.c_str()); + spawn(); + } + return container_id; + } + +private: + bool spawned = false; + std::string image; // spawned mode only + std::string container_id; + common_subproc proc; // spawned mode only: `docker run` client that keeps the container alive + std::mutex mutex; + + // spawns "docker run --rm -i sh" and keeps its stdin open; the shell blocks reading stdin, + // so the container stays alive until we close it (see destructor) or it is killed from the outside + void spawn() { + std::error_code ec; + fs::path cidfile = fs::temp_directory_path(ec) / string_format( + "llama-tools-runtime-cid-%zu.tmp", std::hash{}(std::this_thread::get_id())); + fs::remove(cidfile, ec); + + std::vector args = {"docker", "run", "--rm", "-i", "--cidfile", cidfile.string(), image, "sh"}; + int options = subprocess_option_no_window + | subprocess_option_inherit_environment + | subprocess_option_search_user_path; + if (!proc.create(args, options)) { + throw std::runtime_error("failed to spawn docker container for tools runtime (image: " + image + ")"); + } + + std::string cid; + for (int i = 0; i < 100 && cid.empty(); i++) { + std::ifstream f(cidfile); + if (f) std::getline(f, cid); + if (cid.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + fs::remove(cidfile, ec); + if (cid.empty()) { + proc.terminate(); + throw std::runtime_error("timed out waiting for docker container to start (image: " + image + ")"); + } + container_id = cid; + } + + static bool is_running(const std::string & id) { + auto res = run_subprocess({"docker", "inspect", "-f", "{{.State.Running}}", id}, 16, 5, nullptr, true); + return res.exit_code == 0 && !res.timed_out && res.output.rfind("true", 0) == 0; + } +}; + static server_tool & find_tool(std::vector> & tools, const std::string & name, bool require_stream) { for (auto & t : tools) { if (t->name == name) { @@ -1506,8 +1911,16 @@ static std::string get_header(const std::map & headers return default_value; } +server_tools::server_tools() = default; +server_tools::~server_tools() = default; + void server_tools::setup(const std::vector & enabled_tools, - server_mcp & mcp_mgr) { + server_mcp & mcp_mgr, + const std::string & tools_runtime) { + if (!tools_runtime.empty()) { + docker_runtime = std::make_unique(tools_runtime); + } + if (!enabled_tools.empty()) { if (!common_subproc::is_supported()) { throw std::runtime_error("subprocess is not enabled on this build"); @@ -1590,11 +2003,26 @@ void server_tools::setup(const std::vector & enabled_tools, bool stream = body.value("stream", false); // accept x-tool-cwd header to override of the process + if (params.contains("cwd")) { + params.erase("cwd"); + } auto cwd = get_header(req.headers, "x-tool-cwd"); if (!cwd.empty()) { params["cwd"] = cwd; } + // accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:"; + // falls back to the --tools-runtime isolate, if configured + if (params.contains("runtime")) { + params.erase("runtime"); + } + auto runtime = get_header(req.headers, "x-tool-runtime"); + if (!runtime.empty()) { + params["runtime"] = runtime; + } else if (docker_runtime) { + params["runtime"] = SERVER_TOOL_RUNTIME_DOCKER_CONTAINER + docker_runtime->get_container_id(); + } + server_tool & tool = find_tool(tools, tool_name, stream); if (stream) { diff --git a/tools/server/server-tools.h b/tools/server/server-tools.h index 601399ee9..ede303181 100644 --- a/tools/server/server-tools.h +++ b/tools/server/server-tools.h @@ -14,6 +14,7 @@ struct server_tool { std::string display_name; bool permission_write = false; bool support_stream = false; // if true, output can be streamed + bool uses_cwd = false; // if true, the tool resolves paths and runs against the working directory virtual ~server_tool() = default; virtual json get_definition() const = 0; @@ -30,6 +31,8 @@ struct server_tool { json to_json() const; }; +struct server_tools_docker_runtime; // impl detail, defined in server-tools.cpp + struct server_tools { std::vector> tools; @@ -37,9 +40,16 @@ struct server_tools { server_response queue_res; std::atomic res_id{0}; + // set when --tools-runtime is configured; owns the docker container used to run tools, if any + std::unique_ptr docker_runtime; + void setup(const std::vector & enabled_tools, - server_mcp & mcp_mgr); + server_mcp & mcp_mgr, + const std::string & tools_runtime); server_http_context::handler_t handle_get; server_http_context::handler_t handle_post; + + server_tools(); + ~server_tools(); }; diff --git a/tools/server/server.cpp b/tools/server/server.cpp index aafb1f307..1b2e6edb4 100644 --- a/tools/server/server.cpp +++ b/tools/server/server.cpp @@ -338,7 +338,7 @@ int llama_server(common_params & params, int argc, char ** argv) { if (!params.server_tools.empty() || !mcp_mgr.empty()) { try { - tools.setup(params.server_tools, mcp_mgr); + tools.setup(params.server_tools, mcp_mgr, params.server_tools_runtime); } catch (const std::exception & e) { SRV_ERR("tools setup failed: %s\n", e.what()); return 1; @@ -348,6 +348,9 @@ int llama_server(common_params & params, int argc, char ** argv) { if (!params.server_tools.empty()) { warn_names.push_back("built-in tools (experimental)"); } + if (!params.server_tools_runtime.empty()) { + warn_names.push_back("tools runtime (experimental)"); + } if (!mcp_mgr.empty()) { warn_names.push_back("MCP servers (experimental)"); } diff --git a/tools/server/tests/unit/test_tools_builtin.py b/tools/server/tests/unit/test_tools_builtin.py index 11c82e690..7da569d99 100755 --- a/tools/server/tests/unit/test_tools_builtin.py +++ b/tools/server/tests/unit/test_tools_builtin.py @@ -1,4 +1,6 @@ import os +import shutil +import subprocess import pytest from utils import * @@ -11,6 +13,9 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".. # marker for the grep_search test to find in this file GREP_MARKER = "llama_cpp_test_tools_builtin_marker_grep_search" +# image the container runtime tests run their shell in +DOCKER_IMAGE = "busybox" + @pytest.fixture(autouse=True) def create_server(): @@ -146,6 +151,97 @@ def test_tools_builtin_cwd_header(): os.remove(marker_path) +def _docker_unavailable_reason() -> str | None: + """None if docker can run the image these tests use, otherwise the reason it can't.""" + docker_bin = shutil.which("docker") + if docker_bin is None: + return "docker is not installed" + try: + # a daemon that answers `docker info` still cannot run a linux image when it serves + # windows containers, so probe the image itself, which also pulls it before the tests + subprocess.run([docker_bin, "run", "--rm", DOCKER_IMAGE, "true"], capture_output=True, timeout=60, check=True) + except Exception as e: + return f"docker cannot run {DOCKER_IMAGE}: {e}" + return None + + +@pytest.fixture +def docker_container(): + reason = _docker_unavailable_reason() + if reason is not None: + pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type] + + proc = subprocess.run( + ["docker", "run", "-d", "--rm", DOCKER_IMAGE, "sleep", "300"], + capture_output=True, text=True, + ) + if proc.returncode != 0: + pytest.skip(f"failed to start docker container: {proc.stderr.strip()}") # ty: ignore[too-many-positional-arguments, invalid-argument-type] + + container_id = proc.stdout.strip() + try: + yield container_id + finally: + subprocess.run(["docker", "rm", "-f", container_id], capture_output=True) + + +def test_tools_builtin_runtime_header(docker_container: str): + global server + server.start() + + headers = {"x-tool-runtime": f"docker-container:{docker_container}", "x-tool-cwd": "/tmp"} + + write_res = call_tool("write_file", {"path": "test.log", "content": "hello docker\n"}, headers=headers) + assert write_res["result"] == "file written successfully" + + read_res = call_tool("read_file", {"path": "test.log"}, headers=headers) + assert read_res["plain_text_response"] == "hello docker\n" + + exec_res = call_tool("exec_shell_command", {"command": "cat test.log"}, headers=headers) + assert "hello docker" in exec_res["plain_text_response"] + + +def test_tools_builtin_runtime_header_unknown_scheme(): + global server + server.start() + + # an unknown runtime must fail, never silently fall back to running on the host + res = server.make_request("POST", "/tools", + data={"tool": "exec_shell_command", "params": {"command": "echo hi"}}, + headers={"x-tool-runtime": "ssh:example.com"}) + assert res.status_code == 500, res.body + assert "unknown tool runtime" in str(res.body) + + +def test_tools_builtin_docker_runtime_cleans_up_spawned_container(): + reason = _docker_unavailable_reason() + if reason is not None: + pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type] + + global server + server.server_tools_runtime = f"docker:{DOCKER_IMAGE}" + server.start() + + # exec_shell_command runs inside the container spawned for --tools-runtime; docker sets + # the container's hostname to its own short id, so this also tells us which one to check + res = call_tool("exec_shell_command", {"command": "hostname"}) + container_id = res["plain_text_response"].splitlines()[0].strip() + assert len(container_id) >= 8, res + + running = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Running}}", container_id], + capture_output=True, text=True, + ) + assert running.returncode == 0 and running.stdout.strip() == "true", running.stderr + + server.stop() + + # a clean server shutdown must stop and remove the container it spawned (it runs with --rm), + # not leave it behind as an abandoned child + leftover = subprocess.run(["docker", "inspect", container_id], capture_output=True, text=True) + assert leftover.returncode != 0, f"container {container_id} was not cleaned up after server exit" + + def test_tools_builtin_edit_file_rejects_overlapping_edits(): global server server.start() diff --git a/tools/server/tests/utils.py b/tools/server/tests/utils.py index 3416f0bfd..fffe07a67 100644 --- a/tools/server/tests/utils.py +++ b/tools/server/tests/utils.py @@ -115,6 +115,7 @@ class ServerProcess: backend_sampling: bool = False gcp_compat: bool = False server_tools: str | None = None + server_tools_runtime: str | None = None mcp_servers_config: str | None = None mcp_servers_json: str | None = None cors_origins: str | None = None @@ -270,6 +271,8 @@ class ServerProcess: server_args.append("--ui-mcp-proxy") if self.server_tools: server_args.extend(["--tools", self.server_tools]) + if self.server_tools_runtime: + server_args.extend(["--tools-runtime", self.server_tools_runtime]) if self.mcp_servers_config: server_args.extend(["--mcp-servers-config", self.mcp_servers_config]) if self.mcp_servers_json: diff --git a/tools/ui/eslint.config.js b/tools/ui/eslint.config.js index fcbf7ee95..c65484048 100644 --- a/tools/ui/eslint.config.js +++ b/tools/ui/eslint.config.js @@ -1,14 +1,15 @@ // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from 'eslint-plugin-storybook'; - -import prettier from 'eslint-config-prettier'; +import svelteConfig from './svelte.config.js'; import { includeIgnoreFile } from '@eslint/compat'; import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import perfectionist from 'eslint-plugin-perfectionist'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import storybook from 'eslint-plugin-storybook'; import svelte from 'eslint-plugin-svelte'; import globals from 'globals'; import { fileURLToPath } from 'node:url'; import ts from 'typescript-eslint'; -import svelteConfig from './svelte.config.js'; const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); @@ -21,32 +22,67 @@ export default ts.config( ...svelte.configs.prettier, { languageOptions: { globals: { ...globals.browser, ...globals.node } }, + plugins: { perfectionist, 'simple-import-sort': simpleImportSort }, rules: { - // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. - // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors - 'no-undef': 'off', - 'svelte/no-at-html-tags': 'off', - // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply - 'svelte/no-navigation-without-resolve': 'off', - // Snippet bodies often ignore one or more of the parent's params // (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read). '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } ], - // Enforce empty line at end of file - 'eol-last': 'error' + 'eol-last': 'error', + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off', + + 'padding-line-between-statements': [ + 'error', + // Blank line between function/class declarations. + { blankLine: 'always', next: ['function', 'class'], prev: ['function', 'class'] }, + // Blank line around if blocks (if/else and else if stay one statement). + { blankLine: 'always', next: '*', prev: 'if' }, + { blankLine: 'always', next: 'if', prev: '*' }, + // Blank line after the last declaration in a group. Because the 'never' + // rules below are scoped per declaration kind, a const group and a let + // group get separated by a blank line, while same-kind declarations stay + // together. + { blankLine: 'always', next: '*', prev: ['const', 'let', 'var'] }, + // No blank line between consecutive declarations of the same kind (kept + // last so each takes precedence over the always rule above for matching + // declaration pairs). + { blankLine: 'never', next: 'const', prev: 'const' }, + { blankLine: 'never', next: 'let', prev: 'let' }, + { blankLine: 'never', next: 'var', prev: 'var' }, + // Blank line before a statement that follows another statement in the block + // (works for return/throw/break/continue). A blank line for a terminal + // statement that opens a block body can't be enforced here: Prettier removes + // the leading blank line of a block, so the two formatters would fight. + { blankLine: 'always', next: ['return', 'throw', 'break', 'continue'], prev: '*' } + ], + + 'perfectionist/sort-objects': ['error', { type: 'natural' }], + + // Alphabetical order for variable declarations and object keys + 'perfectionist/sort-variable-declarations': ['error', { type: 'natural' }], + + // Sort imports alphabetically by module path, and sort named members within + // each statement. A single catch-all group keeps the list flat (no blank-line + // grouping); Prettier normalizes comma spacing afterwards. + 'simple-import-sort/imports': ['error', { groups: [['.*']] }], + 'svelte/no-at-html-tags': 'off', + + // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply + 'svelte/no-navigation-without-resolve': 'off' } }, { files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], languageOptions: { parserOptions: { - projectService: true, extraFileExtensions: ['.svelte'], parser: ts.parser, + projectService: true, svelteConfig } } diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json index 976ed050f..f9b793b29 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -39,6 +39,8 @@ "dompurify": "3.4.13", "eslint": "9.39.4", "eslint-config-prettier": "10.1.8", + "eslint-plugin-perfectionist": "^5.10.1", + "eslint-plugin-simple-import-sort": "^14.0.0", "eslint-plugin-storybook": "10.5.6", "eslint-plugin-svelte": "3.19.0", "fflate": "0.8.3", @@ -9281,6 +9283,226 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.10.1.tgz", + "integrity": "sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.65.0", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-perfectionist/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-14.0.0.tgz", + "integrity": "sha512-NUJO0+XFCkk+o5EsAJruTgnfMEpeWrPWeJS15UVF60GgXmqz1BJ9/3hzlvG7lkL8Bubzos5cCLptThbFfPnSMQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, "node_modules/eslint-plugin-storybook": { "version": "10.5.6", "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.6.tgz", @@ -13196,6 +13418,16 @@ "dev": true, "license": "MIT" }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", diff --git a/tools/ui/package.json b/tools/ui/package.json index 7f042f415..f6d6880d7 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -12,7 +12,7 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "reset": "rm -rf .svelte-kit node_modules", - "format": "prettier --write .", + "format": "eslint --fix . && prettier --write .", "lint": "prettier --check . && eslint .", "test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e", "test:e2e": "playwright test", @@ -36,6 +36,7 @@ "@playwright/test": "1.56.1", "@storybook/addon-a11y": "10.5.6", "@storybook/addon-docs": "10.5.6", + "@storybook/addon-mcp": "0.7.0", "@storybook/addon-svelte-csf": "5.1.2", "@storybook/addon-vitest": "10.5.6", "@storybook/sveltekit": "10.5.6", @@ -57,6 +58,8 @@ "dompurify": "3.4.13", "eslint": "9.39.4", "eslint-config-prettier": "10.1.8", + "eslint-plugin-perfectionist": "^5.10.1", + "eslint-plugin-simple-import-sort": "^14.0.0", "eslint-plugin-storybook": "10.5.6", "eslint-plugin-svelte": "3.19.0", "fflate": "0.8.3", @@ -99,8 +102,7 @@ "vite-plugin-devtools-json": "0.2.1", "vitest": "4.1.10", "vitest-browser-svelte": "2.1.1", - "workbox-window": "7.4.1", - "@storybook/addon-mcp": "0.7.0" + "workbox-window": "7.4.1" }, "overrides": { "cookie": "1.1.1", diff --git a/tools/ui/playwright.config.ts b/tools/ui/playwright.config.ts index 55bf38514..057ed416d 100644 --- a/tools/ui/playwright.config.ts +++ b/tools/ui/playwright.config.ts @@ -1,31 +1,31 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: 'tests/e2e', - testMatch: ['**/*.e2e.ts'], - timeout: 30000, expect: { timeout: 5000 }, - fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: 'line', - use: { - baseURL: 'http://localhost:8181', - trace: 'on-first-retry' - }, + fullyParallel: true, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } } ], + reporter: 'line', + retries: process.env.CI ? 2 : 0, + testDir: 'tests/e2e', + testMatch: ['**/*.e2e.ts'], + timeout: 30000, + use: { + baseURL: 'http://localhost:8181', + trace: 'on-first-retry' + }, webServer: { command: 'npm run build && npx http-server ./dist -p 8181', port: 8181, - timeout: 120000, - reuseExistingServer: !process.env.CI - } + reuseExistingServer: !process.env.CI, + timeout: 120000 + }, + workers: process.env.CI ? 1 : undefined }); diff --git a/tools/ui/pwa-assets-dark.config.ts b/tools/ui/pwa-assets-dark.config.ts index 358c0ebc0..1446b47a8 100644 --- a/tools/ui/pwa-assets-dark.config.ts +++ b/tools/ui/pwa-assets-dark.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from '@vite-pwa/assets-generator/config'; -import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa'; import { writeThemeFavicons } from './scripts/favicon-colorize'; +import { FAVICON_COLORS, PWA_ASSET_GENERATOR } from './src/lib/constants/pwa'; +import { defineConfig } from '@vite-pwa/assets-generator/config'; writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, { padding: PWA_ASSET_GENERATOR.FAVICON_PADDING @@ -10,18 +10,18 @@ export default defineConfig({ headLinkOptions: { preset: '2023' }, + images: ['static/favicon-dark.svg'], preset: { - transparent: { - sizes: [], - favicons: [[48, 'favicon-dark.ico']], - padding: PWA_ASSET_GENERATOR.FAVICON_PADDING + apple: { + sizes: [] }, maskable: { sizes: [] }, - apple: { + transparent: { + favicons: [[48, 'favicon-dark.ico']], + padding: PWA_ASSET_GENERATOR.FAVICON_PADDING, sizes: [] } - }, - images: ['static/favicon-dark.svg'] + } }); diff --git a/tools/ui/pwa-assets.config.ts b/tools/ui/pwa-assets.config.ts index b69884d94..5fed0a595 100644 --- a/tools/ui/pwa-assets.config.ts +++ b/tools/ui/pwa-assets.config.ts @@ -1,3 +1,11 @@ +import { writeThemeFavicons } from './scripts/favicon-colorize'; +import { + FAVICON_COLORS, + PWA_ASSET_GENERATOR, + PWA_GENERATOR_DEVICES, + THEME_COLORS +} from './src/lib/constants/pwa'; +import { SplashOrientation } from './src/lib/enums/splash.enums'; import { combinePresetAndAppleSplashScreens, defineConfig, @@ -5,14 +13,6 @@ import { } from '@vite-pwa/assets-generator/config'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; -import { - THEME_COLORS, - PWA_GENERATOR_DEVICES, - PWA_ASSET_GENERATOR, - FAVICON_COLORS -} from './src/lib/constants/pwa'; -import { SplashOrientation } from './src/lib/enums/splash.enums'; -import { writeThemeFavicons } from './scripts/favicon-colorize'; writeThemeFavicons(FAVICON_COLORS.LIGHT, FAVICON_COLORS.DARK, { padding: PWA_ASSET_GENERATOR.FAVICON_PADDING @@ -22,6 +22,7 @@ export default defineConfig({ headLinkOptions: { preset: PWA_ASSET_GENERATOR.LINK_PRESET }, + images: ['static/favicon.svg'], preset: combinePresetAndAppleSplashScreens( { ...minimal2023Preset, @@ -32,37 +33,37 @@ export default defineConfig({ } }, { - padding: PWA_ASSET_GENERATOR.SPLASH_PADDING, - resizeOptions: { - background: THEME_COLORS.BACKGROUND_LIGHT, - fit: PWA_ASSET_GENERATOR.FIT_MODE - }, - darkResizeOptions: { - background: THEME_COLORS.BACKGROUND_DARK, - fit: PWA_ASSET_GENERATOR.FIT_MODE - }, darkImageResolver: async (imageName: string) => { if (imageName.endsWith('favicon.svg')) { return readFileSync(resolve('static/favicon-dark.svg')); } }, + darkResizeOptions: { + background: THEME_COLORS.BACKGROUND_DARK, + fit: PWA_ASSET_GENERATOR.FIT_MODE + }, linkMediaOptions: { - log: true, addMediaScreen: PWA_ASSET_GENERATOR.ADD_MEDIA_SCREEN, basePath: PWA_ASSET_GENERATOR.BASE_PATH, + log: true, xhtml: PWA_ASSET_GENERATOR.XHTML }, - png: { - compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL, - quality: PWA_ASSET_GENERATOR.PNG_QUALITY - }, name: (landscape, size, dark) => { const orientation = landscape ? SplashOrientation.LANDSCAPE : SplashOrientation.PORTRAIT; const darkPrefix = dark ? PWA_ASSET_GENERATOR.DARK_PREFIX : ''; + return `apple-splash-${orientation}-${darkPrefix}${size.width}x${size.height}.png`; + }, + padding: PWA_ASSET_GENERATOR.SPLASH_PADDING, + png: { + compressionLevel: PWA_ASSET_GENERATOR.PNG_COMPRESSION_LEVEL, + quality: PWA_ASSET_GENERATOR.PNG_QUALITY + }, + resizeOptions: { + background: THEME_COLORS.BACKGROUND_LIGHT, + fit: PWA_ASSET_GENERATOR.FIT_MODE } }, PWA_GENERATOR_DEVICES - ), - images: ['static/favicon.svg'] + ) }); diff --git a/tools/ui/scripts/favicon-colorize.ts b/tools/ui/scripts/favicon-colorize.ts index e1872b777..54a951296 100644 --- a/tools/ui/scripts/favicon-colorize.ts +++ b/tools/ui/scripts/favicon-colorize.ts @@ -4,12 +4,10 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(HERE, '..'); - const DEFAULT_LOGO = resolve(PROJECT_ROOT, 'src/lib/assets/logo.svg'); const DEFAULT_OUT_DIR = resolve(PROJECT_ROOT, 'static'); const DEFAULT_OUT_LIGHT = resolve(DEFAULT_OUT_DIR, 'favicon.svg'); const DEFAULT_OUT_DARK = resolve(DEFAULT_OUT_DIR, 'favicon-dark.svg'); - const CURRENT_COLOR = 'currentColor'; export interface ColorizedFavicon { @@ -39,8 +37,8 @@ export function colorizeFaviconSvg( darkColor: string ): ColorizedFavicon { return { - light: svg.replaceAll(CURRENT_COLOR, lightColor), - dark: svg.replaceAll(CURRENT_COLOR, darkColor) + dark: svg.replaceAll(CURRENT_COLOR, darkColor), + light: svg.replaceAll(CURRENT_COLOR, lightColor) }; } @@ -54,33 +52,40 @@ export function padFaviconSvg(svg: string, padding: number): string { if (!(padding > 0) || padding >= 1) return svg; const viewBoxMatch = svg.match(/viewBox\s*=\s*["']([^"']+)["']/i); + if (!viewBoxMatch) return svg; const parts = viewBoxMatch[1] .trim() .split(/[\s,]+/) .map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return svg; const [, , width, height] = parts; + if (width <= 0 || height <= 0) return svg; const scale = 1 - padding; const translateX = (padding * width) / 2; const translateY = (padding * height) / 2; - const openTagStart = svg.search(/', openTagStart); + if (openTagEnd === -1) return svg; + const closeStart = svg.lastIndexOf('`; + return `${openTag}${group}${inner}${closeTag}`; } @@ -93,14 +98,15 @@ export function writeThemeFavicons( lightColor: string, darkColor: string, { - sourcePath = DEFAULT_LOGO, - lightOutPath = DEFAULT_OUT_LIGHT, darkOutPath = DEFAULT_OUT_DARK, - padding = 0 + lightOutPath = DEFAULT_OUT_LIGHT, + padding = 0, + sourcePath = DEFAULT_LOGO }: WriteThemeFaviconsOptions = {} ): void { const source = readFileSync(sourcePath, 'utf-8'); - const { light, dark } = colorizeFaviconSvg(source, lightColor, darkColor); + const { dark, light } = colorizeFaviconSvg(source, lightColor, darkColor); + mkdirSync(dirname(lightOutPath), { recursive: true }); writeFileSync(lightOutPath, padFaviconSvg(light, padding)); writeFileSync(darkOutPath, padFaviconSvg(dark, padding)); diff --git a/tools/ui/scripts/make-icons-circular.js b/tools/ui/scripts/make-icons-circular.js index 7dfd6521e..b4763c625 100644 --- a/tools/ui/scripts/make-icons-circular.js +++ b/tools/ui/scripts/make-icons-circular.js @@ -13,31 +13,28 @@ * maskable-icon and apple-touch-icon are left untouched. */ -import sharp from 'sharp'; import fs from 'fs'; import path from 'path'; +import sharp from 'sharp'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const STATIC_DIR = path.resolve(__dirname, '..', 'static'); - const paddingPct = process.argv.reduce((acc, arg, i, args) => { if (arg === '--padding-pct' && args[i + 1]) return parseFloat(args[i + 1]); + return acc; }, 0); - // Scale down the source image before cropping to circle const scalePct = process.argv.reduce((acc, arg, i, args) => { if (arg === '--scale-pct' && args[i + 1]) return parseFloat(args[i + 1]); + return acc; }, 85); // default 85% - icon fills 85% of the circular area - // Source for circular icons: the maskable icon (white bg, full logo) const sourceIcon = 'maskable-icon-512x512.png'; const targetIcons = ['pwa-64x64.png', 'pwa-192x192.png', 'pwa-512x512.png']; - // maskable-icon and apple-touch-icon stay square const untouchedIcons = ['maskable-icon-512x512.png', 'apple-touch-icon-180x180.png']; @@ -47,10 +44,13 @@ async function makeCircle(targetFilename) { if (!fs.existsSync(sourcePath)) { console.log(`⏭️ ${sourceIcon} not found, skipping`); + return; } + if (!fs.existsSync(targetPath)) { console.log(`⏭️ ${targetFilename} not found, skipping`); + return; } @@ -58,16 +58,18 @@ async function makeCircle(targetFilename) { const size = Math.max(metadata.width, metadata.height); const radius = Math.floor((size * (1 - paddingPct / 100)) / 2); const center = Math.floor(size / 2); - // Build circular mask as RGBA buffer: white opaque circle on transparent bg const maskBuf = Buffer.alloc(size * size * 4, 0); + for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const dx = x - center; const dy = y - center; const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < radius) { const i = (y * size + x) * 4; + maskBuf[i] = 255; maskBuf[i + 1] = 255; maskBuf[i + 2] = 255; @@ -77,8 +79,9 @@ async function makeCircle(targetFilename) { } const tmpMask = path.join(STATIC_DIR, '.mask-tmp.png'); + await sharp(maskBuf, { - raw: { width: size, height: size, channels: 4 } + raw: { channels: 4, height: size, width: size } }) .png() .toFile(tmpMask); @@ -87,28 +90,26 @@ async function makeCircle(targetFilename) { const circleDiameter = Math.floor(size * (1 - paddingPct / 100)); const scaledSize = Math.floor((circleDiameter * scalePct) / 100); const offset = Math.floor((size - scaledSize) / 2); - const scaledBuf = await sharp(sourcePath) .resize(scaledSize, scaledSize, { - fit: 'cover', - background: { r: 255, g: 255, b: 255, alpha: 1 } + background: { alpha: 1, b: 255, g: 255, r: 255 }, + fit: 'cover' }) .ensureAlpha() .png() .toBuffer(); - // Step 2: Composite scaled image onto white background, then apply circular mask const output = await sharp({ create: { - width: size, - height: size, + background: { alpha: 1, b: 255, g: 255, r: 255 }, channels: 4, - background: { r: 255, g: 255, b: 255, alpha: 1 } + height: size, + width: size } }) .composite([ - { input: scaledBuf, top: offset, left: offset }, - { input: tmpMask, top: 0, left: 0, blend: 'dest-in' } + { input: scaledBuf, left: offset, top: offset }, + { blend: 'dest-in', input: tmpMask, left: 0, top: 0 } ]) .png() .toBuffer(); @@ -130,6 +131,7 @@ async function main() { console.log('\nUnchanged:'); for (const icon of untouchedIcons) { const fp = path.join(STATIC_DIR, icon); + console.log(` ${icon} (${fs.existsSync(fp) ? fs.statSync(fp).size + ' bytes' : 'missing'})`); } } diff --git a/tools/ui/scripts/vite-plugin-build-info.ts b/tools/ui/scripts/vite-plugin-build-info.ts index 972ba3b66..800238630 100644 --- a/tools/ui/scripts/vite-plugin-build-info.ts +++ b/tools/ui/scripts/vite-plugin-build-info.ts @@ -1,7 +1,7 @@ -import { writeFileSync, existsSync } from 'node:fs'; +import { BUILD_CONFIG } from '../src/lib/constants/pwa'; +import { existsSync, writeFileSync } from 'node:fs'; import { resolve } from 'path'; import type { Plugin } from 'vite'; -import { BUILD_CONFIG } from '../src/lib/constants/pwa'; let processed = false; @@ -15,27 +15,29 @@ const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR; */ export function buildInfoPlugin(): Plugin { return { - name: 'llamacpp:build-info', apply: 'build', closeBundle() { setTimeout(() => { try { if (processed) return; + processed = true; const buildNumber = process.env.LLAMA_BUILD_NUMBER || 'b0000'; - const outDir = resolve(OUTPUT_DIR); const indexPath = resolve(outDir, 'index.html'); + if (!existsSync(indexPath)) return; const buildJsonPath = resolve(outDir, 'build.json'); + writeFileSync(buildJsonPath, JSON.stringify({ version: buildNumber }), 'utf-8'); console.log(`Created build.json (version: ${buildNumber})`); } catch (error) { console.error('Failed to write build.json:', error); } }, 100); - } + }, + name: 'llamacpp:build-info' }; } diff --git a/tools/ui/scripts/vite-plugin-nerdamer.ts b/tools/ui/scripts/vite-plugin-nerdamer.ts index 218c2fa23..84e463c6d 100644 --- a/tools/ui/scripts/vite-plugin-nerdamer.ts +++ b/tools/ui/scripts/vite-plugin-nerdamer.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from 'url'; import type { Plugin } from 'vite'; const __dirname = dirname(fileURLToPath(import.meta.url)); - const VENDORS_DIR = resolve(__dirname, '../src/lib/vendors'); const VIRTUAL_ID = 'virtual:nerdamer'; const RESOLVED_ID = '\0' + VIRTUAL_ID; @@ -21,29 +20,32 @@ export function nerdamerPlugin(): Plugin { let bundled: string | null = null; return { - name: 'llamacpp:nerdamer', - resolveId(id) { - return id === VIRTUAL_ID ? RESOLVED_ID : undefined; - }, async load(id) { if (id !== RESOLVED_ID) return undefined; + if (bundled === null) { const result = await build({ - entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')], - bundle: true, - minify: true, - format: 'iife', - globalName: 'nerdamer', alias: { 'big-integer': resolve(VENDORS_DIR, 'big-integer/BigInteger.js'), 'decimal.js': resolve(VENDORS_DIR, 'decimal.js/decimal.js') }, - write: false, - logLevel: 'silent' + bundle: true, + entryPoints: [resolve(VENDORS_DIR, 'nerdamer-prime/all.js')], + format: 'iife', + globalName: 'nerdamer', + logLevel: 'silent', + minify: true, + write: false }); + bundled = result.outputFiles[0].text; } + return `export default ${JSON.stringify(bundled)};`; + }, + name: 'llamacpp:nerdamer', + resolveId(id) { + return id === VIRTUAL_ID ? RESOLVED_ID : undefined; } }; } diff --git a/tools/ui/scripts/vite-plugin-relativize-base.ts b/tools/ui/scripts/vite-plugin-relativize-base.ts index ce2f1b6e9..f8eac1d66 100644 --- a/tools/ui/scripts/vite-plugin-relativize-base.ts +++ b/tools/ui/scripts/vite-plugin-relativize-base.ts @@ -1,7 +1,7 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { BUILD_CONFIG } from '../src/lib/constants/pwa'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'path'; import type { Plugin } from 'vite'; -import { BUILD_CONFIG } from '../src/lib/constants/pwa'; let processed = false; @@ -11,11 +11,15 @@ function rewrite(path: string, pairs: [string, string][]): void { if (!existsSync(path)) { return; } + const text = readFileSync(path, 'utf-8'); + let out = text; + for (const [from, to] of pairs) { out = out.split(from).join(to); } + if (out !== text) { writeFileSync(path, out, 'utf-8'); } @@ -32,12 +36,12 @@ function rewrite(path: string, pairs: [string, string][]): void { */ export function relativizeBasePlugin(): Plugin { return { - name: 'llamacpp:relativize-base', apply: 'build', closeBundle() { setTimeout(() => { try { if (processed) return; + processed = true; const outDir = resolve(OUTPUT_DIR); @@ -56,6 +60,7 @@ export function relativizeBasePlugin(): Plugin { console.error('Failed to relativize base refs:', error); } }, 100); - } + }, + name: 'llamacpp:relativize-base' }; } diff --git a/tools/ui/scripts/vite-plugin-splash-screen.ts b/tools/ui/scripts/vite-plugin-splash-screen.ts index 059ce4920..45d931bab 100644 --- a/tools/ui/scripts/vite-plugin-splash-screen.ts +++ b/tools/ui/scripts/vite-plugin-splash-screen.ts @@ -1,10 +1,10 @@ -import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { NEWLINE, TAB } from '../src/lib/constants/code'; +import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa'; +import { SplashOrientation } from '../src/lib/enums/splash.enums'; +import type { SplashDimensions } from '../src/lib/types'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'path'; import type { Plugin } from 'vite'; -import { TAB, NEWLINE } from '../src/lib/constants/code'; -import { APPLE_DEVICES, BUILD_CONFIG, REGEX_PATTERNS, SPLASH_LINK } from '../src/lib/constants/pwa'; -import type { SplashDimensions } from '../src/lib/types'; -import { SplashOrientation } from '../src/lib/enums/splash.enums'; let processed = false; @@ -16,23 +16,26 @@ const OUTPUT_DIR = process.env.LLAMA_UI_OUT_DIR ?? BUILD_CONFIG.OUTPUT_DIR; */ export function generateSplashScreenLinks(outDir: string): string[] { const files = readdirSync(outDir).filter((f) => f.match(REGEX_PATTERNS.SPLASH_FILE)); + if (files.length === 0) return []; const dimMap = new Map(); + for (const [dims, spec] of Object.entries(APPLE_DEVICES)) { const [w, h] = dims.split('x').map(Number); + // logical-point dimensions - dimMap.set(`${w}x${h}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr }); - dimMap.set(`${h}x${w}`, { deviceW: spec.width, deviceH: spec.height, dpr: spec.dpr }); + dimMap.set(`${w}x${h}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr }); + dimMap.set(`${h}x${w}`, { deviceH: spec.height, deviceW: spec.width, dpr: spec.dpr }); // pixel dimensions (used by actual generated splash files) dimMap.set(`${w * spec.dpr}x${h * spec.dpr}`, { - deviceW: spec.width, deviceH: spec.height, + deviceW: spec.width, dpr: spec.dpr }); dimMap.set(`${h * spec.dpr}x${w * spec.dpr}`, { - deviceW: spec.width, deviceH: spec.height, + deviceW: spec.width, dpr: spec.dpr }); } @@ -42,20 +45,23 @@ export function generateSplashScreenLinks(outDir: string): string[] { for (const file of files) { const match = file.match(REGEX_PATTERNS.SPLASH_FILE); + if (!match) continue; + const orientation = match[1] as SplashOrientation; const isDark = !!match[2]; const pixelW = parseInt(match[3]); const pixelH = parseInt(match[4]); - const key = `${pixelW}x${pixelH}`; const spec = dimMap.get(key); + if (!spec) { console.warn(`Unknown splash screen dimensions: ${key} (${file})`); + continue; } - const { deviceW, deviceH, dpr } = spec; + const { deviceH, deviceW, dpr } = spec; const media = `screen and (device-width: ${deviceW}px) and (device-height: ${deviceH}px) and (-webkit-device-pixel-ratio: ${dpr}) and (orientation: ${orientation})`; const href = `./${file}`; @@ -73,16 +79,17 @@ export function generateSplashScreenLinks(outDir: string): string[] { export function splashScreenPlugin(): Plugin { return { - name: 'llamacpp:splash-screen', apply: 'build', closeBundle() { setTimeout(() => { try { if (processed) return; + processed = true; const outDir = resolve(OUTPUT_DIR); const indexPath = resolve(outDir, 'index.html'); + if (!existsSync(indexPath)) return; let content = readFileSync(indexPath, 'utf-8'); @@ -91,9 +98,11 @@ export function splashScreenPlugin(): Plugin { // The @vite-pwa/assets-generator generates apple-splash-*.png files; // this scans them and creates the tags SvelteKit needs. const splashLinks = generateSplashScreenLinks(outDir); + if (splashLinks.length > 0) { console.log(`Generated ${splashLinks.length} apple-splash link tags`); const splashHtml = splashLinks.map((l) => TAB + TAB + l).join(NEWLINE); + content = content.replace( REGEX_PATTERNS.HEAD_CLOSE, splashHtml + NEWLINE + TAB + TAB + '' @@ -110,6 +119,7 @@ export function splashScreenPlugin(): Plugin { console.error('Failed to process build output:', error); } }, 100); - } + }, + name: 'llamacpp:splash-screen' }; } diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts index 814fcf523..6f26214e1 100644 --- a/tools/ui/src/app.d.ts +++ b/tools/ui/src/app.d.ts @@ -3,9 +3,8 @@ import 'vite-plugin-pwa/pwa-assets'; import 'vite-plugin-pwa/svelte'; - +import { ModelModality, ServerModelStatus, ServerRole } from '$lib/enums'; // Import chat types from dedicated module - import type { // API types ApiChatCompletionRequest, @@ -13,59 +12,57 @@ import type { ApiChatCompletionStreamChunk, ApiChatCompletionToolCall, ApiChatCompletionToolCallDelta, - ApiChatMessageData, ApiChatMessageContentPart, + ApiChatMessageData, ApiContextSizeError, ApiErrorResponse, ApiLlamaCppServerProps, ApiModelDataEntry, + ApiModelListResponse, ApiModelLoadStage, - ApiModelsSseProgress, ApiModelsSseData, ApiModelsSseEvent, - ApiModelListResponse, + ApiModelsSseProgress, ApiProcessingState, ApiRouterModelMeta, + ApiRouterModelsListResponse, ApiRouterModelsLoadRequest, ApiRouterModelsLoadResponse, ApiRouterModelsStatusRequest, ApiRouterModelsStatusResponse, - ApiRouterModelsListResponse, ApiRouterModelsUnloadRequest, ApiRouterModelsUnloadResponse, // Chat types ChatAttachmentDisplayItem, + ChatMessagePromptProgress, + ChatMessageSiblingInfo, + ChatMessageTimings, ChatMessageType, ChatRole, ChatUploadedFile, - ChatMessageSiblingInfo, - ChatMessagePromptProgress, - ChatMessageTimings, // Database types DatabaseConversation, DatabaseMessage, DatabaseMessageExtra, DatabaseMessageExtraAudioFile, - DatabaseMessageExtraVideoFile, DatabaseMessageExtraImageFile, - DatabaseMessageExtraTextFile, - DatabaseMessageExtraPdfFile, DatabaseMessageExtraLegacyContext, + DatabaseMessageExtraPdfFile, + DatabaseMessageExtraTextFile, + DatabaseMessageExtraVideoFile, ExportedConversation, ExportedConversations, + ModelLoadProgress, // Model types ModelModalities, ModelOption, - ModelLoadProgress, // Settings types SettingsChatServiceOptions, + SettingsConfigType, SettingsConfigValue, - SettingsFieldConfig, - SettingsConfigType + SettingsFieldConfig } from '$lib/types'; -import { ServerRole, ServerModelStatus, ModelModality } from '$lib/enums'; - declare global { // namespace App { // interface Error {} diff --git a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte index 608ff6fab..e29b5ad67 100644 --- a/tools/ui/src/lib/components/app/actions/ActionIcon.svelte +++ b/tools/ui/src/lib/components/app/actions/ActionIcon.svelte @@ -1,8 +1,8 @@ {#each modalities as modality (modality)} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte index e74bd8456..36895c8e7 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte @@ -28,18 +28,18 @@ } let { - class: className = '', - style = '', + activeModelId, attachments = [], - readonly = false, - onFileRemove, - uploadedFiles = $bindable([]), + class: className = '', // Default to small size for form previews imageClass = '', imageHeight = 'h-24', imageWidth = 'w-auto', limitToSingleRow = false, - activeModelId + onFileRemove, + readonly = false, + style = '', + uploadedFiles = $bindable([]) }: Props = $props(); let carouselRef: HorizontalScrollCarousel | undefined = $state(); @@ -48,7 +48,7 @@ let previewFocusIndex = $state(0); let viewAllDialogOpen = $state(false); - let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments })); + let displayItems = $derived(getAttachmentDisplayItems({ attachments, uploadedFiles })); function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) { event?.stopPropagation(); diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte index 143621cd9..ba06e1815 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItem.svelte @@ -2,8 +2,8 @@ import { ChatAttachmentsListItemMcpPrompt, ChatAttachmentsListItemMcpResource, - ChatAttachmentsListItemThumbnailImage, - ChatAttachmentsListItemThumbnailFile + ChatAttachmentsListItemThumbnailFile, + ChatAttachmentsListItemThumbnailImage } from '$lib/components/app'; import { AttachmentType } from '$lib/enums'; import type { @@ -49,10 +49,10 @@ return { id, resource: { - uri: extra.uri, name: extra.name, + serverName: extra.serverName, title: extra.name, - serverName: extra.serverName + uri: extra.uri } }; } @@ -64,12 +64,12 @@ ? (item.attachment as DatabaseMessageExtraMcpPrompt) : item.uploadedFile?.mcpPrompt ? { - type: AttachmentType.MCP_PROMPT as const, - name: item.name, - serverName: item.uploadedFile.mcpPrompt.serverName, - promptName: item.uploadedFile.mcpPrompt.promptName, + arguments: item.uploadedFile.mcpPrompt.arguments, content: item.textContent ?? '', - arguments: item.uploadedFile.mcpPrompt.arguments + name: item.name, + promptName: item.uploadedFile.mcpPrompt.promptName, + serverName: item.uploadedFile.mcpPrompt.serverName, + type: AttachmentType.MCP_PROMPT as const } : null} {#if mcpPrompt} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte index 636e93f22..f5452aade 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemMcpPrompt.svelte @@ -1,8 +1,8 @@
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte index 7c7cf5120..69b1724fb 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte @@ -1,13 +1,13 @@ {#if show} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte index 8a85df7d0..994a35f20 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte @@ -1,7 +1,7 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 1df125708..bc1befd63 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -1,4 +1,5 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte index 18fd24302..98b082c89 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte @@ -1,11 +1,8 @@
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte index fdec5aca5..ab36e4a43 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugeDetails.svelte @@ -1,8 +1,8 @@ {#if modelId !== null && !isLoading} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte index af9ad010e..db4479b65 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte @@ -1,15 +1,15 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte index 04b85b24b..3ebb29b9b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte @@ -1,9 +1,9 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte index 0fb0ea0c0..51a51f808 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte @@ -12,13 +12,13 @@ } let { + children, class: className = '', - isSelected = false, - disabled = false, - onclick, - onmouseenter, dataIndex, - children + disabled = false, + isSelected = false, + onclick, + onmouseenter }: Props = $props(); diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte index 5a2ab26fc..cbf7b972e 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte @@ -4,7 +4,7 @@ showBadge?: boolean; } - let { titleWidth = 'w-48', showBadge = false }: Props = $props(); + let { showBadge = false, titleWidth = 'w-48' }: Props = $props();
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte index 24ea8619f..b09d346f1 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte @@ -1,6 +1,6 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte index 22ec53119..126d08744 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte @@ -1,19 +1,19 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte index 638d10eef..074c69b84 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPromptPickerArgumentInput.svelte @@ -1,8 +1,8 @@ @@ -66,7 +66,7 @@ {#if isAutocompleteActive && suggestions.length > 0}
{#each suggestions as suggestion, i (suggestion)}