mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-29 07:57:38 -05:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b431451636 | ||
|
|
d88ec8cc70 | ||
|
|
d80fe6bea3 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); \
|
||||
} \
|
||||
} \
|
||||
} \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
facdc44a67da17df4433adcc468b424f48e9ded2
|
||||
f1aa6a3465ffad40dadd390ac16b9deedd4e97f7
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+54
-64
@@ -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} />
|
||||
|
||||
+77
-20
@@ -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,
|
||||
|
||||
+4
-11
@@ -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'
|
||||
}
|
||||
]}
|
||||
|
||||
+106
-76
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
||||
Vendored
-5
@@ -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;
|
||||
|
||||
Vendored
+1
-3
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user