Compare commits

..
Author SHA1 Message Date
Aman Gupta b431451636 cont : move the loader change to #31321 2026-10-28 23:20:26 +08:00
Aman Gupta d88ec8cc70 cont : release loader staging buffer on shrink 2026-10-28 12:43:50 +08:00
Aman Gupta d80fe6bea3 RPC: improve loading 2026-10-28 12:43:50 +08:00
63 changed files with 656 additions and 1120 deletions
-3
View File
@@ -1803,9 +1803,6 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
//LOG_DBG("Parsing PEG input with format %s: %s\n", common_chat_format_name(params.format), effective_input.text.c_str());
common_peg_parse_flags flags = COMMON_PEG_PARSE_FLAG_LENIENT;
if (!is_partial) {
flags |= COMMON_PEG_PARSE_FLAG_COMPLETE;
}
if (params.debug) {
flags |= COMMON_PEG_PARSE_FLAG_DEBUG;
}
+3 -11
View File
@@ -76,10 +76,9 @@ common_chat_params common_chat_params_init_qwen3_coder(const common_chat_templat
auto reasoning = p.eps();
if (supports_reasoning && extract_reasoning) {
// The template strips the newline before </think>, so leave it out of the reasoning
reasoning = p.optional("<think>" + p.space() +
p.reasoning(p.until_one_of({ "\n</think>", "</think>", "<tool_call>" })) +
(p.literal("\n</think>") | p.literal("</think>") | p.peek(p.literal("<tool_call>"))));
p.reasoning(p.until_one_of({ "</think>", "<tool_call>" })) +
(p.literal("</think>") | p.peek(p.literal("<tool_call>"))));
}
// Response format parser
@@ -167,15 +166,8 @@ common_chat_params common_chat_params_init_qwen3_coder(const common_chat_templat
auto calls = inputs.parallel_tool_calls ? tool_call_first + p.zero_or_more(tool_call) : tool_call_first;
auto tool_calls = p.trigger_rule("tool-call-root", p.repeat(calls, min_calls, 1));
// The templates separate content from the tool calls with one or two newlines,
// stop the content before them
std::vector<std::string> content_ends;
for (const auto & start : tool_call_starts) {
content_ends.insert(content_ends.end(), { "\n\n" + start, "\n" + start, start });
}
return generation_prompt +
(reasoning << p.content(p.until_one_of(content_ends)) << tool_calls);
(reasoning << p.content(p.until_one_of(tool_call_starts)) << tool_calls);
}
// Content only parser
+2 -3
View File
@@ -721,9 +721,8 @@ struct parser_executor {
return common_peg_parse_result(COMMON_PEG_PARSE_RESULT_SUCCESS, start_pos, pos, {}, std::move(invalid_utf8));
}
if (match == common_trie::PARTIAL_MATCH && !ctx.is_complete()) {
// Found a partial match extending to end of input, return everything before it.
// On a complete input the delimiter can no longer form, so the bytes belong to the text
if (match == common_trie::PARTIAL_MATCH) {
// Found a partial match extending to end of input, return everything before it
return common_peg_parse_result(COMMON_PEG_PARSE_RESULT_SUCCESS, start_pos, pos, {}, std::move(invalid_utf8));
}
+3 -5
View File
@@ -162,10 +162,9 @@ struct common_peg_parse_result {
};
enum common_peg_parse_flags {
COMMON_PEG_PARSE_FLAG_NONE = 0,
COMMON_PEG_PARSE_FLAG_LENIENT = 1 << 0,
COMMON_PEG_PARSE_FLAG_DEBUG = 1 << 1,
COMMON_PEG_PARSE_FLAG_COMPLETE = 1 << 2,
COMMON_PEG_PARSE_FLAG_NONE = 0,
COMMON_PEG_PARSE_FLAG_LENIENT = 1 << 0,
COMMON_PEG_PARSE_FLAG_DEBUG = 1 << 1,
};
inline common_peg_parse_flags operator|(common_peg_parse_flags a, common_peg_parse_flags b) {
@@ -210,7 +209,6 @@ struct common_peg_parse_context {
bool is_lenient() const { return flags & COMMON_PEG_PARSE_FLAG_LENIENT; }
bool is_debug() const { return flags & COMMON_PEG_PARSE_FLAG_DEBUG; }
bool is_complete() const { return flags & COMMON_PEG_PARSE_FLAG_COMPLETE; }
};
class common_peg_arena;
+1 -1
View File
@@ -4,7 +4,7 @@ project("ggml" C CXX ASM)
### GGML Version
set(GGML_VERSION_MAJOR 0)
set(GGML_VERSION_MINOR 28)
set(GGML_VERSION_MINOR 27)
set(GGML_VERSION_PATCH 0)
set(GGML_VERSION_BASE "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
+1 -5
View File
@@ -402,10 +402,6 @@ static void launch_topk_moe_cuda(ggml_backend_cuda_context & ctx,
ggml_cuda_kernel_launch(topk_moe_cuda<288, has_bias>, launch_params,
logits, weights, ids, bias, n_rows, n_expert_used, clamp_val, scale_val, config);
break;
case 384: // DeepSeek V4.1
ggml_cuda_kernel_launch(topk_moe_cuda<384, has_bias>, launch_params,
logits, weights, ids, bias, n_rows, n_expert_used, clamp_val, scale_val, config);
break;
case 512:
ggml_cuda_kernel_launch(topk_moe_cuda<512, has_bias>, launch_params,
logits, weights, ids, bias, n_rows, n_expert_used, clamp_val, scale_val, config);
@@ -476,7 +472,7 @@ bool ggml_cuda_should_use_topk_moe(const ggml_tensor * gating_op,
// must match an instantiation of launch_topk_moe_cuda: a power of 2 up to 512,
// or one of the non-power-of-2 expert counts of supported models
const int n_expert = ids->nb[1] / ids->nb[0];
if (((n_expert & (n_expert - 1)) != 0 || n_expert > 512) && n_expert != 288 && n_expert != 384 && n_expert != 576) {
if (((n_expert & (n_expert - 1)) != 0 || n_expert > 512) && n_expert != 288 && n_expert != 576) {
return false;
}
+94 -30
View File
@@ -125,11 +125,16 @@ struct rpc_msg_hello_req {
uint8_t conn_caps[RPC_CONN_CAPS_SIZE];
};
// server feature flags in rpc_msg_hello_rsp::flags; old servers send 0
enum rpc_server_flags : uint8_t {
RPC_SERVER_FLAG_CACHE = 1 << 0, // server has a local tensor cache (rpc-server -c)
};
struct rpc_msg_hello_rsp {
uint8_t major;
uint8_t minor;
uint8_t patch;
uint8_t padding;
uint8_t flags;
uint8_t conn_caps[RPC_CONN_CAPS_SIZE];
};
@@ -373,21 +378,30 @@ static bool parse_endpoint(const std::string & endpoint, std::string & host, int
}
// RPC request : | rpc_cmd (1 byte) | request_size (8 bytes) | request_data (request_size bytes) |
// request_data is input followed by an optional payload, so large data does not need to be copied into input
// No response
static bool send_rpc_cmd(socket_ptr sock, enum rpc_cmd cmd, const void * input, size_t input_size) {
static bool send_rpc_cmd_payload(socket_ptr sock, enum rpc_cmd cmd, const void * input, size_t input_size, const void * payload, size_t payload_size) {
uint8_t cmd_byte = cmd;
if (!sock->send_data(&cmd_byte, sizeof(cmd_byte))) {
return false;
}
if (!sock->send_data(&input_size, sizeof(input_size))) {
const size_t request_size = input_size + payload_size;
if (!sock->send_data(&request_size, sizeof(request_size))) {
return false;
}
if (!sock->send_data(input, input_size)) {
return false;
}
if (payload_size > 0 && !sock->send_data(payload, payload_size)) {
return false;
}
return sock->flush();
}
static bool send_rpc_cmd(socket_ptr sock, enum rpc_cmd cmd, const void * input, size_t input_size) {
return send_rpc_cmd_payload(sock, cmd, input, input_size, nullptr, 0);
}
// RPC request : | rpc_cmd (1 byte) | request_size (8 bytes) | request_data (request_size bytes) |
// RPC response: | response_size (8 bytes) | response_data (response_size bytes) |
static bool send_rpc_cmd(socket_ptr sock, enum rpc_cmd cmd, const void * input, size_t input_size, void * output, size_t output_size) {
@@ -423,7 +437,7 @@ static inline void rpc_cpu_relax() {
// Performs HELLO handshake with transport auto-negotiation.
// Advertises local capabilities via conn_caps; if the server responds with
// matching capabilities, the socket is upgraded transparently.
static bool negotiate_hello(const std::shared_ptr<socket_t> & sock) {
static bool negotiate_hello(const std::shared_ptr<socket_t> & sock, uint8_t & server_flags) {
rpc_msg_hello_req request = {};
rpc_msg_hello_rsp response = {};
@@ -439,6 +453,7 @@ static bool negotiate_hello(const std::shared_ptr<socket_t> & sock) {
}
sock->update_caps(response.conn_caps);
server_flags = response.flags;
LOG_DBG("[%s] handshake ok: server version %d.%d.%d, transport: %s\n",
__func__, response.major, response.minor, response.patch, sock->transport_name());
return true;
@@ -503,6 +518,8 @@ public:
void send(enum rpc_cmd cmd, std::shared_ptr<const void> input, size_t input_size, void * output, size_t output_size);
void send_async(enum rpc_cmd cmd, std::shared_ptr<const void> input, size_t input_size);
void send_async(enum rpc_cmd cmd, std::shared_ptr<const void> input, size_t input_size, void * output, size_t output_size);
// payload is sent after input without a copy, the call returns once it is sent
void send_payload(enum rpc_cmd cmd, std::shared_ptr<const void> input, size_t input_size, const void * payload, size_t payload_size);
ggml_backend_event_t event_new(ggml_backend_dev_t dev);
void event_free(ggml_backend_event_t event);
@@ -516,6 +533,8 @@ public:
void start(const std::string & endpoint);
void work();
bool server_has_cache() const { return server_flags & RPC_SERVER_FLAG_CACHE; }
~rpc_dispatcher();
private:
@@ -525,6 +544,8 @@ private:
size_t input_size;
void * output;
size_t output_size;
const void * payload = nullptr;
size_t payload_size = 0;
std::promise<void> completion;
};
using rpc_msg_ptr = std::shared_ptr<rpc_msg>;
@@ -537,6 +558,7 @@ private:
std::unordered_map<uint32_t, std::unordered_set<uint64_t>> graph_uids;
rpc_msg_queue queue;
socket_ptr sock;
uint8_t server_flags = 0;
std::atomic_uint busy_spin_users = 0;
std::atomic_bool running;
std::thread thread;
@@ -569,6 +591,20 @@ void rpc_dispatcher::send_async(enum rpc_cmd cmd, std::shared_ptr<const void> in
GGML_ASSERT(queue.push(msg));
}
void rpc_dispatcher::send_payload(enum rpc_cmd cmd, std::shared_ptr<const void> input, size_t input_size, const void * payload, size_t payload_size) {
auto msg = std::make_shared<rpc_msg>();
msg->cmd = cmd;
msg->input = input;
msg->input_size = input_size;
msg->output = nullptr;
msg->output_size = 0;
msg->payload = payload;
msg->payload_size = payload_size;
GGML_ASSERT(queue.push(msg));
auto future = msg->completion.get_future();
future.wait();
}
void rpc_dispatcher::send(enum rpc_cmd cmd, std::shared_ptr<const void> input, size_t input_size, void * output, size_t output_size) {
auto msg = std::make_shared<rpc_msg>();
msg->cmd = cmd;
@@ -652,7 +688,7 @@ void rpc_dispatcher::start(const std::string & endpoint) {
if (sock == nullptr) {
GGML_ABORT("Failed to connect to %s\n", endpoint.c_str());
}
if (!negotiate_hello(sock)) {
if (!negotiate_hello(sock, server_flags)) {
GGML_ABORT("RPC handshake failed for %s\n", endpoint.c_str());
}
LOG_DBG("[%s] connected to %s\n", __func__, endpoint.c_str());
@@ -684,7 +720,7 @@ void rpc_dispatcher::work() {
bool status = send_rpc_cmd(sock, msg_ptr->cmd, msg_ptr->input.get(), msg_ptr->input_size, msg_ptr->output, msg_ptr->output_size);
RPC_STATUS_ASSERT(status);
} else {
bool status = send_rpc_cmd(sock, msg_ptr->cmd, msg_ptr->input.get(), msg_ptr->input_size);
bool status = send_rpc_cmd_payload(sock, msg_ptr->cmd, msg_ptr->input.get(), msg_ptr->input_size, msg_ptr->payload, msg_ptr->payload_size);
RPC_STATUS_ASSERT(status);
}
if (trace) {
@@ -829,14 +865,17 @@ static void ggml_backend_rpc_buffer_memset_tensor(
}
// input serialization format: | rpc_tensor | cache_flag (1 byte) | offset (8 bytes) | data (size bytes)
// with size == 0 only the header is serialized and data is sent separately as payload
static std::shared_ptr<uint8_t> serialize_set_tensor(const rpc_tensor & rpc_tensor, uint8_t cache_flag, uint64_t offset, const void * data, size_t size, size_t & input_size) {
input_size = sizeof(rpc_tensor) + sizeof(cache_flag) + sizeof(offset) + size;
uint8_t * input = new uint8_t[input_size]();
uint8_t * input = new uint8_t[input_size];
uint8_t * p = input;
memcpy(p, &rpc_tensor, sizeof(rpc_tensor)); p += sizeof(rpc_tensor);
memcpy(p, &cache_flag, sizeof(cache_flag)); p += sizeof(cache_flag);
memcpy(p, &offset, sizeof(offset)); p += sizeof(offset);
memcpy(p, data, size);
if (size > 0) {
memcpy(p, data, size);
}
return std::shared_ptr<uint8_t>(input, std::default_delete<uint8_t[]>());
}
@@ -844,8 +883,9 @@ static std::shared_ptr<uint8_t> serialize_set_tensor(const rpc_tensor & rpc_tens
// compute-buffer inputs (the activations ggml_backend_sched copies between backends) must not
// take this path, otherwise with `rpc-server -c` every ubatch above the threshold is written
// to the cache directory and later served from there.
static bool rpc_use_hash_cache(const ggml_tensor * tensor, size_t size) {
return size > HASH_THRESHOLD && tensor->buffer->usage == GGML_BACKEND_BUFFER_USAGE_WEIGHTS;
// hashing is slow (~1 GB/s), so skip it when the server has no cache to look up.
static bool rpc_use_hash_cache(const rpc_dispatcher & dispatcher, const ggml_tensor * tensor, size_t size) {
return dispatcher.server_has_cache() && size > HASH_THRESHOLD && tensor->buffer->usage == GGML_BACKEND_BUFFER_USAGE_WEIGHTS;
}
static void ggml_backend_rpc_buffer_set_tensor(ggml_backend_buffer_t buffer, ggml_tensor * tensor, const void * data, size_t offset, size_t size) {
@@ -854,7 +894,7 @@ static void ggml_backend_rpc_buffer_set_tensor(ggml_backend_buffer_t buffer, ggm
uint8_t cache_flag = 0;
LOG_DBG2("[%s] tensor: %s, offset: %zu, size: %s\n", __func__, rpc_log_format_tensor(tensor).c_str(), offset,
rpc_log_format_size(size).c_str());
if (rpc_use_hash_cache(tensor, size)) {
if (rpc_use_hash_cache(*ctx->dispatcher, tensor, size)) {
auto request = std::make_shared<rpc_msg_set_tensor_hash_req>();
request->tensor = rpc_tensor;
request->offset = offset;
@@ -870,9 +910,9 @@ static void ggml_backend_rpc_buffer_set_tensor(ggml_backend_buffer_t buffer, ggm
// the server has no cache entry for this tensor - ask it to save one
cache_flag = 1;
}
size_t input_size;
auto input = serialize_set_tensor(rpc_tensor, cache_flag, offset, data, size, input_size);
ctx->dispatcher->send(RPC_CMD_SET_TENSOR, input, input_size);
size_t header_size;
auto header = serialize_set_tensor(rpc_tensor, cache_flag, offset, nullptr, 0, header_size);
ctx->dispatcher->send_payload(RPC_CMD_SET_TENSOR, header, header_size, data, size);
}
static void ggml_backend_rpc_buffer_set_tensor_2d(ggml_backend_buffer_t buffer, ggml_tensor * tensor, const void * data,
@@ -1154,7 +1194,7 @@ static void ggml_backend_rpc_set_tensor_async(ggml_backend_t backend, ggml_tenso
uint8_t cache_flag = 0;
LOG_DBG2("[%s] tensor: %s, offset: %zu, size: %s\n", __func__, rpc_log_format_tensor(tensor).c_str(), offset,
rpc_log_format_size(size).c_str());
if (rpc_use_hash_cache(tensor, size)) {
if (rpc_use_hash_cache(*ctx->dispatcher, tensor, size)) {
auto request = std::make_shared<rpc_msg_set_tensor_hash_req>();
request->tensor = rpc_tensor;
request->offset = offset;
@@ -1405,7 +1445,7 @@ public:
bool free_buffer(const rpc_msg_free_buffer_req & request);
bool buffer_clear(const rpc_msg_buffer_clear_req & request);
bool memset_tensor(const rpc_msg_memset_tensor_req & request);
bool set_tensor(const std::vector<uint8_t> & input);
bool set_tensor(socket_ptr sock, uint64_t input_size);
bool set_tensor_2d(const std::vector<uint8_t> & input);
bool set_tensor_hash(const rpc_msg_set_tensor_hash_req & request, rpc_msg_set_tensor_hash_rsp & response);
bool get_tensor(const rpc_msg_get_tensor_req & request, std::vector<uint8_t> & response);
@@ -1452,12 +1492,15 @@ private:
// computed graphs cached per backend, keyed by uid
std::vector<std::unordered_map<uint64_t, stored_graph>> stored_graphs;
std::vector<comm_state> comm_states;
// SET_TENSOR data is received in chunks through this buffer
std::vector<uint8_t> set_tensor_buf;
};
void rpc_server::hello(rpc_msg_hello_rsp & response) {
response.major = RPC_PROTO_MAJOR_VERSION;
response.minor = RPC_PROTO_MINOR_VERSION;
response.patch = RPC_PROTO_PATCH_VERSION;
response.flags = cache_dir ? RPC_SERVER_FLAG_CACHE : 0;
LOG_DBG("[%s] version: %d.%d.%d\n", __func__, response.major, response.minor, response.patch);
}
@@ -1698,19 +1741,21 @@ ggml_tensor * rpc_server::deserialize_tensor(struct ggml_context * ctx, const rp
}
bool rpc_server::set_tensor(const std::vector<uint8_t> & input) {
bool rpc_server::set_tensor(socket_ptr sock, uint64_t input_size) {
sync_all_backends();
// serialization format: | rpc_tensor | cache_flag (1 byte) | offset (8 bytes) | data (size bytes) |
// only the header is read here, the data is streamed from the socket below
uint8_t cache_flag;
uint64_t offset;
const size_t header_size = sizeof(rpc_tensor) + sizeof(cache_flag) + sizeof(offset);
if (input.size() < header_size) {
uint8_t header[sizeof(rpc_tensor) + sizeof(cache_flag) + sizeof(offset)];
const size_t header_size = sizeof(header);
if (input_size < header_size || !sock->recv_data(header, header_size)) {
return false;
}
const rpc_tensor * in_tensor = (const rpc_tensor *)input.data();
memcpy(&cache_flag, input.data() + sizeof(rpc_tensor), sizeof(cache_flag));
memcpy(&offset, input.data() + sizeof(rpc_tensor) + sizeof(cache_flag), sizeof(offset));
const size_t size = input.size() - header_size;
const rpc_tensor * in_tensor = (const rpc_tensor *)header;
memcpy(&cache_flag, header + sizeof(rpc_tensor), sizeof(cache_flag));
memcpy(&offset, header + sizeof(rpc_tensor) + sizeof(cache_flag), sizeof(offset));
const size_t size = input_size - header_size;
struct ggml_init_params params {
/*.mem_size =*/ ggml_tensor_overhead(),
@@ -1741,18 +1786,37 @@ bool rpc_server::set_tensor(const std::vector<uint8_t> & input) {
}
}
const void * data = input.data() + header_size;
if (cache_dir && cache_flag) {
uint64_t hash = fnv_hash((const uint8_t*)data, size);
// the whole data is needed to compute the hash
std::vector<uint8_t> data(size);
if (!sock->recv_data(data.data(), size)) {
return false;
}
uint64_t hash = fnv_hash(data.data(), size);
char hash_str[17];
snprintf(hash_str, sizeof(hash_str), "%016" PRIx64, hash);
// save to cache_dir/hash_str
fs::path cache_file = fs::path(cache_dir) / hash_str;
std::ofstream ofs(cache_file, std::ios::binary);
ofs.write((const char *)data, size);
ofs.write((const char *)data.data(), size);
LOG_INFO("[%s] saved to '%s'\n", __func__, cache_file.string().c_str());
ggml_backend_tensor_set(tensor, data.data(), offset, size);
return true;
}
// a buffer of the tensor size would be allocated and page faulted for every tensor, which is slow
const size_t chunk_size = 16*1024*1024;
if (set_tensor_buf.size() < std::min(size, chunk_size)) {
set_tensor_buf.resize(std::min(size, chunk_size));
}
for (size_t done = 0; done < size; ) {
const size_t n = std::min(size - done, chunk_size);
if (!sock->recv_data(set_tensor_buf.data(), n)) {
return false;
}
ggml_backend_tensor_set(tensor, set_tensor_buf.data(), offset + done, n);
done += n;
}
ggml_backend_tensor_set(tensor, data, offset, size);
return true;
}
@@ -2601,11 +2665,11 @@ static void rpc_serve_client(const std::vector<ggml_backend_t> & backends, const
break;
}
case RPC_CMD_SET_TENSOR: {
std::vector<uint8_t> input;
if (!recv_msg(sock, input)) {
uint64_t input_size;
if (!sock->recv_data(&input_size, sizeof(input_size))) {
return;
}
if (!server.set_tensor(input)) {
if (!server.set_tensor(sock, input_size)) {
return;
}
break;
+20 -10
View File
@@ -71,6 +71,11 @@ struct rdma_conn {
struct ibv_mr * rx_mr = nullptr;
int rx_head = 0;
// received slot that is only partly consumed, so recv sizes need not match send sizes
int rx_cur = -1;
size_t rx_off = 0;
size_t rx_len = 0;
uint32_t max_inline = 0;
uint8_t * rx_slot(int i) const {
@@ -512,19 +517,24 @@ bool socket_t::impl::rdma_recv(void * data, size_t size) {
uint8_t * dst = (uint8_t *)data;
size_t rem = size;
while (rem > 0) {
struct ibv_wc wc;
if (!rdma_poll(c->rcq, &wc)) return false;
if (c->rx_cur < 0) {
struct ibv_wc wc;
if (!rdma_poll(c->rcq, &wc)) return false;
int slot = (int)wc.wr_id;
size_t got = wc.byte_len;
if (got > rem) {
LOG_ERROR("RDMA recv overrun: got=%zu rem=%zu\n", got, rem);
c->post_rx(slot);
return false;
c->rx_cur = (int)wc.wr_id;
c->rx_off = 0;
c->rx_len = wc.byte_len;
}
memcpy(dst, c->rx_slot(slot), got);
if (!c->post_rx(slot)) return false;
size_t got = std::min(rem, c->rx_len - c->rx_off);
memcpy(dst, c->rx_slot(c->rx_cur) + c->rx_off, got);
c->rx_off += got;
// repost the slot only once it is fully consumed
if (c->rx_off == c->rx_len) {
if (!c->post_rx(c->rx_cur)) return false;
c->rx_cur = -1;
}
dst += got;
rem -= got;
@@ -19,7 +19,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
uint32_t lo4 = blk.qs & 0x0F0F0F0F;
uint32_t hi4 = (blk.qs >> 4) & 0x0F0F0F0F;
lo4 = ((lo4 | 0x80808080) - 0x08080808) ^ 0x80808080;
@@ -28,7 +28,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
buf_a_d[ks * BM + buf_ib] = float(blk.d);
}
}
@@ -46,7 +46,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
// Store raw unsigned nibbles; the -8 offset is absorbed by the min term.
uint32_t lo4 = blk.qs & 0x0F0F0F0F;
uint32_t hi4 = (blk.qs >> 4) & 0x0F0F0F0F;
@@ -54,7 +54,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(float(blk.dm.x), float(blk.dm.y)) : vec2(0.0f);
buf_a_dm[ks * BM + buf_ib] = vec2(float(blk.dm.x), float(blk.dm.y));
}
}
@@ -75,7 +75,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
uint32_t lo4 = blk.qs & 0x0F0F0F0F;
uint32_t hi4 = (blk.qs >> 4) & 0x0F0F0F0F;
lo4 |= ((blk.qh >> (4u * loadr )) & 0xFu) * 0x02040810u & 0x10101010u;
@@ -86,7 +86,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
buf_a_d[ks * BM + buf_ib] = float(blk.d);
}
}
@@ -106,7 +106,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
// Store raw unsigned 5-bit values; the -16 offset is absorbed by the min term.
uint32_t lo4 = blk.qs & 0x0F0F0F0F;
uint32_t hi4 = (blk.qs >> 4) & 0x0F0F0F0F;
@@ -116,7 +116,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(float(blk.dm.x), float(blk.dm.y)) : vec2(0.0f);
buf_a_dm[ks * BM + buf_ib] = vec2(float(blk.dm.x), float(blk.dm.y));
}
}
@@ -135,11 +135,11 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr] = blk.qs;
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
buf_a_d[ks * BM + buf_ib] = float(blk.d);
}
}
@@ -158,7 +158,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
const u8vec4 lo_idx = unpack8(blk.qs & 0x0F0F0F0F);
const u8vec4 hi_idx = unpack8((blk.qs >> 4) & 0x0F0F0F0F);
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr ] =
@@ -169,7 +169,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
buf_a_d[ks * BM + buf_ib] = float(blk.d);
}
}
@@ -194,7 +194,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
const u8vec4 lo_idx = unpack8(blk.qs & 0x0F0F0F0F);
const u8vec4 hi_idx = unpack8((blk.qs >> 4) & 0x0F0F0F0F);
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr ] =
@@ -205,7 +205,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = in_bounds ? blk.d : 0.0f;
buf_a_d[ks * BM + buf_ib] = blk.d;
}
}
@@ -226,7 +226,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
const u8vec4 lo_idx = unpack8(blk.qs & 0x0F0F0F0F);
const u8vec4 hi_idx = unpack8((blk.qs >> 4) & 0x0F0F0F0F);
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr ] =
@@ -237,7 +237,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = in_bounds ? e8m0_to_fp32(blk.e) * 0.5 : 0.0f;
buf_a_d[ks * BM + buf_ib] = e8m0_to_fp32(blk.e) * 0.5;
}
}
@@ -271,7 +271,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
// Store raw unsigned nibbles (blk.qs already masked); no -8 recentering needed.
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr * 2 ] = blk.qs0;
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr * 2 + 1] = blk.qs1;
@@ -287,7 +287,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
const uint mn_val = (sub < 4) ? (s_j4 & 0x3Fu) : ((s_j8 >> 4) | ((s_j4 >> 6) << 4));
vec2 dm = vec2(data_a_packed32[ib_k].dm);
float d_scaled = dm.x * float(sc_val);
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(d_scaled, -(dm.y * float(mn_val))) : vec2(0.0f);
buf_a_dm[ks * BM + buf_ib] = vec2(d_scaled, -(dm.y * float(mn_val)));
}
}
@@ -323,7 +323,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
// Store raw unsigned 5-bit values (qs nibble | qh bit); no -16 recentering needed.
uint32_t v0 = blk.qs0 | blk.qh0;
uint32_t v1 = blk.qs1 | blk.qh1;
@@ -341,7 +341,7 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
const uint mn_val = (sub < 4) ? (s_j4 & 0x3Fu) : ((s_j8 >> 4) | ((s_j4 >> 6) << 4));
vec2 dm = vec2(data_a_packed32[ib_k].dm);
float d_scaled = dm.x * float(sc_val);
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(d_scaled, -(dm.y * float(mn_val))) : vec2(0.0f);
buf_a_dm[ks * BM + buf_ib] = vec2(d_scaled, -(dm.y * float(mn_val)));
}
}
@@ -388,7 +388,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
uint32_t v0 = ((blk.qs0 | 0x80808080) - 0x20202020) ^ 0x80808080;
uint32_t v1 = ((blk.qs1 | 0x80808080) - 0x20202020) ^ 0x80808080;
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr * 2 ] = v0;
@@ -398,8 +398,8 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
const uint ib_k = blk.ib / 8;
const uint sub = blk.ib % 8;
i8vec2 sc = unpack8(int32_t(int16_t(data_a_packed16[ib_k].scales[sub]))).xy;
buf_a_d[(ks * KSCALES ) * BM + buf_ib] = in_bounds ? float(data_a_packed16[ib_k].d) * float(sc.x) : 0.0f;
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = in_bounds ? float(data_a_packed16[ib_k].d) * float(sc.y) : 0.0f;
buf_a_d[(ks * KSCALES ) * BM + buf_ib] = float(data_a_packed16[ib_k].d) * float(sc.x);
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = float(data_a_packed16[ib_k].d) * float(sc.y);
}
}
@@ -439,7 +439,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
uint32_t v0 = ((blk.qs0 | 0x80808080) - 0x04040404) ^ 0x80808080;
uint32_t v1 = ((blk.qs1 | 0x80808080) - 0x04040404) ^ 0x80808080;
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr * 2 ] = v0;
@@ -456,8 +456,8 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
uint combined = lo | (hi << 4);
i8vec2 sc = unpack8(int32_t(combined)).xy;
float d = float(data_a_packed16[ib_k].d);
buf_a_d[(ks * KSCALES ) * BM + buf_ib] = in_bounds ? d * float(int(sc.x) - 32) : 0.0f;
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = in_bounds ? d * float(int(sc.y) - 32) : 0.0f;
buf_a_d[(ks * KSCALES ) * BM + buf_ib] = d * float(int(sc.x) - 32);
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = d * float(int(sc.y) - 32);
}
}
@@ -486,7 +486,7 @@ block_a_prefetch block_a_load(uint ib, uint loadr) {
return blk;
}
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr) {
const u8vec4 lo_idx = unpack8(blk.qs & 0x0F0F0F0F);
const u8vec4 hi_idx = unpack8((blk.qs >> 4) & 0x0F0F0F0F);
const uint sub_base = (loadr >> 1) * 4;
@@ -499,8 +499,8 @@ void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[(ks * KSCALES ) * BM + buf_ib] = in_bounds ? ue4m3_to_fp32(blk.d0) * 0.5 : 0.0f;
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = in_bounds ? ue4m3_to_fp32(blk.d1) * 0.5 : 0.0f;
buf_a_d[(ks * KSCALES ) * BM + buf_ib] = ue4m3_to_fp32(blk.d0) * 0.5;
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = ue4m3_to_fp32(blk.d1) * 0.5;
}
}
@@ -559,9 +559,7 @@ void block_b_to_shmem(block_b_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
if (buf_ib < BM) { \
const uint ib = pos_a_ib + buf_ib * p.stride_a / BK; \
[[unroll]] for (uint ks = 0; ks < BK_STEP; ks++) { \
/* a k step past end_k re-reads an in-bounds block; its scale is zeroed below */ \
const uint ib_k = ((blk) + ks * BK < end_k) ? (ib + ks) : ib; \
pre_a[li * BK_STEP + ks] = block_a_load(ib_k, loadr_a); \
pre_a[li * BK_STEP + ks] = block_a_load(ib + ks, loadr_a); \
} \
} \
} \
@@ -581,8 +579,7 @@ void block_b_to_shmem(block_b_prefetch blk, uint buf_ib, uint ks, uint loadr, bo
const uint buf_ib = loadc_a + li * loadstride_a; \
if (buf_ib < BM) { \
[[unroll]] for (uint ks = 0; ks < BK_STEP; ks++) { \
const bool in_bounds = (blk) + ks * BK < end_k; \
block_a_to_shmem(pre_a[li * BK_STEP + ks], buf_ib, ks, loadr_a, in_bounds); \
block_a_to_shmem(pre_a[li * BK_STEP + ks], buf_ib, ks, loadr_a); \
} \
} \
} \
+1 -2
View File
@@ -42,8 +42,7 @@ def _apply_over_grouped_rows(func: Callable[[np.ndarray], np.ndarray], arr: np.n
# round away from zero
# ref: https://stackoverflow.com/a/59143326/22827863
def np_roundf(n: np.ndarray) -> np.ndarray:
# np.abs, not abs(): numpy < 2.3 on Python 3.14 can write abs() in place into n when the caller passes a temporary (numpy/numpy#28681), dropping the sign
a = np.abs(n)
a = abs(n)
floored = np.floor(a)
b = floored + np.floor(2 * (a - floored))
return np.sign(n) * b
+1 -1
View File
@@ -7,7 +7,7 @@ dynamic = ["classifiers"]
readme = "README.md"
authors = [{name = "GGML", email = "ggml@ggml.ai"}]
requires-python = '>=3.10'
dependencies = ['numpy (>=2.2.6); python_version < "3.14"', 'numpy (>=2.3.0); python_version >= "3.14"', 'tqdm (>=4.27)', 'pyyaml (>=5.1)', 'requests (>=2.25)']
dependencies = ['numpy (>=2.2.6)', 'tqdm (>=4.27)', 'pyyaml (>=5.1)', 'requests (>=2.25)']
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
+1 -2
View File
@@ -8,8 +8,7 @@ readme = "README.md"
authors = [{name = "GGML", email = "ggml@ggml.ai"}]
requires-python = '>=3.10,<3.15'
dependencies = [
'numpy (>=2.2.6,<3.0.0); python_version < "3.14"',
'numpy (>=2.3.0,<3.0.0); python_version >= "3.14"',
'numpy (>=2.2.6,<3.0.0)',
'sentencepiece (>=0.1.98,<0.3.0)',
'transformers (==4.57.6)',
'protobuf (>=4.21.0,<5.0.0)',
@@ -1,5 +1,4 @@
numpy~=2.2.6; python_version < "3.14"
numpy~=2.4.6; python_version >= "3.14"
numpy~=2.2.6
sentencepiece>=0.1.98,<0.3.0
transformers==4.57.6
+1 -1
View File
@@ -1 +1 @@
facdc44a67da17df4433adcc468b424f48e9ded2
f1aa6a3465ffad40dadd390ac16b9deedd4e97f7
+1 -1
View File
@@ -303,7 +303,7 @@ llama_kv_cache::llama_kv_cache(
const bool attn_rot_disable = !model.attn_rot || (env_rot_disable != nullptr && atoi(env_rot_disable) != 0);
if (attn_rot_disable) {
LLAMA_LOG_INFO("%s: attention rotation force disabled (%s)\n", __func__,
LLAMA_LOG_WARN("%s: attention rotation force disabled (%s)\n", __func__,
model.attn_rot ? "LLAMA_ATTN_ROT_DISABLE" : "llama_model_set_attn_rot");
}
+2 -2
View File
@@ -37,7 +37,7 @@ struct case_multi_seq_split_replay : ctx_test {
// both contexts have the same layout, only what they decode differs
const auto make_ctx_multi = [&]() {
ctx_opts opts;
opts.n_ctx = 512;
opts.n_ctx = 256;
opts.n_batch = 256;
opts.n_ubatch = n_ubatch;
opts.n_seq_max = n_seqs;
@@ -367,7 +367,7 @@ struct case_shared_seq_reserve : ctx_test {
constexpr uint32_t n_continue = 32;
ctx_opts opts;
opts.n_ctx = 256;
opts.n_ctx = 512;
opts.n_batch = 256;
opts.n_ubatch = 64;
opts.n_seq_max = n_seqs;
-3
View File
@@ -12439,9 +12439,6 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_topk_moe({160, 4, 1, 1}, 160, with_norm, bias_probs, gate, scale_w));
test_cases.emplace_back(new test_topk_moe({256, 22, 1, 1}, 6, with_norm, bias_probs, gate, scale_w)); // Used by DeepSeek-V4
test_cases.emplace_back(new test_topk_moe({288, 22, 1, 1}, 8, with_norm, bias_probs, gate, scale_w)); // Used by StepFun 3.7
test_cases.emplace_back(new test_topk_moe({384, 1, 1, 1}, 6, with_norm, bias_probs, gate, scale_w)); // Used by DeepSeek-V4.1
test_cases.emplace_back(new test_topk_moe({384, 8, 1, 1}, 6, with_norm, bias_probs, gate, scale_w));
test_cases.emplace_back(new test_topk_moe({384, 22, 1, 1}, 6, with_norm, bias_probs, gate, scale_w));
// rows at and just past the limit where one block still covers all rows
test_cases.emplace_back(new test_topk_moe({32, 8, 1, 1}, 4, with_norm, bias_probs, gate, scale_w));
test_cases.emplace_back(new test_topk_moe({32, 8, 1, 1}, 8, with_norm, bias_probs, gate, scale_w));
+6 -6
View File
@@ -2473,7 +2473,7 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
.tools({
run_in_terminal_tool
})
.expect_content("Let me inspect the current directory.")
.expect_content("Let me inspect the current directory.\n")
.expect_tool_calls({
{ "run_in_terminal", R"({"command": "pwd"})", {} },
})
@@ -2495,7 +2495,7 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
run_in_terminal_tool
})
.tool_choice(COMMON_CHAT_TOOL_CHOICE_REQUIRED)
.expect_content("Let me inspect the current directory.")
.expect_content("Let me inspect the current directory.\n")
.expect_tool_calls({
{ "run_in_terminal", R"({"command": "pwd"})", {} },
})
@@ -2604,7 +2604,7 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
.tool_choice(COMMON_CHAT_TOOL_CHOICE_REQUIRED)
.messages({ user_start, assistant_todos, tool_result, user_continue })
.expect_reasoning("I need to run a terminal command.")
.expect_content("Let me inspect the current directory.")
.expect_content("Let me inspect the current directory.\n")
.expect_tool_calls({
{ "run_in_terminal", R"({"command": "pwd"})", {} },
})
@@ -8146,12 +8146,12 @@ static void test_chat_session() {
for (size_t i = 0; i < thinking.size(); i += 3) {
session.feed(common_chat_input(thinking.substr(i, 3)));
}
assert_msg_equals(simple_assist_msg("", "I'm\nthinking"), session.msg());
assert_msg_equals(simple_assist_msg("", "I'm\nthinking\n"), session.msg());
for (size_t i = thinking.size(); i < output.size(); i += 3) {
session.feed(common_chat_input(output.substr(i, 3)));
}
assert_msg_equals(simple_assist_msg("", "I'm\nthinking", "special_function", "{\"arg1\":1}"), session.finish());
assert_msg_equals(simple_assist_msg("", "I'm\nthinking\n", "special_function", "{\"arg1\":1}"), session.finish());
}
// a copy does not see what is fed to the original
@@ -8176,7 +8176,7 @@ static void test_chat_session() {
common_chat_session session(tmpls.get(), nullptr, cont);
assert_equals(std::string("<|im_start|>assistant\n<think>\nI'm thinking\n</think>\n\nHello, "),
session.generation_prompt());
assert_msg_equals(simple_assist_msg("Hello, ", "I'm thinking"), session.msg());
assert_msg_equals(simple_assist_msg("Hello, ", "I'm thinking\n"), session.msg());
session.feed(common_chat_input("world!"));
assert_equals(std::string("Hello, world!"), session.msg().content);
+1 -2
View File
@@ -2,8 +2,7 @@ aiohttp~=3.9.3
pytest~=8.3.3
pytest-xdist~=3.6
filelock~=3.16
numpy~=2.2.6; python_version < "3.14"
numpy~=2.4.6; python_version >= "3.14"
numpy~=2.2.6
openai~=2.14.0
prometheus-client~=0.20.0
requests~=2.32.3
@@ -4,7 +4,7 @@
import * as Drawer from '$lib/components/ui/drawer';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT, REASONING_EFFORT_LABELS } from '$lib/constants';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { deviceStore } from '$lib/stores';
@@ -36,7 +36,7 @@
{/if}
{#if !isDefault}
<span>{REASONING_EFFORT_LABELS[reasoning.currentEffort]}</span>
<span class="capitalize">{reasoning.currentEffort}</span>
{/if}
</span>
{/snippet}
@@ -34,11 +34,12 @@
settingsStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { tick, untrack } from 'svelte';
import { onMount, tick, untrack } from 'svelte';
let { showCenteredEmpty = false } = $props();
let emptyFileNames = $state<string[]>([]);
let initialMessage = $state('');
let showDeleteDialog = $state(false);
let showEmptyFileDialog = $state(false);
let isEmpty = $derived(
@@ -196,7 +197,11 @@
}
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
await chatStore.addSystemPrompt(draft);
if (draft.message || draft.files.length > 0) {
chatStore.savePendingDraft(draft.message, draft.files);
}
await chatStore.addSystemPrompt();
}
$effect(() => {
@@ -208,6 +213,15 @@
return () => stick.detach();
});
onMount(() => {
const pendingDraft = chatStore.consumePendingDraft();
if (pendingDraft) {
initialMessage = pendingDraft.message;
fileUpload.uploadedFiles = pendingDraft.files;
}
});
</script>
{#if dragAndDrop.isDragOver}
@@ -273,6 +287,7 @@
bind:uploadedFiles={fileUpload.uploadedFiles}
class="pointer-events-auto conversation-chat-form"
disabled={hasPropsError || chatStore.isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={fileUpload.handleFileRemove}
onFileUpload={fileUpload.handleFileUpload}
@@ -9,6 +9,7 @@
interface Props {
class?: string;
disabled?: boolean;
initialMessage?: string;
isLoading?: boolean;
onFileRemove?: (fileId: string) => void;
onFileUpload?: (files: File[]) => void;
@@ -21,6 +22,7 @@
let {
class: className,
disabled = false,
initialMessage = '',
isLoading = false,
onFileRemove,
onFileUpload,
@@ -60,12 +62,14 @@
};
});
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let message = $state('');
let message = $derived(initialMessage);
let previousIsLoading = $derived(isLoading);
let previousInitialMessage = $derived(initialMessage);
const { clearDraft } = useDraftMessages({
getChatId: () => chatId,
getFiles: () => uploadedFiles,
getInitialMessage: () => initialMessage,
getMessage: () => message,
setFiles: (f) => (uploadedFiles = f),
setMessage: (m) => (message = m)
@@ -128,6 +132,13 @@
}
});
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(focusFormUnlessCaptured, 10);
@@ -1,261 +0,0 @@
<script lang="ts">
import { ArrowDown, ArrowUp, CornerDownLeft, MessageSquare, Pin, X } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { ActionIcon, KeyboardShortcutInfo } from '$lib/components/app';
import * as Dialog from '$lib/components/ui/dialog';
import {
ICON_CLASS_DEFAULT,
ICON_CLASS_SM,
ICON_CLASS_XS,
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { KeyboardKey, SidebarAction } from '$lib/enums';
import { RouterService } from '$lib/services/router.service';
import { conversationsStore } from '$lib/stores';
import { formatRelativeTime, isIMEComposing } from '$lib/utils';
import type { Component } from 'svelte';
interface Props {
open: boolean;
/** Runs a sidebar action picked from the actions section. */
onAction?: (action: SidebarAction) => void;
}
let { onAction, open = $bindable() }: Props = $props();
interface SearchEntry {
icon: Component;
id: string;
keys?: string[];
label: string;
meta?: string;
pinned?: boolean;
run: () => void;
}
interface SearchSection {
entries: SearchEntry[];
title: string;
}
/** Recent chats shown before typing, so the actions stay in view below them. */
const RECENT_LIMIT = 5;
/** Matches kept while typing; the rest stays reachable by typing more. */
const MATCH_LIMIT = 50;
let query = $state('');
let highlighted = $state(0);
let inputRef = $state<HTMLInputElement | null>(null);
const needle = $derived(query.trim().toLowerCase());
const limit = $derived(needle ? MATCH_LIMIT : RECENT_LIMIT);
function matches(label: string): boolean {
return !needle || label.toLowerCase().includes(needle);
}
const chatEntries = $derived.by((): SearchEntry[] =>
conversationsStore.conversations
.filter((conversation) => matches(conversation.name))
.sort((a, b) => b.lastModified - a.lastModified)
.slice(0, limit)
.map((conversation) => ({
icon: MessageSquare,
id: `chat-${conversation.id}`,
label: conversation.name,
meta: formatRelativeTime(conversation.lastModified),
pinned: conversation.pinned,
run: () => close(() => goto(RouterService.chat(conversation.id)))
}))
);
const actionEntries = $derived.by((): SearchEntry[] =>
// every sidebar action but Search itself, which is what opened this dialog
SIDEBAR_ACTIONS_ITEMS.filter((item) => item.action && item.action !== SidebarAction.SEARCH)
.map((item) => ({
icon: item.icon,
id: `action-${item.action}`,
keys: item.keys,
label: item.tooltip,
run: () => close(() => onAction?.(item.action!))
}))
.filter((entry) => matches(entry.label))
);
const sections = $derived<SearchSection[]>(
[
{ entries: chatEntries, title: needle ? 'Chats' : 'Recents' },
{ entries: actionEntries, title: 'Actions' }
].filter((section) => section.entries.length > 0)
);
const entries = $derived(sections.flatMap((section) => section.entries));
const activeIndex = $derived(Math.min(highlighted, entries.length - 1));
const emptyMessage = $derived(needle ? 'No results found' : 'No chats yet');
/** Closes the search first, so a dialog opened by `next` takes the focus. */
function close(next: () => void) {
handleOpenChange(false);
next();
}
function move(step: number) {
if (entries.length === 0) return;
highlighted = (activeIndex + step + entries.length) % entries.length;
document.getElementById(`search-result-${highlighted}`)?.scrollIntoView({ block: 'nearest' });
}
function handleKeydown(event: KeyboardEvent) {
// while an IME is composing, Enter confirms a candidate and the arrows move through them
if (isIMEComposing(event)) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
move(1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
move(-1);
} else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
entries[activeIndex]?.run();
}
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) {
query = '';
highlighted = 0;
}
open = nextOpen;
}
</script>
{#snippet hint(label: string, keys: (string | Component)[])}
<span class="flex items-center gap-1.5">
{label}
<span class="flex items-center gap-1">
{#each keys as Key, index (index)}
<kbd
class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border/60 px-1 font-sans text-[11px]"
>
{#if typeof Key === 'string'}
{Key}
{:else}
<Key class={ICON_CLASS_XS} />
{/if}
</kbd>
{/each}
</span>
</span>
{/snippet}
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="top-[12%] flex h-[min(36rem,76dvh)]! w-full max-w-2xl! translate-y-0 flex-col gap-0 overflow-hidden rounded-xl p-0 max-md:top-4 md:w-full md:max-w-2xl!"
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef?.focus();
}}
>
<Dialog.Title class="sr-only">Search</Dialog.Title>
<div class="border-b border-border/30 px-4 py-3 dark:border-border/20">
<div class="flex items-center gap-2">
<input
bind:this={inputRef}
bind:value={query}
aria-activedescendant={entries.length > 0 ? `search-result-${activeIndex}` : undefined}
aria-controls="search-results"
aria-expanded="true"
autocomplete="off"
class="h-10 min-w-0 flex-1 border-0 bg-transparent px-1 text-lg outline-none placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0"
oninput={() => (highlighted = 0)}
onkeydown={handleKeydown}
placeholder="Search"
role="combobox"
spellcheck={false}
/>
<ActionIcon
ariaLabel="Close search"
class="h-8 w-8 shrink-0 text-muted-foreground"
icon={X}
iconSize={ICON_CLASS_DEFAULT}
onclick={() => handleOpenChange(false)}
/>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2" id="search-results" role="listbox">
{#each sections as section, sectionIndex (section.title)}
{@const offset = sections
.slice(0, sectionIndex)
.reduce((total, previous) => total + previous.entries.length, 0)}
<div class="flex flex-col gap-0.5 {sectionIndex > 0 ? 'mt-3' : ''}" role="group">
<p class="px-2.5 pt-1 pb-1.5 text-xs font-medium text-muted-foreground">
{section.title}
</p>
{#each section.entries as entry, entryIndex (entry.id)}
{@const index = offset + entryIndex}
{@const isActive = index === activeIndex}
<button
aria-selected={isActive}
class="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm transition-colors {isActive
? 'bg-accent text-accent-foreground'
: ''}"
id="search-result-{index}"
onclick={entry.run}
onmousemove={() => (highlighted = index)}
role="option"
tabindex={-1}
type="button"
>
<entry.icon class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{entry.label}</span>
{#if entry.pinned}
<Pin aria-label="Pinned" class="{ICON_CLASS_SM} shrink-0 text-muted-foreground" />
{/if}
{#if entry.meta}
<span class="max-w-[40%] shrink-0 truncate text-xs text-muted-foreground">
{entry.meta}
</span>
{/if}
{#if entry.keys}
<KeyboardShortcutInfo class="opacity-100" keys={entry.keys} />
{/if}
<CornerDownLeft
class="{ICON_CLASS_SM} shrink-0 text-muted-foreground {isActive
? 'opacity-100'
: 'opacity-0'}"
/>
</button>
{/each}
</div>
{:else}
<p class="px-3 py-12 text-center text-sm text-muted-foreground">{emptyMessage}</p>
{/each}
</div>
<div
class="flex items-center gap-5 border-t border-border/30 px-4 py-2.5 text-xs text-muted-foreground max-md:hidden dark:border-border/20"
>
{@render hint('Close', ['Esc'])}
{@render hint('Navigate', [ArrowUp, ArrowDown])}
<span class="ml-auto">{@render hint('Open', [CornerDownLeft])}</span>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -108,14 +108,6 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
*/
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
/**
* **DialogSearch** - Spotlight-style search over chats and sidebar actions
*
* Opens over the app with a focused input and a keyboard-driven result list:
* arrow keys move the highlight, Enter opens, Escape closes. Desktop only; phones use the `/search` page.
*/
export { default as DialogSearch } from './DialogSearch.svelte';
/**
* **DialogConfirmDownload** - Confirm a destructive download action
*
@@ -1,6 +1,5 @@
<script lang="ts">
import { ArrowBigUp } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
interface Props {
keys: string[];
@@ -20,9 +19,7 @@
<kbd class="{baseClasses} {variantClasses} {className}">
{#each keys as key, index (index)}
{#if key === 'shift'}
<ArrowBigUp
class="{ICON_CLASS_DEFAULT} {variant === 'destructive' ? 'text-destructive' : ''} -mr-1"
/>
<ArrowBigUp class="h-1 w-1 {variant === 'destructive' ? 'text-destructive' : ''} -mr-1" />
{:else if key === 'cmd'}
<span class={variant === 'destructive' ? 'text-destructive' : ''}>⌘</span>
{:else}
@@ -8,7 +8,6 @@
DialogConversationRename,
DialogDiscoverModels,
DialogMcpServers,
DialogSearch,
DialogSettingsChat,
Logo,
ModelsManagerModelDrawer,
@@ -24,7 +23,7 @@
SIDEBAR_OVERSTRETCH_PX,
SIDEBAR_SETTLE_MS
} from '$lib/constants';
import { SidebarAction, TooltipSide } from '$lib/enums';
import { TooltipSide } from '$lib/enums';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
import { RouterService } from '$lib/services/router.service';
@@ -34,10 +33,14 @@
import { SvelteSet } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
interface Props {
onSearchClick?: () => void;
}
let { onSearchClick = () => {} }: Props = $props();
const { handleKeydown } = useKeyboardShortcuts({
openModelsManager: () => handleSidebarAction(SidebarAction.MANAGE_MODELS),
openSearch: () => handleSidebarAction(SidebarAction.SEARCH),
openSettings: () => handleSidebarAction(SidebarAction.SETTINGS),
activateSearchMode: () => onSearchClick(),
toggleSidebar: () => toggleExpandedMode()
});
@@ -64,6 +67,9 @@
$effect(() => {
if (!uiStore.isSidebarExpanded) {
isSearchModeActive = false;
searchQuery = '';
if (isSelectionMode) exitSelectionMode();
cancelMobileCollapse();
@@ -77,18 +83,22 @@
});
let currentChatId = $derived(page.params.id);
let searchOpen = $state(false);
let isSearchModeActive = $state(false);
let searchQuery = $state('');
// a phone shows the search page instead of the spotlight overlay
function openSearch() {
if (deviceStore.isMobile) {
void goto(ROUTES.SEARCH);
let filteredConversations = $derived.by(() => {
if (isSearchModeActive) {
if (searchQuery.trim().length > 0) {
return conversationsStore.conversations.filter((conversation: { name: string }) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
return;
return [];
}
searchOpen = true;
}
return conversationsStore.conversations;
});
let isSelectionMode = $state(false);
let selectedIds = new SvelteSet<string>();
@@ -101,7 +111,7 @@
let renameOriginalTitle = $state('');
const renderedOrderIds = $derived(
buildConversationTree(conversationsStore.conversations).map((t) => t.conversation.id)
buildConversationTree(filteredConversations).map((t) => t.conversation.id)
);
const allSelectedArePinned = $derived.by(() => {
@@ -141,7 +151,7 @@
});
const visibleSelectionStats = $derived.by(() => {
const visibleIds = conversationsStore.conversations.map((c) => c.id);
const visibleIds = filteredConversations.map((c) => c.id);
let selectedVisible = 0;
@@ -177,7 +187,7 @@
}
function toggleSelectAllVisible() {
const visibleIds = conversationsStore.conversations.map((c) => c.id);
const visibleIds = filteredConversations.map((c) => c.id);
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id));
if (allSelected) {
@@ -444,36 +454,6 @@
open();
}
/** Runs a sidebar action, whether picked from the icon strip, the search dialog or a shortcut. */
function handleSidebarAction(action: SidebarAction): void {
switch (action) {
case SidebarAction.NEW_CHAT:
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
void conversationsStore.openNewChat();
break;
case SidebarAction.SEARCH:
openSearch();
break;
case SidebarAction.MANAGE_MODELS:
openDialog(() => uiStore.openModelsManager());
break;
case SidebarAction.MCP:
openDialog(() => (mcpDialogOpen = true));
break;
case SidebarAction.SETTINGS:
openDialog(() => (settingsDialogOpen = true));
break;
}
}
</script>
<svelte:window bind:innerWidth onkeydown={handleKeydown} />
@@ -525,7 +505,7 @@
/>
</div>
{#if isOnMobile || uiStore.isSidebarExpanded}
{#if isOnMobile || (uiStore.isSidebarExpanded && !alwaysShowOnDesktop)}
<div
in:fade={{ delay: 50, duration: 150, easing: circIn }}
out:fade={{ duration: 100 }}
@@ -534,18 +514,16 @@
? 'opacity-0 h-0!'
: ''}"
>
{#if isOnMobile || !alwaysShowOnDesktop}
<ActionIcon
ariaLabel="Collapse navigation"
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 max-md:-mr-1 max-md:-mt-1 hover:bg-accent!"
icon={deviceStore.isMobile ? X : PanelLeftClose}
iconSize="h-4 w-4"
onclick={toggleExpandedMode}
size="lg"
tooltip="Close Sidebar"
tooltipSide={TooltipSide.LEFT}
/>
{/if}
<ActionIcon
ariaLabel="Collapse navigation"
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 max-md:-mr-1 max-md:-mt-1 hover:bg-accent!"
icon={deviceStore.isMobile ? X : PanelLeftClose}
iconSize="h-4 w-4"
onclick={toggleExpandedMode}
size="lg"
tooltip="Close Sidebar"
tooltipSide={TooltipSide.LEFT}
/>
</div>
{/if}
</div>
@@ -558,14 +536,26 @@
: ''} {deviceStore.isMobile && !uiStore.isSidebarExpanded ? 'opacity-0 !h-0' : ''}"
>
<SidebarNavigationActions
bind:isSearchModeActive
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
onAction={handleSidebarAction}
onNavigate={() => {
onManageModelsClick={() => openDialog(() => uiStore.openModelsManager())}
onMcpClick={() => openDialog(() => (mcpDialogOpen = true))}
onNewChat={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
}}
onSearchClick={() => {
uiStore.isSidebarExpanded = true;
isSearchModeActive = true;
}}
onSearchDeactivated={() => {
isSearchModeActive = false;
searchQuery = '';
}}
onSettingsClick={() => openDialog(() => (settingsDialogOpen = true))}
/>
{#if uiStore.isSidebarExpanded || isOnMobile}
@@ -575,8 +565,9 @@
allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
visibleSelectionStats.selectedVisibleCount === visibleSelectionStats.visibleCount}
class="px-2"
conversations={conversationsStore.conversations}
{currentChatId}
{filteredConversations}
{isSearchModeActive}
{isSelectionMode}
onBulkDelete={handleBulkDelete}
onBulkExport={handleBulkExport}
@@ -592,6 +583,7 @@
onStop={handleStopGeneration}
onToggleSelect={toggleSelected}
{pinStateIsMixed}
{searchQuery}
{selectedIds}
someVisibleSelected={visibleSelectionStats.selectedVisibleCount > 0 &&
visibleSelectionStats.selectedVisibleCount < visibleSelectionStats.visibleCount}
@@ -646,8 +638,6 @@
<DialogSettingsChat bind:open={settingsDialogOpen} />
<DialogSearch bind:open={searchOpen} onAction={handleSidebarAction} />
<DialogMcpServers bind:open={mcpDialogOpen} />
<DialogDiscoverModels bind:open={uiStore.discoverModelsOpen} />
@@ -1,16 +1,18 @@
<script lang="ts">
import { Search } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon, KeyboardShortcutInfo } from '$lib/components/app';
import { ActionIcon, KeyboardShortcutInfo, SearchInput } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import {
ICON_CLASS_DEFAULT,
ICON_STRIP_TRANSITION_DELAY_MULTIPLIER,
ICON_STRIP_TRANSITION_DURATION,
ROUTES,
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { SidebarAction, TooltipSide } from '$lib/enums';
import { deviceStore } from '$lib/stores';
import { conversationsStore, deviceStore } from '$lib/stores';
import type { DesktopIconStripItem } from '$lib/types';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
@@ -20,12 +22,28 @@
interface Props {
class: string;
isExpandedMode: boolean;
onAction?: (action: SidebarAction) => void;
/** Called before following a route item, so a phone can collapse the sidebar. */
onNavigate?: () => void;
isSearchModeActive: boolean;
searchQuery: string;
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
onManageModelsClick?: () => void;
onMcpClick?: () => void;
onNewChat?: () => void;
onSettingsClick?: () => void;
}
let { class: className, isExpandedMode = false, onAction, onNavigate }: Props = $props();
let {
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
onManageModelsClick,
onMcpClick,
onNewChat,
onSearchClick,
onSearchDeactivated,
onSettingsClick,
searchQuery = $bindable('')
}: Props = $props();
// the models manager stays desktop-only: a phone picks its models in the selector
const actions = $derived(
@@ -35,9 +53,16 @@
);
let initialized = $state(false);
let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
const isOnMobile = $derived(deviceStore.isMobile);
$effect(() => {
if (isSearchModeActive && searchInputRef) {
searchInputRef.focus();
}
});
onMount(() => {
showIcons = true;
@@ -46,22 +71,41 @@
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actions.length);
});
/** Click handler of one icon strip item: a sidebar action or a route. */
function itemClickHandler(item: DesktopIconStripItem): (() => void) | undefined {
const { action, route } = item;
if (action) {
return () => onAction?.(action);
/** Click handler of one icon strip item: a sidebar action, a route, or the search. */
function itemClickHandler(
item: DesktopIconStripItem,
isSearchOnMobile: boolean
): (() => void) | undefined {
switch (item.action) {
case SidebarAction.NEW_CHAT:
return () => {
onNewChat?.();
void conversationsStore.openNewChat();
};
case SidebarAction.MANAGE_MODELS:
return () => onManageModelsClick?.();
case SidebarAction.MCP:
return () => onMcpClick?.();
case SidebarAction.SETTINGS:
return () => onSettingsClick?.();
default:
break;
}
if (route) {
if (item.route) {
return () => {
onNavigate?.();
goto(route);
onNewChat?.();
goto(item.route!);
};
}
return undefined;
return isSearchOnMobile ? undefined : onSearchClick;
}
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
onSearchDeactivated?.();
}
function isItemActive(item: {
@@ -89,7 +133,17 @@
<IconComponent class={ICON_CLASS_DEFAULT} />
{/snippet}
{#if isExpandedMode || isOnMobile}
{#if isSearchModeActive}
<div class="px-4 my-2">
<SearchInput
bind:ref={searchInputRef}
bind:value={searchQuery}
onClose={handleSearchModeDeactivate}
onKeyDown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
placeholder="Search conversations..."
/>
</div>
{:else if isExpandedMode || isOnMobile}
<div
class="{className} flex flex-col gap-5 md:gap-1 mt-2 md:mt-0 {!isExpandedMode && isOnMobile
? 'hidden pointer-events-none'
@@ -97,7 +151,9 @@
>
{#each actions as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const itemOnClick = itemClickHandler(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
{@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -110,7 +166,7 @@
class="w-full min-w-9 justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isActive
? 'bg-accent text-accent-foreground'
: ''}"
href={item.route}
href={itemHref}
onclick={itemOnClick}
size="default"
variant="ghost"
@@ -139,7 +195,8 @@
<div class="{className} flex-col gap-1 hidden md:flex">
{#each actions as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const itemOnClick = itemClickHandler(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -16,14 +16,7 @@
import { TruncatedText } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
FORK_TREE_DEPTH_PADDING,
ICON_CLASS_DEFAULT,
SHORTCUT_DELETE_CONVERSATION,
SHORTCUT_EDIT_CONVERSATION,
SHORTCUT_EXPORT_CONVERSATION,
UI_DATA_ATTRS
} from '$lib/constants';
import { FORK_TREE_DEPTH_PADDING, ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, settingsStore, tabsStore } from '$lib/stores';
import { onMount } from 'svelte';
@@ -295,7 +288,7 @@
icon: Pencil,
label: 'Edit',
onclick: handleEdit,
shortcut: SHORTCUT_EDIT_CONVERSATION
shortcut: ['shift', 'cmd', 'e']
},
{
icon: Download,
@@ -304,7 +297,7 @@
e.stopPropagation();
conversationsStore.downloadConversation(conversation.id);
},
shortcut: SHORTCUT_EXPORT_CONVERSATION
shortcut: ['shift', 'cmd', 's']
},
{
icon: ListChecks,
@@ -316,7 +309,7 @@
label: 'Delete',
onclick: handleDelete,
separator: true,
shortcut: SHORTCUT_DELETE_CONVERSATION,
shortcut: ['shift', 'cmd', 'd'],
variant: 'destructive'
}
]}
@@ -1,13 +1,16 @@
<script lang="ts">
import SidebarNavigationConversationItem from './SidebarNavigationConversationItem.svelte';
import SidebarNavigationSearchResults from './SidebarNavigationSearchResults.svelte';
import SidebarNavigationSelectionBar from './SidebarNavigationSelectionBar.svelte';
import { Pin } from '@lucide/svelte';
import { buildConversationTree } from '$lib/utils';
interface Props {
class: string;
conversations: DatabaseConversation[];
filteredConversations: DatabaseConversation[];
currentChatId: string | undefined;
isSearchModeActive: boolean;
searchQuery: string;
isSelectionMode?: boolean;
selectedIds?: Set<string>;
onSelect: (id: string) => void;
@@ -34,8 +37,9 @@
allSelectedArePinned,
allVisibleSelected,
class: className,
conversations,
currentChatId,
filteredConversations,
isSearchModeActive,
isSelectionMode = false,
onBulkDelete,
onBulkExport,
@@ -51,12 +55,13 @@
onStop,
onToggleSelect,
pinStateIsMixed,
searchQuery,
selectedIds = new Set<string>(),
someVisibleSelected,
visibleCount
}: Props = $props();
let conversationTree = $derived(buildConversationTree(conversations));
let conversationTree = $derived(buildConversationTree(filteredConversations));
let pinnedConversations = $derived(
conversationTree.filter(({ conversation }) => conversation.pinned)
@@ -65,62 +70,44 @@
let unpinnedConversations = $derived(
conversationTree.filter(({ conversation }) => !conversation.pinned)
);
const recentEmptyMessage = $derived(
searchQuery.length > 0 ? 'No results found' : 'No conversations yet'
);
</script>
<div class="flex min-h-0 flex-1 flex-col">
{#if pinnedConversations.length > 0}
<div class="py-2 flex whitespace-nowrap {className}">
<div
class="text-muted-foreground inline-flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium gap-1"
>
<Pin class="h-3.5 w-3.5" />
{#if isSearchModeActive}
<SidebarNavigationSearchResults
class={className}
{currentChatId}
{filteredConversations}
{isSelectionMode}
{onDelete}
{onEdit}
{onEnterSelectionMode}
{onRowMouseDown}
{onSelect}
{onSelectionClick}
{onStop}
{onToggleSelect}
{searchQuery}
{selectedIds}
/>
{:else}
{#if pinnedConversations.length > 0}
<div class="py-2 flex whitespace-nowrap {className}">
<div
class="text-muted-foreground inline-flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium gap-1"
>
<Pin class="h-3.5 w-3.5" />
<span>Pinned</span>
<span>Pinned</span>
</div>
</div>
</div>
<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1 {className}">
{#each pinnedConversations as { conversation, depth } (conversation.id)}
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId,
id: conversation.id,
lastModified: conversation.lastModified,
name: conversation.name,
pinned: conversation.pinned
}}
{depth}
isActive={currentChatId === conversation.id}
isSelected={selectedIds.has(conversation.id)}
{isSelectionMode}
{onDelete}
{onEdit}
{onEnterSelectionMode}
{onRowMouseDown}
{onSelect}
{onSelectionClick}
{onStop}
{onToggleSelect}
/>
</li>
{/each}
</ul>
{/if}
<div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-0 whitespace-nowrap {className}">
{#if conversations.length > 0}
<div
class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
>
Recent conversations
</div>
{/if}
<div class="min-h-0 flex-1 md:overflow-y-auto">
<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0">
{#each unpinnedConversations as { conversation, depth } (conversation.id)}
<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-1 {className}">
{#each pinnedConversations as { conversation, depth } (conversation.id)}
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
@@ -146,30 +133,73 @@
/>
</li>
{/each}
{#if unpinnedConversations.length === 0}
<li class="px-2 py-4 text-center">
<p class="mb-4 p-4 text-sm text-muted-foreground">No conversations yet</p>
</li>
{/if}
</ul>
</div>
</div>
{/if}
{#if isSelectionMode}
<SidebarNavigationSelectionBar
{allVisibleSelected}
class="sticky top-0 z-10 m-2 mt-0"
{onBulkDelete}
{onBulkExport}
{onBulkPinToggle}
onClose={onCloseSelection}
{onSelectAllToggle}
{pinStateIsMixed}
selectedCount={selectedIds.size}
someSelectedPinned={allSelectedArePinned}
{someVisibleSelected}
{visibleCount}
/>
<div class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-0 whitespace-nowrap {className}">
{#if filteredConversations.length > 0}
<div
class="text-muted-foreground flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium"
>
Recent conversations
</div>
{/if}
<div class="min-h-0 flex-1 md:overflow-y-auto">
<ul class="flex w-full min-w-0 flex-col gap-4 md:gap-0">
{#each unpinnedConversations as { conversation, depth } (conversation.id)}
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId,
id: conversation.id,
lastModified: conversation.lastModified,
name: conversation.name,
pinned: conversation.pinned
}}
{depth}
isActive={currentChatId === conversation.id}
isSelected={selectedIds.has(conversation.id)}
{isSelectionMode}
{onDelete}
{onEdit}
{onEnterSelectionMode}
{onRowMouseDown}
{onSelect}
{onSelectionClick}
{onStop}
{onToggleSelect}
/>
</li>
{/each}
{#if unpinnedConversations.length === 0}
<li class="px-2 py-4 text-center">
<p class="mb-4 p-4 text-sm text-muted-foreground">
{recentEmptyMessage}
</p>
</li>
{/if}
</ul>
</div>
</div>
{#if isSelectionMode}
<SidebarNavigationSelectionBar
{allVisibleSelected}
class="sticky top-0 z-10 m-2 mt-0"
{onBulkDelete}
{onBulkExport}
{onBulkPinToggle}
onClose={onCloseSelection}
{onSelectAllToggle}
{pinStateIsMixed}
selectedCount={selectedIds.size}
someSelectedPinned={allSelectedArePinned}
{someVisibleSelected}
{visibleCount}
/>
{/if}
{/if}
</div>
@@ -66,13 +66,14 @@ export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
/**
* **SidebarNavigation** - Sidebar with actions menu and conversation list
*
* Collapsible sidebar displaying conversation history and management actions. Integrates with ShadCN sidebar component for
* Collapsible sidebar displaying conversation history with search and
* management actions. Integrates with ShadCN sidebar component for
* consistent styling and mobile responsiveness.
*
* **Architecture:**
* - Uses ShadCN Sidebar.* components for structure
* - Fetches conversations from conversationsStore
* - Owns the search dialog (desktop) and routes phones to the search page
* - Manages search state and filtered results locally
* - Handles conversation CRUD operations via conversationsStore
*
* **Navigation:**
@@ -87,7 +88,7 @@ export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
* - Delete All: Removes all conversations with confirmation
*
* **Features:**
* - Search dialog (Cmd+K) over chats and sidebar actions
* - Search/filter conversations by title
* - Conversation list with message previews (first message truncated)
* - Active conversation highlighting
* - Mobile-responsive collapse/expand via ShadCN sidebar
@@ -95,6 +96,7 @@ export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
* - Settings button opens DialogChatSettings
*
* **Exported API:**
* - `activateSearchMode()` - Focus search input programmatically
* - `editActiveConversation()` - Open rename dialog for current conversation
*
* @example
@@ -146,14 +148,17 @@ export { default as SidebarNavigationSelectionBar } from './SidebarNavigation/Si
* **SidebarNavigationConversationList** - Grouped conversation list
*
* Pure-presentational list of conversations. Splits items into a Pinned
* section and a Recent Conversations section with the unpinned items. Item selection, edit, delete,
* section (when not in search mode) and a Recent Conversations / Search
* Results section with the unpinned items. Item selection, edit, delete,
* and stop-generation are delegated to the caller via callbacks.
*
* @example
* ```svelte
* <SidebarNavigationConversationList
* {conversations}
* {filteredConversations}
* {currentChatId}
* {isSearchModeActive}
* {searchQuery}
* onSelect={...}
* onEdit={...}
* onDelete={...}
@@ -169,8 +174,9 @@ export { default as SidebarNavigationActions } from './SidebarNavigation/Sidebar
*
* Pure-presentational rendering of the search-mode subtree: "Search results"
* header, the matching items rendered through {@link SidebarNavigationConversationItem},
* and contextual empty-state messages. Used as the body of the mobile `/search`
* route; desktop search goes through {@link DialogSearch}.
* and contextual empty-state messages. Used both inline inside
* {@link SidebarNavigationConversationList} (when search mode is active in the
* sidebar) and as the body of the mobile `/search` route.
*
* The caller is expected to provide an already-filtered list via
* `filteredConversations` and a `searchQuery` for the empty-state messages.
@@ -6,8 +6,7 @@
SettingsChatImportExportTab,
SettingsChatMobileHeader,
SettingsChatToolsTab,
SettingsFooter,
SettingsResetButton
SettingsFooter
} from '$lib/components/app/settings';
import { Button } from '$lib/components/ui/button';
import {
@@ -49,20 +48,6 @@
let localConfig: SettingsConfigType = $state({ ...settingsStore.config });
// inputs hand back strings for numeric fields, so compare stringified values
let hasChanges = $derived.by(() => {
const saved = settingsStore.config;
const keys = new Set([...Object.keys(saved), ...Object.keys(localConfig)]);
for (const key of keys) {
if (String(localConfig[key] ?? '') !== String(saved[key] ?? '')) {
return true;
}
}
return false;
});
let mobileHeader: { updateCarousel: () => void } | undefined;
let fetchInitiated = false;
@@ -183,9 +168,7 @@
/>
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.GENERAL}
<div class="flex justify-end gap-2">
<SettingsResetButton onReset={handleReset} />
<div class="flex justify-end">
<Button onclick={() => window.location.reload()} variant="outline">
<RefreshCw class="h-3 w-3" />
Reload app
@@ -196,12 +179,12 @@
{/if}
</div>
<div class="mt-8 border-t border-border/30 pt-6 pb-6">
<div class="mt-8 border-t border-border/30 pt-6">
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
</div>
</div>
<SettingsFooter {hasChanges} onSave={handleSave} />
<SettingsFooter onReset={handleReset} onSave={handleSave} />
</div>
</div>
</div>
@@ -1,20 +1,62 @@
<script lang="ts">
import { RotateCcw } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { settingsStore } from '$lib/stores';
interface Props {
hasChanges?: boolean;
onReset?: () => void;
onSave?: () => void;
}
let { hasChanges = true, onSave }: Props = $props();
let { onReset, onSave }: Props = $props();
let showResetDialog = $state(false);
function handleResetClick() {
showResetDialog = true;
}
function handleConfirmReset() {
settingsStore.forceSyncWithServerDefaults();
onReset?.();
showResetDialog = false;
}
function handleSave() {
onSave?.();
}
</script>
{#if hasChanges}
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-end pb-4 md:pb-0">
<Button onclick={handleSave}>Save settings</Button>
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between pb-4 md:pb-0">
<div class="flex gap-2">
<Button onclick={handleResetClick} variant="outline">
<RotateCcw class="h-3 w-3" />
Reset to default
</Button>
</div>
{/if}
<Button onclick={handleSave}>Save settings</Button>
</div>
<AlertDialog.Root bind:open={showResetDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to reset all settings to their default values? This will reset all
parameters to the values provided by the server's /props endpoint and remove all your custom
configurations.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,53 +0,0 @@
<script lang="ts">
import { RotateCcw } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import { settingsStore } from '$lib/stores';
interface Props {
onReset?: () => void;
}
let { onReset }: Props = $props();
let showResetDialog = $state(false);
function handleResetClick() {
showResetDialog = true;
}
function handleConfirmReset() {
settingsStore.forceSyncWithServerDefaults();
onReset?.();
showResetDialog = false;
}
</script>
{#if settingsStore.hasCustomSettings}
<Button onclick={handleResetClick} variant="outline">
<RotateCcw class="h-3 w-3" />
Reset to default
</Button>
{/if}
<AlertDialog.Root bind:open={showResetDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Reset Settings to Default</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to reset all settings to their default values? This will reset all
parameters to the values provided by the server's /props endpoint and remove all your custom
configurations.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={handleConfirmReset}>Reset to Default</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -35,17 +35,12 @@ export { default as SettingsChatParameterSourceIndicator } from './SettingsChat/
export { default as SettingsGroup } from './SettingsGroup.svelte';
/**
* Sticky footer with the save button for settings panel, shown only when
* there are unsaved changes. Save commits form state to config store.
* Footer with save/cancel buttons for settings panel. Positioned at bottom
* of settings dialog. Save button commits form state to config store,
* cancel button triggers reset and close.
*/
export { default as SettingsFooter } from './SettingsFooter.svelte';
/**
* "Reset to default" button with confirmation dialog, shown only when the
* saved config differs from the defaults.
*/
export { default as SettingsResetButton } from './SettingsResetButton.svelte';
/**
* Settings Import/Export panel.
* Provides UI for importing and exporting chat conversations.
@@ -1,10 +1,6 @@
export const MS_PER_SECOND = 1000;
export const SECONDS_PER_MINUTE = 60;
export const SECONDS_PER_HOUR = 3600;
export const SECONDS_PER_DAY = 86400;
/** Age past which a relative time label falls back to a plain date. */
export const RELATIVE_TIME_MAX_DAYS = 30;
export const SHORT_DURATION_THRESHOLD = 1;
export const MEDIUM_DURATION_THRESHOLD = 10;
-1
View File
@@ -30,7 +30,6 @@ export * from './css-classes.constants';
export * from './formatters.constants';
export * from './headers.constants';
export * from './key-value-pairs.constants';
export * from './keyboard-shortcuts.constants';
export * from './latex-protection.constants';
export * from './literal-html.constants';
export * from './markdown.constants';
@@ -1,9 +0,0 @@
// Key combinations shown in shortcut hints; `KeyboardShortcutInfo` renders 'shift' and 'cmd' as symbols.
export const SHORTCUT_NEW_CHAT = ['shift', 'cmd', 'o'];
export const SHORTCUT_SEARCH = ['cmd', 'k'];
export const SHORTCUT_MODELS = ['shift', 'cmd', 'm'];
export const SHORTCUT_SETTINGS = ['cmd', ','];
export const SHORTCUT_EDIT_CONVERSATION = ['shift', 'cmd', 'e'];
export const SHORTCUT_EXPORT_CONVERSATION = ['shift', 'cmd', 's'];
export const SHORTCUT_DELETE_CONVERSATION = ['shift', 'cmd', 'd'];
@@ -11,20 +11,12 @@ export const REASONING_EFFORT_LABELS: Record<string, string> = {
[ReasoningEffort.LOW]: 'Low',
[ReasoningEffort.MAX]: 'Max',
[ReasoningEffort.MEDIUM]: 'Medium',
[ReasoningEffort.MINIMAL]: 'Minimal',
[ReasoningEffort.OFF]: 'Off',
[ReasoningEffort.XHIGH]: 'Extra High'
[ReasoningEffort.OFF]: 'Off'
};
/** Levels of every menu, ahead of the template levels or the budget levels. */
export const REASONING_EFFORT_BASE_LEVELS: ReasoningEffortLevel[] = [
export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [
{ label: 'Default', value: ReasoningEffort.DEFAULT },
{ label: 'Off', value: ReasoningEffort.OFF }
];
/** Menu of a chat template that honors no effort level: each level is a token budget. */
export const REASONING_EFFORT_BUDGET_LEVELS: ReasoningEffortLevel[] = [
...REASONING_EFFORT_BASE_LEVELS,
{ label: 'Off', value: ReasoningEffort.OFF },
{ label: 'Low', value: ReasoningEffort.LOW },
{ label: 'Medium', value: ReasoningEffort.MEDIUM },
{ label: 'High', value: ReasoningEffort.HIGH },
+2 -10
View File
@@ -1,10 +1,4 @@
import { MODEL_ICON } from './icons.constants';
import {
SHORTCUT_MODELS,
SHORTCUT_NEW_CHAT,
SHORTCUT_SEARCH,
SHORTCUT_SETTINGS
} from './keyboard-shortcuts.constants';
import { MODELS_ROUTE_ID } from './routes.constants';
import { Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
@@ -96,15 +90,14 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{
action: SidebarAction.NEW_CHAT,
icon: SquarePen,
keys: SHORTCUT_NEW_CHAT,
keys: ['shift', 'cmd', 'o'],
tooltip: 'New chat'
},
{ action: SidebarAction.SEARCH, icon: Search, keys: SHORTCUT_SEARCH, tooltip: 'Search' },
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
action: SidebarAction.MANAGE_MODELS,
activeRouteId: MODELS_ROUTE_ID,
icon: MODEL_ICON,
keys: SHORTCUT_MODELS,
tooltip: 'Models'
},
{
@@ -115,7 +108,6 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{
action: SidebarAction.SETTINGS,
icon: Settings,
keys: SHORTCUT_SETTINGS,
tooltip: 'Settings'
}
];
-1
View File
@@ -193,7 +193,6 @@ export enum MimeTypeAudio {
MP3 = 'audio/mp3',
MP3_MPEG = 'audio/mpeg',
MP4 = 'audio/mp4',
OGG = 'audio/ogg',
VND_WAVE = 'audio/vnd.wave',
WAV = 'audio/wav',
WAVE = 'audio/wave',
-3
View File
@@ -9,7 +9,6 @@ export enum KeyboardKey {
B_LOWER = 'b',
BRACKET_LEFT = 'BracketLeft',
BRACKET_RIGHT = 'BracketRight',
COMMA = ',',
D_LOWER = 'd',
D_UPPER = 'D',
E_UPPER = 'E',
@@ -17,8 +16,6 @@ export enum KeyboardKey {
ESCAPE = 'Escape',
HOME = 'Home',
K_LOWER = 'k',
M_LOWER = 'm',
M_UPPER = 'M',
O_LOWER = 'o',
O_UPPER = 'O',
PAGE_UP = 'PageUp',
@@ -1,6 +1,6 @@
/**
* Reasoning effort levels for thinking models.
* A level the chat template honors is sent as reasoning_effort, the others map to token budgets.
* These values are sent to the server and mapped to token budgets.
*/
export enum ReasoningEffort {
DEFAULT = 'default',
@@ -8,7 +8,5 @@ export enum ReasoningEffort {
LOW = 'low',
MAX = 'max',
MEDIUM = 'medium',
MINIMAL = 'minimal',
OFF = 'off',
XHIGH = 'xhigh'
OFF = 'off'
}
-1
View File
@@ -26,7 +26,6 @@ export enum SidebarAction {
MANAGE_MODELS = 'manage-models',
MCP = 'mcp',
NEW_CHAT = 'new-chat',
SEARCH = 'search',
SETTINGS = 'settings'
}
@@ -8,6 +8,7 @@ interface UseDraftMessagesOptions {
getFiles: () => ChatUploadedFile[];
setMessage: (message: string) => void;
setFiles: (files: ChatUploadedFile[]) => void;
getInitialMessage: () => string;
}
export function useDraftMessages(options: UseDraftMessagesOptions) {
@@ -15,7 +16,7 @@ export function useDraftMessages(options: UseDraftMessagesOptions) {
const chatId = options.getChatId();
const draft = draftMessagesStore.getDraftMessage(chatId);
if (draft.message || draft.files.length > 0) {
if ((draft.message || draft.files.length > 0) && !options.getInitialMessage()) {
options.setMessage(draft.message);
options.setFiles(draft.files);
}
@@ -4,15 +4,14 @@ import { KeyboardKey } from '$lib/enums';
import { conversationsStore, settingsStore, tabsStore } from '$lib/stores';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
editActiveConversation?: () => void;
onSearchActivated?: () => void;
deleteActiveConversation?: () => void;
navigateToPrevConversation?: () => void;
navigateToNextConversation?: () => void;
navigateToPrevTab?: () => void;
navigateToNextTab?: () => void;
openModelsManager?: () => void;
openSearch?: () => void;
openSettings?: () => void;
toggleSidebar?: () => void;
}
@@ -22,12 +21,8 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
if (isCmdOrCtrl && event.key === KeyboardKey.K_LOWER) {
event.preventDefault();
callbacks.openSearch?.();
}
if (isCmdOrCtrl && event.key === KeyboardKey.COMMA) {
event.preventDefault();
callbacks.openSettings?.();
callbacks.activateSearchMode?.();
callbacks.onSearchActivated?.();
}
if (isCmdOrCtrl && event.key === KeyboardKey.B_LOWER) {
@@ -45,15 +40,6 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
void conversationsStore.openNewChat();
}
if (
isCmdOrCtrl &&
event.shiftKey &&
(event.key === KeyboardKey.M_LOWER || event.key === KeyboardKey.M_UPPER)
) {
event.preventDefault();
callbacks.openModelsManager?.();
}
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
event.preventDefault();
callbacks.editActiveConversation?.();
@@ -1,4 +1,4 @@
import { REASONING_EFFORT_TOKENS } from '$lib/constants';
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_TOKENS } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import type { ReasoningEffortLevel } from '$lib/types';
@@ -54,27 +54,7 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
return modelsStore.props.supportsThinking || modelSupportsThinkingFromMessages;
});
// the model the next request goes to, as getApiOptions() resolves it
const modelId = $derived(modelsStore.selectedModelName ?? '');
// levels the chat template honors are sent as reasoning_effort and carry no token budget
const hasTemplateLevels = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.getReasoningEfforts(modelId).length > 0;
});
const levels = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.getReasoningLevels(modelId);
});
const currentEffort = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.resolveReasoningEffort(
modelId,
conversationsStore.preferences.getReasoningEffort()
);
});
const currentEffort = $derived(conversationsStore.preferences.getReasoningEffort());
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
@@ -99,7 +79,7 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
return currentEffort === level.value;
},
get levels() {
return levels;
return REASONING_EFFORT_LEVELS;
},
get modelSupportsThinking() {
return modelSupportsThinking;
@@ -113,8 +93,6 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
tokenLabel(level: ReasoningEffortLevel): string | null {
if (level.value === ReasoningEffort.DEFAULT) return 'Model default';
if (hasTemplateLevels) return null;
const tokens = REASONING_EFFORT_TOKENS[level.value];
if (tokens === undefined) return null;
+6 -7
View File
@@ -15,6 +15,7 @@ import {
CONTROL_ACTION,
HEADERS,
LEGACY_AGENTIC_REGEX,
REASONING_EFFORT_TOKENS,
SETTINGS_KEYS,
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
@@ -1093,7 +1094,6 @@ export class ChatService {
stream,
// Generation parameters
temperature,
thinkingBudgetTokens,
timings_per_token,
// Tools for function calling
tools,
@@ -1165,6 +1165,9 @@ export class ChatService {
? ReasoningFormat.NONE
: ReasoningFormat.AUTO;
const reasoningBudgetTokens =
enableThinking && reasoningEffort ? (REASONING_EFFORT_TOKENS[reasoningEffort] ?? -1) : -1;
// an explicit user choice injects the kwarg, otherwise it is omitted so
// the server default applies (--reasoning flag or chat template)
if (enableThinking !== undefined) {
@@ -1174,12 +1177,8 @@ export class ChatService {
};
}
if (reasoningEffort) {
requestBody.reasoning_effort = reasoningEffort;
}
if (thinkingBudgetTokens !== undefined) {
requestBody.thinking_budget_tokens = thinkingBudgetTokens;
if (reasoningBudgetTokens >= 0) {
requestBody.thinking_budget_tokens = reasoningBudgetTokens;
}
// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
+28 -24
View File
@@ -12,7 +12,6 @@
import {
COMPACTION,
CWD_CLEARED_TEXT,
REASONING_EFFORT_TOKENS,
SYSTEM_MESSAGE_PLACEHOLDER,
TITLE_GENERATION
} from '$lib/constants';
@@ -83,6 +82,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
// message flows: edit, regenerate, continue, delete
private flows = new ChatMessageFlows(this);
private isEditModeActive = $state(false);
private pendingDraftFiles = $state<ChatUploadedFile[]>([]);
private pendingDraftMessage = $state<string>('');
/** Reactive: queued pending messages for non-agentic streaming */
private pendingMessages = new SvelteMap<
string,
@@ -170,11 +171,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return message;
}
async addSystemPrompt(draft?: { message: string; files: ChatUploadedFile[] }): Promise<void> {
async addSystemPrompt(): Promise<void> {
let activeConv = conversationsStore.activeConversation;
if (!activeConv) {
await conversationsStore.createConversation(undefined, draft);
await conversationsStore.createConversation();
activeConv = conversationsStore.activeConversation;
}
@@ -340,6 +341,17 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
if (pending) await this.sendMessage(pending.content, pending.extras);
}
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
const d = { files: [...this.pendingDraftFiles], message: this.pendingDraftMessage };
this.pendingDraftMessage = '';
this.pendingDraftFiles = [];
return d;
}
consumePendingMessage(
convId: string
): { content: string; extras?: DatabaseMessageExtra[] } | null {
@@ -443,23 +455,13 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
// an explicit reasoning choice overrides the server default, DEFAULT sends nothing:
// a level the chat template honors goes as is, the others as a token budget
const modelId = modelsStore.selectedModelName ?? '';
const effort = modelsStore.props.resolveReasoningEffort(
modelId,
conversationsStore.preferences.getReasoningEffort()
);
const budgetTokens = REASONING_EFFORT_TOKENS[effort];
// an explicit reasoning choice overrides the server default, DEFAULT sends nothing
const effort = conversationsStore.preferences.getReasoningEffort();
if (effort !== ReasoningEffort.DEFAULT) {
apiOptions.enableThinking = effort !== ReasoningEffort.OFF;
}
if (modelsStore.props.getReasoningEfforts(modelId).includes(effort)) {
apiOptions.reasoningEffort = effort;
} else if (budgetTokens >= 0) {
apiOptions.thinkingBudgetTokens = budgetTokens;
if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort;
}
if (hasValue(currentConfig.temperature))
@@ -562,6 +564,10 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return this.streams.getResumeModel(convId);
}
hasPendingDraft(): boolean {
return Boolean(this.pendingDraftMessage) || this.pendingDraftFiles.length > 0;
}
hasPendingMessage(convId: string): boolean {
return this.pendingMessages.has(convId);
}
@@ -720,6 +726,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
}
}
savePendingDraft(message: string, files: ChatUploadedFile[]): void {
this.pendingDraftMessage = message;
this.pendingDraftFiles = [...files];
}
async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
if (!content.trim() && (!extras || extras.length === 0)) return;
@@ -929,15 +940,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
effectiveModel = modelOverride || modelsStore.selectedModelName || conversationModel;
}
// a router serves the props the request options read once the model is loaded, so the
// request waits for the load, and a stop during the wait aborts it
const abortController = this.getOrCreateAbortController(assistantMessage.convId);
if (serverStore.isRouterMode && effectiveModel) {
await modelsStore.status.ensureLoaded(effectiveModel);
if (abortController.signal.aborted) return;
if (!modelsStore.props.getModelProps(effectiveModel))
await modelsStore.props.fetchModelProps(effectiveModel);
}
@@ -1013,6 +1016,7 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
};
this.processing.setActiveConversation(convId);
const abortController = this.getOrCreateAbortController(convId);
const streamCallbacks: ChatStreamCallbacks = {
createAssistantMessage: async () => {
// Reset streaming state for new message
@@ -16,7 +16,6 @@ import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
import { RouterService } from '$lib/services/router.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { draftMessagesStore } from '$lib/stores/chat/drafts.svelte';
import {
ConversationPreferences,
type ConversationsPreferencesHost
@@ -261,13 +260,9 @@ class ConversationsStore implements ConversationsPreferencesHost {
/**
* Creates a new conversation and navigates to it
* @param name - Optional name for the conversation
* @param draft - Optional input draft to carry over to the new conversation
* @returns The ID of the created conversation
*/
async createConversation(
name?: string,
draft?: { message: string; files: ChatUploadedFile[] }
): Promise<string> {
async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
// The tool policy is seeded from the current defaults: edits made inside
// the conversation afterwards live on its row and do not flow back into
@@ -286,17 +281,8 @@ class ConversationsStore implements ConversationsPreferencesHost {
this.activeConversation = conversation;
this.activeMessages = [];
if (draft) {
draftMessagesStore.saveDraftMessage(conversation.id, draft.message, draft.files);
}
await goto(RouterService.chat(conversation.id));
// the draft moved to the new conversation, so drop the copy saved for the new-chat screen
if (draft) {
draftMessagesStore.clearDraftMessage(undefined);
}
return conversation.id;
}
@@ -15,7 +15,7 @@ import {
RECENT_MODEL_LIMIT,
RECENT_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ReasoningEffort, ServerModelStatus } from '$lib/enums';
import { ServerModelStatus } from '$lib/enums';
import { HuggingFaceService } from '$lib/services/huggingface.service';
import { ModelsService } from '$lib/services/models.service';
// direct imports between stores, not via the barrel, to avoid circular deps
@@ -396,13 +396,6 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.selectedModelName = option.model;
if (options?.recordRecent) this.recordRecentModel(modelId);
// reasoning levels belong to the model that answered: another model in the chat starts at Default
const conversationModel = getConversationModel(conversationsStore.activeMessages);
if (conversationModel && conversationModel !== option.model) {
void conversationsStore.preferences.setReasoningEffort(ReasoningEffort.DEFAULT);
}
} finally {
this.updating = false;
}
+17 -69
View File
@@ -10,22 +10,16 @@
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
*/
import {
MODEL_PROPS_CACHE,
REASONING_EFFORT_BASE_LEVELS,
REASONING_EFFORT_BUDGET_LEVELS,
REASONING_EFFORT_LABELS
} from '$lib/constants';
import { FileTypeCategory, ModelModality, ReasoningEffort } from '$lib/enums';
import { MODEL_PROPS_CACHE } from '$lib/constants';
import { FileTypeCategory, ModelModality } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import type { ReasoningEffortLevel } from '$lib/types';
// deep imports, not the '$lib/utils' barrel: it re-exports modules that reach back
// into the stores, and going through it here would read a half-built module
import { TTLCache } from '$lib/utils/cache-ttl';
import { detectThinkingSupport } from '$lib/utils/chat-template-thinking-detector';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteSet } from 'svelte/reactivity';
/**
* The slice of modelsStore the manager reads. Kept narrow on purpose so it
@@ -51,8 +45,7 @@ export class ModelPropsManager {
maxEntries: MODEL_PROPS_CACHE.MAX_ENTRIES,
ttlMs: MODEL_PROPS_CACHE.TTL_MS
});
/** Props requests in flight, a second caller awaits the same request. */
private fetching = new SvelteMap<string, Promise<ApiLlamaCppServerProps | null>>();
private fetching = new SvelteSet<string>();
/**
* Whether the selected model's chat template supports thinking/reasoning.
@@ -167,27 +160,24 @@ export class ModelPropsManager {
return null;
}
const inFlight = this.fetching.get(modelId);
if (this.fetching.has(modelId)) return null;
if (inFlight) return inFlight;
this.fetching.add(modelId);
const request = PropsService.fetchForModel(modelId)
.then((props) => {
this.cache.set(modelId, props);
this.cacheVersion++;
try {
const props = await PropsService.fetchForModel(modelId);
return props;
})
.catch((error) => {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
this.cache.set(modelId, props);
this.cacheVersion++;
return null;
})
.finally(() => this.fetching.delete(modelId));
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
this.fetching.set(modelId, request);
return request;
return null;
} finally {
this.fetching.delete(modelId);
}
}
getModelContextSize(modelId: string): number | null {
@@ -237,41 +227,6 @@ export class ModelPropsManager {
return this.cache.get(modelId);
}
/**
* Reasoning effort levels the model's chat template honors, from lowest to highest.
* Empty when the template honors none, the menu then maps its levels to token budgets.
* In ROUTER mode, fetches model props if not cached.
*/
getReasoningEfforts(modelId: string): ReasoningEffort[] {
let props: ApiLlamaCppServerProps | null = serverStore.props;
if (serverStore.isRouterMode) {
if (!modelId) return [];
if (!this.cache.get(modelId)) {
this.fetchModelProps(modelId);
}
props = this.getModelProps(modelId);
}
const levels: string[] = props?.chat_template_caps?.reasoning_effort_levels ?? [];
return levels.filter((level): level is ReasoningEffort => level in REASONING_EFFORT_LABELS);
}
/** Reasoning menu of the model: the template levels, or the token budget levels. */
getReasoningLevels(modelId: string): ReasoningEffortLevel[] {
const efforts = this.getReasoningEfforts(modelId);
if (efforts.length === 0) return REASONING_EFFORT_BUDGET_LEVELS;
return [
...REASONING_EFFORT_BASE_LEVELS,
...efforts.map((value) => ({ label: REASONING_EFFORT_LABELS[value], value }))
];
}
isModelPropsFetching(modelId: string): boolean {
return this.fetching.has(modelId);
}
@@ -288,13 +243,6 @@ export class ModelPropsManager {
return this.getModelModalities(modelId)?.vision ?? false;
}
/** The effort as the model's menu offers it, DEFAULT when the menu lacks it. */
resolveReasoningEffort(modelId: string, effort: ReasoningEffort): ReasoningEffort {
return this.getReasoningLevels(modelId).some((level) => level.value === effort)
? effort
: ReasoningEffort.DEFAULT;
}
/**
* Update modalities for a specific model.
* Called when a model is loaded or when we need fresh modality data.
+14 -28
View File
@@ -201,7 +201,7 @@ export class ModelStatusManager {
private statusWaiters = new SvelteMap<
string,
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }[]
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
>();
/** Models the user asked to stop (pause or cancel); the download_failed the stop triggers is intentional, not a failure. */
private stopRequests = new SvelteMap<string, ModelDownloadStopRequest>();
@@ -369,21 +369,10 @@ export class ModelStatusManager {
}
}
/** Resolves once the model is loaded: waits on a load in progress, or starts one. */
async ensureLoaded(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
const status = this.host.routerModels.find((m) => m.id === modelId)?.status?.value;
if (!this.loadingStates.get(modelId) && status !== ServerModelStatus.LOADING) {
await this.load(modelId);
return;
}
this.subscribe();
await this.waitForStatus(modelId, ServerModelStatus.LOADED);
await this.load(modelId);
}
/**
@@ -799,11 +788,12 @@ export class ModelStatusManager {
}
private rejectStatus(modelId: string, error: Error): void {
const waiters = this.statusWaiters.get(modelId) ?? [];
const waiter = this.statusWaiters.get(modelId);
this.statusWaiters.delete(modelId);
for (const waiter of waiters) waiter.reject(error);
if (waiter) {
this.statusWaiters.delete(modelId);
waiter.reject(error);
}
}
private removeRouterModel(modelId: string): void {
@@ -850,22 +840,18 @@ export class ModelStatusManager {
}
private settleStatus(modelId: string, status: ServerModelStatus): void {
const waiters = this.statusWaiters.get(modelId) ?? [];
const pending = waiters.filter((waiter) => waiter.target !== status);
const waiter = this.statusWaiters.get(modelId);
if (pending.length) this.statusWaiters.set(modelId, pending);
else this.statusWaiters.delete(modelId);
for (const waiter of waiters) if (waiter.target === status) waiter.resolve();
if (waiter && waiter.target === status) {
this.statusWaiters.delete(modelId);
waiter.resolve();
}
}
// a load and the requests that wait on it can each hold a waiter for the same model
// one operation runs per model at a time, so one waiter per model suffices
private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
return new Promise((resolve, reject) => {
this.statusWaiters.set(modelId, [
...(this.statusWaiters.get(modelId) ?? []),
{ reject, resolve, target }
]);
this.statusWaiters.set(modelId, { reject, resolve, target });
});
}
}
@@ -33,40 +33,9 @@ class SettingsStore {
// application of server ui_settings defaults for new users.
private isFirstVisit = false;
/**
* Whether the saved config differs from what "Reset to Default" would apply
*/
get hasCustomSettings(): boolean {
const propsDefaults = this.getServerDefaults();
for (const [key, value] of Object.entries(this.getResetValues())) {
const current = getConfigValue(this.config, key);
// sampling params reset to '' (server decides); an explicit value equal to the server default is not custom
if (value === '' && propsDefaults[key] !== undefined) {
if (
current !== '' &&
current !== undefined &&
normalizeFloatingPoint(current) !== normalizeFloatingPoint(propsDefaults[key])
) {
return true;
}
continue;
}
if (current !== value) {
return true;
}
}
return false;
}
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
* Clear all user overrides (for debugging)
*/
@@ -122,7 +91,35 @@ class SettingsStore {
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
for (const [key, value] of Object.entries(this.getResetValues())) {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
const value =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
@@ -132,10 +129,6 @@ class SettingsStore {
this.userOverrides.delete(key);
}
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
this.userOverrides.delete(key);
}
this.saveConfig();
}
@@ -434,43 +427,6 @@ class SettingsStore {
}
}
/**
* Values applied by "Reset to Default": admin ui_settings first, then server
* defaults from /props for sampling params, then the factory defaults
*/
private getResetValues(): Record<string, unknown> {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
const values: Record<string, unknown> = {};
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
values[key] = uiSettings[key];
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
values[key] = '';
} else if (key in SETTING_CONFIG_DEFAULT) {
values[key] = getConfigValue(SETTING_CONFIG_DEFAULT, key);
}
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
values[key] =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
}
return values;
}
/**
*
*
-5
View File
@@ -284,10 +284,6 @@ export interface ApiLlamaCppServerProps {
video: boolean;
};
chat_template: string;
chat_template_caps?: {
/** reasoning_effort values the chat template renders distinctly, from lowest to highest */
reasoning_effort_levels?: string[];
};
bos_token: string;
eos_token: string;
build_info: string;
@@ -312,7 +308,6 @@ export interface ApiChatCompletionRequest {
tools?: ApiChatCompletionTool[];
// Reasoning parameters
reasoning_format?: string;
reasoning_effort?: string;
// Generation parameters
temperature?: number;
max_tokens?: number;
+1 -3
View File
@@ -89,10 +89,8 @@ export interface SettingsChatServiceOptions {
excludeReasoningFromContext?: boolean;
// Enable model thinking/reasoning via chat_template_kwargs
enableThinking?: boolean;
// Reasoning effort level the chat template honors, sent as reasoning_effort
// Reasoning effort level (low/medium/high/max) for thinking models
reasoningEffort?: ReasoningEffort;
// Token budget for thinking, sent as thinking_budget_tokens
thinkingBudgetTokens?: number;
tools?: OpenAIToolDefinition[];
// Generation parameters
temperature?: number;
+10 -22
View File
@@ -1,32 +1,20 @@
import { FileTypeAudio, MimeTypeAudio } from '$lib/enums';
import type { AudioInputFormat } from '$lib/types/api';
const WAV_MIME_TYPES: string[] = [
MimeTypeAudio.WAV,
MimeTypeAudio.WAVE,
MimeTypeAudio.X_WAV,
MimeTypeAudio.X_WAVE,
MimeTypeAudio.VND_WAVE,
MimeTypeAudio.X_PN_WAV
];
const MP3_MIME_TYPES: string[] = [MimeTypeAudio.MP3, MimeTypeAudio.MP3_MPEG];
function isWavMimeType(mimeType: string): boolean {
return WAV_MIME_TYPES.includes(mimeType.trim().toLowerCase());
}
/**
* Check if the server can decode this audio MIME type as is (WAV or MP3).
*/
export function isServerDecodableAudio(mimeType: string): boolean {
return isWavMimeType(mimeType) || MP3_MIME_TYPES.includes(mimeType.trim().toLowerCase());
}
/**
* Map a MIME type to the AudioInputFormat expected by the API.
*/
export function getAudioInputFormat(mimeType: string): AudioInputFormat {
if (isWavMimeType(mimeType)) {
const normalizedMimeType = mimeType.trim().toLowerCase();
if (
normalizedMimeType === MimeTypeAudio.WAV ||
normalizedMimeType === MimeTypeAudio.WAVE ||
normalizedMimeType === MimeTypeAudio.X_WAV ||
normalizedMimeType === MimeTypeAudio.X_WAVE ||
normalizedMimeType === MimeTypeAudio.VND_WAVE ||
normalizedMimeType === MimeTypeAudio.X_PN_WAV
) {
return FileTypeAudio.WAV;
}
-1
View File
@@ -38,7 +38,6 @@ export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
case MimeTypeAudio.MP3_MPEG:
case MimeTypeAudio.MP3:
case MimeTypeAudio.MP4:
case MimeTypeAudio.OGG:
case MimeTypeAudio.WAV:
case MimeTypeAudio.WAVE:
case MimeTypeAudio.X_WAV:
-30
View File
@@ -1,8 +1,6 @@
import {
MEDIUM_DURATION_THRESHOLD,
MS_PER_SECOND,
RELATIVE_TIME_MAX_DAYS,
SECONDS_PER_DAY,
SECONDS_PER_HOUR,
SECONDS_PER_MINUTE,
SHORT_DURATION_THRESHOLD
@@ -104,34 +102,6 @@ export function formatJsonPretty(jsonString: string): string {
}
}
/**
* Formats a past timestamp as a coarse relative label.
* Examples: "just now", "37 minutes ago", "1 hour ago", "3 days ago"
*
* @param timestamp - Epoch milliseconds in the past
* @returns Relative time string
*/
export function formatRelativeTime(timestamp: number): string {
const elapsedSeconds = (Date.now() - timestamp) / MS_PER_SECOND;
const plural = (value: number, unit: string) => `${value} ${unit}${value === 1 ? '' : 's'} ago`;
if (elapsedSeconds < SECONDS_PER_MINUTE) return 'just now';
if (elapsedSeconds < SECONDS_PER_HOUR) {
return plural(Math.floor(elapsedSeconds / SECONDS_PER_MINUTE), 'minute');
}
if (elapsedSeconds < SECONDS_PER_DAY) {
return plural(Math.floor(elapsedSeconds / SECONDS_PER_HOUR), 'hour');
}
if (elapsedSeconds < RELATIVE_TIME_MAX_DAYS * SECONDS_PER_DAY) {
return plural(Math.floor(elapsedSeconds / SECONDS_PER_DAY), 'day');
}
return new Date(timestamp).toLocaleDateString();
}
/**
* Format time as HH:MM:SS in 24-hour format
*
-1
View File
@@ -99,7 +99,6 @@ export {
formatJsonPretty,
formatTime,
formatPerformanceTime,
formatRelativeTime,
formatAttachmentText
} from './formatters';
@@ -1,11 +1,9 @@
import { isServerDecodableAudio } from './audio-format';
import { convertToWav, createAudioFile } from './audio-recording';
import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
import { convertPDFToText } from './pdf-processing';
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { SETTINGS_KEYS } from '$lib/constants';
import { FileExtensionAudio, FileTypeCategory, MimeTypeAudio } from '$lib/enums';
import { FileTypeCategory } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { getFileTypeCategory } from '$lib/utils';
@@ -129,35 +127,10 @@ export async function processFilesToChatUploaded(
});
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
let audioFile = file;
// Server only decodes WAV and MP3, so convert other audio (OGG, WEBM, MP4) to WAV in the browser
if (!isServerDecodableAudio(file.type)) {
const wavBlob = await convertToWav(file);
if (wavBlob.type !== MimeTypeAudio.WAV) {
toast.error(`Failed to decode audio file ${file.name}`);
continue;
}
audioFile = createAudioFile(
wavBlob,
file.name.replace(/\.[^.]*$/, '') + FileExtensionAudio.WAV
);
}
// Generate preview URL for audio files
const preview = await readFileAsDataURL(audioFile);
const preview = await readFileAsDataURL(file);
results.push({
...base,
file: audioFile,
name: audioFile.name,
preview,
size: audioFile.size,
type: audioFile.type
});
results.push({ ...base, preview });
} else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
// Generate preview URL for video files
const preview = await readFileAsDataURL(file);
+16 -2
View File
@@ -13,6 +13,7 @@
HEADERS,
MODELS_ROUTE_ID,
NEW_CHAT_TAB_ID,
ROUTES,
SETTINGS_KEYS,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
@@ -46,7 +47,12 @@
let innerHeight = $state<number | undefined>();
let innerWidth = $state(browser ? window.innerWidth : 0);
let chatSidebar: { editActiveConversation?: () => void } | undefined = $state();
let chatSidebar:
| {
activateSearchMode?: () => void;
editActiveConversation?: () => void;
}
| undefined = $state();
let showBuildVersion = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_BUILD_VERSION] as boolean
@@ -302,7 +308,15 @@
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
<div class="flex flex-col md:flex-row">
<SidebarNavigation />
<SidebarNavigation
onSearchClick={() => {
if (deviceStore.isMobile) {
goto(ROUTES.SEARCH);
} else if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
}
}}
/>
<!-- min-w-0 lets the chat column shrink below its content width, so wide
code blocks and tables scroll inside their own containers instead of
@@ -1,7 +1,6 @@
<script lang="ts" module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import SidebarNavigation from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte';
import { expect } from 'storybook/test';
import { waitFor } from 'storybook/test';
import { screen } from 'storybook/test';
@@ -86,9 +85,7 @@
await userEvent.click(logoTrigger);
const searchTrigger = screen.getByText('Search');
await userEvent.click(searchTrigger);
await expect(await screen.findByRole('combobox')).toHaveFocus();
userEvent.click(searchTrigger);
}}
>
<div class="flex-column h-screen w-72 bg-background">
+2 -42
View File
@@ -1,12 +1,5 @@
import {
MS_PER_SECOND,
RELATIVE_TIME_MAX_DAYS,
SECONDS_PER_DAY,
SECONDS_PER_HOUR,
SECONDS_PER_MINUTE
} from '$lib/constants';
import { formatContextLength, formatParameters, formatRelativeTime } from '$lib/utils/formatters';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { formatContextLength, formatParameters } from '$lib/utils/formatters';
import { describe, expect, it } from 'vitest';
describe('formatContextLength', () => {
it('shows a dash when no value is known', () => {
@@ -29,36 +22,3 @@ describe('formatParameters', () => {
expect(formatParameters(512)).toBe('512');
});
});
describe('formatRelativeTime', () => {
const NOW = new Date('2026-06-15T12:00:00Z').getTime();
const ago = (seconds: number) => NOW - seconds * MS_PER_SECOND;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('says just now under a minute', () => {
expect(formatRelativeTime(ago(SECONDS_PER_MINUTE - 1))).toBe('just now');
});
it('counts minutes, hours and days with singular and plural units', () => {
expect(formatRelativeTime(ago(SECONDS_PER_MINUTE))).toBe('1 minute ago');
expect(formatRelativeTime(ago(37 * SECONDS_PER_MINUTE))).toBe('37 minutes ago');
expect(formatRelativeTime(ago(SECONDS_PER_HOUR))).toBe('1 hour ago');
expect(formatRelativeTime(ago(5 * SECONDS_PER_HOUR))).toBe('5 hours ago');
expect(formatRelativeTime(ago(SECONDS_PER_DAY))).toBe('1 day ago');
expect(formatRelativeTime(ago(3 * SECONDS_PER_DAY))).toBe('3 days ago');
});
it('falls back to a date once the age reaches the day limit', () => {
const timestamp = ago(RELATIVE_TIME_MAX_DAYS * SECONDS_PER_DAY);
expect(formatRelativeTime(timestamp)).toBe(new Date(timestamp).toLocaleDateString());
});
});