Compare commits

...
Author SHA1 Message Date
Xuan-Son NguyenandSigbjørn Skjæret 7617156b71 Update tools/server/tests/requirements.txt
Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>
2026-10-28 13:20:34 +01:00
Xuan Son Nguyen a5930af33e requirements.txt 2026-10-28 13:15:06 +01:00
Xuan-Son NguyenandSigbjørn Skjæret 141dffcf05 Update requirements/requirements-convert_legacy_llama.txt
Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>
2026-10-28 13:08:24 +01:00
Xuan Son Nguyen 290baa839d gguf-py: fix np_roundf dropping the sign 2026-10-28 12:09:18 +01:00
Georgi Gerganov 5d7793d6fc sync : ggml 2026-10-28 12:36:51 +02:00
Georgi Gerganov ec941ee852 ggml : bump version to v.28.0 (ggml/1676)
* ggml : bump version to 0.28.0

* authors : update
2026-10-28 12:36:51 +02:00
Pascal e486a4b559 ui: build the reasoning menu from the template effort levels (#31273)
* ui: build the reasoning menu from the template effort levels

When the chat template reports effort levels, the menu offers Default,
Off and those levels, and a pick is sent as reasoning_effort. A
template that reports none keeps the token budget menu.

Picking a model other than the one that produced the last answer resets
the conversation effort to Default, and an effort the current menu
lacks resolves to Default.

* ui: wait for a cold router model before building the request

A router serves the props of a loaded model only, so a request sent to
a cold model was built without its template effort levels and fell
back to a token budget. The request now waits for the model to load,
joining a load already in flight, and a stop during the wait sends
nothing.

Status waiters hold a list per model so a load and the requests that
wait on it each get theirs, and a props request in flight is shared
with a second caller instead of answering null.
2026-10-28 11:35:57 +02:00
Georgi Gerganov f1a7dea99a tests : adjust constants to reduce irrelevant warnings (#31254) 2026-10-28 11:26:38 +02:00
Yonghye Kwon a7af149a2a cuda : add top-k MoE fusion for 384 experts (#31288)
Assisted-by: Codex
2026-10-28 10:13:01 +02:00
Kris 2864937a77 vulkan: clamp A prefetch to end_k in coopmat1 int8 matmul (#30283)
* fix: clamp the A prefetch to end_k in the Vulkan coopmat1 int8 matmul, like the B prefetch

* fix: zero the A scale for k steps past end_k in the Vulkan coopmat1 int8 matmul instead of relying on the clamped block
2026-10-28 10:12:22 +02:00
a71c00f0bb chat : stop qwen3-coder reasoning and content before template separators (#30557)
* chat : strip trailing whitespace from parsed reasoning and content

Assisted-by: Claude Fable 5.1

* chat : stop qwen3-coder reasoning and content before template separators

Assisted-by: Claude

* tests : remove the Qwen parser case from the chat session tests

Assisted-by: Claude

* common : handle partial prefix until match at end of generation

---------

Co-authored-by: Zeshawn Zahid <zzshawnn@gmail.com>
Co-authored-by: Alde Rojas <hello@alde.dev>
2026-10-28 10:11:47 +02:00
Aleksander Grygier f4d8af608a ui : show save and reset settings buttons only when relevant (#31248)
* ui : show save and reset settings buttons only when relevant

Assisted-by: Claude (Opus 5.5 Medium)

* ui : move reset settings button next to reload app, pad settings caption

Assisted-by: Claude (Opus 5.5 Medium)
2026-10-28 08:54:34 +02:00
Aleksander Grygier 02d502f314 ui: Support OGG audio attachments by converting them to WAV (#31237)
* ui: support OGG audio attachments by converting them to WAV

Fixes #24164

Assisted-by: Claude (Opus 5.5 Medium)

* ui: convert all non WAV/MP3 audio uploads to WAV

Assisted-by: Claude (Opus 5.5 Medium)
2026-10-28 08:54:15 +02:00
Aleksander Grygier b1756b23f3 ui: Fix draft for new message disappearing after using "Add System Message" action (#31214)
* ui: keep new chat draft when adding a system prompt

Fixes #24338

Assisted-by: Claude (Opus 5.5 Medium)

* ui: remove unused initialMessage from chat form

Assisted-by: Claude (Opus 5.5 Medium)
2026-10-28 08:53:56 +02:00
Aleksander Grygier 6886628cb1 ui : Search dialog (#31173)
* ui : replace the sidebar search mode with a search dialog

Search opens a spotlight dialog (Cmd+K or the Search entry under New chat in the sidebar) with keyboard navigation over recent chats and sidebar actions. Phones keep the search page.

Sidebar actions show their shortcuts, with new ones for Settings (Cmd+,) and Models (Shift+Cmd+M).

Assisted-by: Claude (Opus 5.5 Medium)

* ui : ignore search dialog keys while an IME is composing

Enter confirms an IME candidate and the arrow keys move through the candidates, so the search field leaves them to the IME instead of opening the highlighted result or moving the highlight.

Assisted-by: Claude (Opus 5.5 Medium)
2026-10-28 08:53:41 +02:00
61 changed files with 1080 additions and 542 deletions
+3
View File
@@ -1803,6 +1803,9 @@ 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;
}
+11 -3
View File
@@ -76,9 +76,10 @@ 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({ "</think>", "<tool_call>" })) +
(p.literal("</think>") | p.peek(p.literal("<tool_call>"))));
p.reasoning(p.until_one_of({ "\n</think>", "</think>", "<tool_call>" })) +
(p.literal("\n</think>") | p.literal("</think>") | p.peek(p.literal("<tool_call>"))));
}
// Response format parser
@@ -166,8 +167,15 @@ 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(tool_call_starts)) << tool_calls);
(reasoning << p.content(p.until_one_of(content_ends)) << tool_calls);
}
// Content only parser
+3 -2
View File
@@ -721,8 +721,9 @@ 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) {
// Found a partial match extending to end of input, return everything before it
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
return common_peg_parse_result(COMMON_PEG_PARSE_RESULT_SUCCESS, start_pos, pos, {}, std::move(invalid_utf8));
}
+5 -3
View File
@@ -162,9 +162,10 @@ 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_NONE = 0,
COMMON_PEG_PARSE_FLAG_LENIENT = 1 << 0,
COMMON_PEG_PARSE_FLAG_DEBUG = 1 << 1,
COMMON_PEG_PARSE_FLAG_COMPLETE = 1 << 2,
};
inline common_peg_parse_flags operator|(common_peg_parse_flags a, common_peg_parse_flags b) {
@@ -209,6 +210,7 @@ struct common_peg_parse_context {
bool is_lenient() const { return flags & COMMON_PEG_PARSE_FLAG_LENIENT; }
bool is_debug() const { return flags & COMMON_PEG_PARSE_FLAG_DEBUG; }
bool is_complete() const { return flags & COMMON_PEG_PARSE_FLAG_COMPLETE; }
};
class common_peg_arena;
+1 -1
View File
@@ -4,7 +4,7 @@ project("ggml" C CXX ASM)
### GGML Version
set(GGML_VERSION_MAJOR 0)
set(GGML_VERSION_MINOR 27)
set(GGML_VERSION_MINOR 28)
set(GGML_VERSION_PATCH 0)
set(GGML_VERSION_BASE "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
+5 -1
View File
@@ -402,6 +402,10 @@ 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);
@@ -472,7 +476,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 != 576) {
if (((n_expert & (n_expert - 1)) != 0 || n_expert > 512) && n_expert != 288 && n_expert != 384 && n_expert != 576) {
return false;
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = float(blk.d);
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
// 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) {
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_dm[ks * BM + buf_ib] = vec2(float(blk.dm.x), float(blk.dm.y));
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(float(blk.dm.x), float(blk.dm.y)) : vec2(0.0f);
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = float(blk.d);
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
// 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) {
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr + 4] = hi4;
if (loadr == 0) {
buf_a_dm[ks * BM + buf_ib] = vec2(float(blk.dm.x), float(blk.dm.y));
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(float(blk.dm.x), float(blk.dm.y)) : vec2(0.0f);
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
buf_a_qs[buf_ib * QPITCH + ks * (BK / 4) + loadr] = blk.qs;
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = float(blk.d);
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = float(blk.d);
buf_a_d[ks * BM + buf_ib] = in_bounds ? float(blk.d) : 0.0f;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = blk.d;
buf_a_d[ks * BM + buf_ib] = in_bounds ? blk.d : 0.0f;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
buf_a_d[ks * BM + buf_ib] = e8m0_to_fp32(blk.e) * 0.5;
buf_a_d[ks * BM + buf_ib] = in_bounds ? e8m0_to_fp32(blk.e) * 0.5 : 0.0f;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
// 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) {
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] = vec2(d_scaled, -(dm.y * float(mn_val)));
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(d_scaled, -(dm.y * float(mn_val))) : vec2(0.0f);
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
// 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) {
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] = vec2(d_scaled, -(dm.y * float(mn_val)));
buf_a_dm[ks * BM + buf_ib] = in_bounds ? vec2(d_scaled, -(dm.y * float(mn_val))) : vec2(0.0f);
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
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] = 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);
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;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
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] = d * float(int(sc.x) - 32);
buf_a_d[(ks * KSCALES + 1) * BM + buf_ib] = d * float(int(sc.y) - 32);
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;
}
}
@@ -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) {
void block_a_to_shmem(block_a_prefetch blk, uint buf_ib, uint ks, uint loadr, bool in_bounds) {
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) {
cm1_kvalues[hi_idx.z], cm1_kvalues[hi_idx.w]));
if (loadr == 0) {
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;
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;
}
}
@@ -559,7 +559,9 @@ 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++) { \
pre_a[li * BK_STEP + ks] = block_a_load(ib + ks, loadr_a); \
/* 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); \
} \
} \
} \
@@ -579,7 +581,8 @@ 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++) { \
block_a_to_shmem(pre_a[li * BK_STEP + ks], buf_ib, ks, loadr_a); \
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); \
} \
} \
} \
+2 -1
View File
@@ -42,7 +42,8 @@ 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:
a = abs(n)
# 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)
floored = np.floor(a)
b = floored + np.floor(2 * (a - floored))
return np.sign(n) * b
+1 -1
View File
@@ -7,7 +7,7 @@ dynamic = ["classifiers"]
readme = "README.md"
authors = [{name = "GGML", email = "ggml@ggml.ai"}]
requires-python = '>=3.10'
dependencies = ['numpy (>=2.2.6)', 'tqdm (>=4.27)', 'pyyaml (>=5.1)', 'requests (>=2.25)']
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)']
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
+2 -1
View File
@@ -8,7 +8,8 @@ readme = "README.md"
authors = [{name = "GGML", email = "ggml@ggml.ai"}]
requires-python = '>=3.10,<3.15'
dependencies = [
'numpy (>=2.2.6,<3.0.0)',
'numpy (>=2.2.6,<3.0.0); python_version < "3.14"',
'numpy (>=2.3.0,<3.0.0); python_version >= "3.14"',
'sentencepiece (>=0.1.98,<0.3.0)',
'transformers (==4.57.6)',
'protobuf (>=4.21.0,<5.0.0)',
@@ -1,4 +1,5 @@
numpy~=2.2.6
numpy~=2.2.6; python_version < "3.14"
numpy~=2.4.6; python_version >= "3.14"
sentencepiece>=0.1.98,<0.3.0
transformers==4.57.6
+1 -1
View File
@@ -1 +1 @@
f1aa6a3465ffad40dadd390ac16b9deedd4e97f7
facdc44a67da17df4433adcc468b424f48e9ded2
+1 -1
View File
@@ -303,7 +303,7 @@ llama_kv_cache::llama_kv_cache(
const bool attn_rot_disable = !model.attn_rot || (env_rot_disable != nullptr && atoi(env_rot_disable) != 0);
if (attn_rot_disable) {
LLAMA_LOG_WARN("%s: attention rotation force disabled (%s)\n", __func__,
LLAMA_LOG_INFO("%s: attention rotation force disabled (%s)\n", __func__,
model.attn_rot ? "LLAMA_ATTN_ROT_DISABLE" : "llama_model_set_attn_rot");
}
+2 -2
View File
@@ -37,7 +37,7 @@ struct case_multi_seq_split_replay : ctx_test {
// both contexts have the same layout, only what they decode differs
const auto make_ctx_multi = [&]() {
ctx_opts opts;
opts.n_ctx = 256;
opts.n_ctx = 512;
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 = 512;
opts.n_ctx = 256;
opts.n_batch = 256;
opts.n_ubatch = 64;
opts.n_seq_max = n_seqs;
+3
View File
@@ -12439,6 +12439,9 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_topk_moe({160, 4, 1, 1}, 160, with_norm, bias_probs, gate, scale_w));
test_cases.emplace_back(new test_topk_moe({256, 22, 1, 1}, 6, with_norm, bias_probs, gate, scale_w)); // Used by DeepSeek-V4
test_cases.emplace_back(new test_topk_moe({288, 22, 1, 1}, 8, with_norm, bias_probs, gate, scale_w)); // Used by StepFun 3.7
test_cases.emplace_back(new test_topk_moe({384, 1, 1, 1}, 6, with_norm, bias_probs, gate, scale_w)); // Used by DeepSeek-V4.1
test_cases.emplace_back(new test_topk_moe({384, 8, 1, 1}, 6, with_norm, bias_probs, gate, scale_w));
test_cases.emplace_back(new test_topk_moe({384, 22, 1, 1}, 6, with_norm, bias_probs, gate, scale_w));
// rows at and just past the limit where one block still covers all rows
test_cases.emplace_back(new test_topk_moe({32, 8, 1, 1}, 4, with_norm, bias_probs, gate, scale_w));
test_cases.emplace_back(new test_topk_moe({32, 8, 1, 1}, 8, with_norm, bias_probs, gate, scale_w));
+6 -6
View File
@@ -2473,7 +2473,7 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
.tools({
run_in_terminal_tool
})
.expect_content("Let me inspect the current directory.\n")
.expect_content("Let me inspect the current directory.")
.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.\n")
.expect_content("Let me inspect the current directory.")
.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.\n")
.expect_content("Let me inspect the current directory.")
.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\n"), session.msg());
assert_msg_equals(simple_assist_msg("", "I'm\nthinking"), 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\n", "special_function", "{\"arg1\":1}"), session.finish());
assert_msg_equals(simple_assist_msg("", "I'm\nthinking", "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\n"), session.msg());
assert_msg_equals(simple_assist_msg("Hello, ", "I'm thinking"), session.msg());
session.feed(common_chat_input("world!"));
assert_equals(std::string("Hello, world!"), session.msg().content);
+2 -1
View File
@@ -2,7 +2,8 @@ aiohttp~=3.9.3
pytest~=8.3.3
pytest-xdist~=3.6
filelock~=3.16
numpy~=2.2.6
numpy~=2.2.6; python_version < "3.14"
numpy~=2.4.6; python_version >= "3.14"
openai~=2.14.0
prometheus-client~=0.20.0
requests~=2.32.3
@@ -4,7 +4,7 @@
import * as Drawer from '$lib/components/ui/drawer';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ICON_CLASS_DEFAULT, REASONING_EFFORT_LABELS } 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 class="capitalize">{reasoning.currentEffort}</span>
<span>{REASONING_EFFORT_LABELS[reasoning.currentEffort]}</span>
{/if}
</span>
{/snippet}
@@ -34,12 +34,11 @@
settingsStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { onMount, tick, untrack } from 'svelte';
import { 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(
@@ -197,11 +196,7 @@
}
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
if (draft.message || draft.files.length > 0) {
chatStore.savePendingDraft(draft.message, draft.files);
}
await chatStore.addSystemPrompt();
await chatStore.addSystemPrompt(draft);
}
$effect(() => {
@@ -213,15 +208,6 @@
return () => stick.detach();
});
onMount(() => {
const pendingDraft = chatStore.consumePendingDraft();
if (pendingDraft) {
initialMessage = pendingDraft.message;
fileUpload.uploadedFiles = pendingDraft.files;
}
});
</script>
{#if dragAndDrop.isDragOver}
@@ -287,7 +273,6 @@
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,7 +9,6 @@
interface Props {
class?: string;
disabled?: boolean;
initialMessage?: string;
isLoading?: boolean;
onFileRemove?: (fileId: string) => void;
onFileUpload?: (files: File[]) => void;
@@ -22,7 +21,6 @@
let {
class: className,
disabled = false,
initialMessage = '',
isLoading = false,
onFileRemove,
onFileUpload,
@@ -62,14 +60,12 @@
};
});
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let message = $derived(initialMessage);
let message = $state('');
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)
@@ -132,13 +128,6 @@
}
});
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(focusFormUnlessCaptured, 10);
@@ -0,0 +1,261 @@
<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,6 +108,14 @@ 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,5 +1,6 @@
<script lang="ts">
import { ArrowBigUp } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
interface Props {
keys: string[];
@@ -19,7 +20,9 @@
<kbd class="{baseClasses} {variantClasses} {className}">
{#each keys as key, index (index)}
{#if key === 'shift'}
<ArrowBigUp class="h-1 w-1 {variant === 'destructive' ? 'text-destructive' : ''} -mr-1" />
<ArrowBigUp
class="{ICON_CLASS_DEFAULT} {variant === 'destructive' ? 'text-destructive' : ''} -mr-1"
/>
{:else if key === 'cmd'}
<span class={variant === 'destructive' ? 'text-destructive' : ''}>⌘</span>
{:else}
@@ -8,6 +8,7 @@
DialogConversationRename,
DialogDiscoverModels,
DialogMcpServers,
DialogSearch,
DialogSettingsChat,
Logo,
ModelsManagerModelDrawer,
@@ -23,7 +24,7 @@
SIDEBAR_OVERSTRETCH_PX,
SIDEBAR_SETTLE_MS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { SidebarAction, 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';
@@ -33,14 +34,10 @@
import { SvelteSet } from 'svelte/reactivity';
import { fade } from 'svelte/transition';
interface Props {
onSearchClick?: () => void;
}
let { onSearchClick = () => {} }: Props = $props();
const { handleKeydown } = useKeyboardShortcuts({
activateSearchMode: () => onSearchClick(),
openModelsManager: () => handleSidebarAction(SidebarAction.MANAGE_MODELS),
openSearch: () => handleSidebarAction(SidebarAction.SEARCH),
openSettings: () => handleSidebarAction(SidebarAction.SETTINGS),
toggleSidebar: () => toggleExpandedMode()
});
@@ -67,9 +64,6 @@
$effect(() => {
if (!uiStore.isSidebarExpanded) {
isSearchModeActive = false;
searchQuery = '';
if (isSelectionMode) exitSelectionMode();
cancelMobileCollapse();
@@ -83,22 +77,18 @@
});
let currentChatId = $derived(page.params.id);
let isSearchModeActive = $state(false);
let searchQuery = $state('');
let searchOpen = $state(false);
let filteredConversations = $derived.by(() => {
if (isSearchModeActive) {
if (searchQuery.trim().length > 0) {
return conversationsStore.conversations.filter((conversation: { name: string }) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// a phone shows the search page instead of the spotlight overlay
function openSearch() {
if (deviceStore.isMobile) {
void goto(ROUTES.SEARCH);
return [];
return;
}
return conversationsStore.conversations;
});
searchOpen = true;
}
let isSelectionMode = $state(false);
let selectedIds = new SvelteSet<string>();
@@ -111,7 +101,7 @@
let renameOriginalTitle = $state('');
const renderedOrderIds = $derived(
buildConversationTree(filteredConversations).map((t) => t.conversation.id)
buildConversationTree(conversationsStore.conversations).map((t) => t.conversation.id)
);
const allSelectedArePinned = $derived.by(() => {
@@ -151,7 +141,7 @@
});
const visibleSelectionStats = $derived.by(() => {
const visibleIds = filteredConversations.map((c) => c.id);
const visibleIds = conversationsStore.conversations.map((c) => c.id);
let selectedVisible = 0;
@@ -187,7 +177,7 @@
}
function toggleSelectAllVisible() {
const visibleIds = filteredConversations.map((c) => c.id);
const visibleIds = conversationsStore.conversations.map((c) => c.id);
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selectedIds.has(id));
if (allSelected) {
@@ -454,6 +444,36 @@
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} />
@@ -505,7 +525,7 @@
/>
</div>
{#if isOnMobile || (uiStore.isSidebarExpanded && !alwaysShowOnDesktop)}
{#if isOnMobile || uiStore.isSidebarExpanded}
<div
in:fade={{ delay: 50, duration: 150, easing: circIn }}
out:fade={{ duration: 100 }}
@@ -514,16 +534,18 @@
? 'opacity-0 h-0!'
: ''}"
>
<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 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}
</div>
{/if}
</div>
@@ -536,26 +558,14 @@
: ''} {deviceStore.isMobile && !uiStore.isSidebarExpanded ? 'opacity-0 !h-0' : ''}"
>
<SidebarNavigationActions
bind:isSearchModeActive
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
onManageModelsClick={() => openDialog(() => uiStore.openModelsManager())}
onMcpClick={() => openDialog(() => (mcpDialogOpen = true))}
onNewChat={() => {
onAction={handleSidebarAction}
onNavigate={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
}}
onSearchClick={() => {
uiStore.isSidebarExpanded = true;
isSearchModeActive = true;
}}
onSearchDeactivated={() => {
isSearchModeActive = false;
searchQuery = '';
}}
onSettingsClick={() => openDialog(() => (settingsDialogOpen = true))}
/>
{#if uiStore.isSidebarExpanded || isOnMobile}
@@ -565,9 +575,8 @@
allVisibleSelected={visibleSelectionStats.visibleCount > 0 &&
visibleSelectionStats.selectedVisibleCount === visibleSelectionStats.visibleCount}
class="px-2"
conversations={conversationsStore.conversations}
{currentChatId}
{filteredConversations}
{isSearchModeActive}
{isSelectionMode}
onBulkDelete={handleBulkDelete}
onBulkExport={handleBulkExport}
@@ -583,7 +592,6 @@
onStop={handleStopGeneration}
onToggleSelect={toggleSelected}
{pinStateIsMixed}
{searchQuery}
{selectedIds}
someVisibleSelected={visibleSelectionStats.selectedVisibleCount > 0 &&
visibleSelectionStats.selectedVisibleCount < visibleSelectionStats.visibleCount}
@@ -638,6 +646,8 @@
<DialogSettingsChat bind:open={settingsDialogOpen} />
<DialogSearch bind:open={searchOpen} onAction={handleSidebarAction} />
<DialogMcpServers bind:open={mcpDialogOpen} />
<DialogDiscoverModels bind:open={uiStore.discoverModelsOpen} />
@@ -1,18 +1,16 @@
<script lang="ts">
import { Search } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon, KeyboardShortcutInfo, SearchInput } from '$lib/components/app';
import { ActionIcon, KeyboardShortcutInfo } 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 { conversationsStore, deviceStore } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { DesktopIconStripItem } from '$lib/types';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
@@ -22,28 +20,12 @@
interface Props {
class: string;
isExpandedMode: boolean;
isSearchModeActive: boolean;
searchQuery: string;
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
onManageModelsClick?: () => void;
onMcpClick?: () => void;
onNewChat?: () => void;
onSettingsClick?: () => void;
onAction?: (action: SidebarAction) => void;
/** Called before following a route item, so a phone can collapse the sidebar. */
onNavigate?: () => void;
}
let {
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
onManageModelsClick,
onMcpClick,
onNewChat,
onSearchClick,
onSearchDeactivated,
onSettingsClick,
searchQuery = $bindable('')
}: Props = $props();
let { class: className, isExpandedMode = false, onAction, onNavigate }: Props = $props();
// the models manager stays desktop-only: a phone picks its models in the selector
const actions = $derived(
@@ -53,16 +35,9 @@
);
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;
@@ -71,41 +46,22 @@
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actions.length);
});
/** 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;
/** 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);
}
if (item.route) {
if (route) {
return () => {
onNewChat?.();
goto(item.route!);
onNavigate?.();
goto(route);
};
}
return isSearchOnMobile ? undefined : onSearchClick;
}
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
onSearchDeactivated?.();
return undefined;
}
function isItemActive(item: {
@@ -133,17 +89,7 @@
<IconComponent class={ICON_CLASS_DEFAULT} />
{/snippet}
{#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}
{#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'
@@ -151,9 +97,7 @@
>
{#each actions as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
{@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
{@const itemOnClick = itemClickHandler(item)}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -166,7 +110,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={itemHref}
href={item.route}
onclick={itemOnClick}
size="default"
variant="ghost"
@@ -195,8 +139,7 @@
<div class="{className} flex-col gap-1 hidden md:flex">
{#each actions as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = itemClickHandler(item, isSearchOnMobile)}
{@const itemOnClick = itemClickHandler(item)}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -16,7 +16,14 @@
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, UI_DATA_ATTRS } from '$lib/constants';
import {
FORK_TREE_DEPTH_PADDING,
ICON_CLASS_DEFAULT,
SHORTCUT_DELETE_CONVERSATION,
SHORTCUT_EDIT_CONVERSATION,
SHORTCUT_EXPORT_CONVERSATION,
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';
@@ -288,7 +295,7 @@
icon: Pencil,
label: 'Edit',
onclick: handleEdit,
shortcut: ['shift', 'cmd', 'e']
shortcut: SHORTCUT_EDIT_CONVERSATION
},
{
icon: Download,
@@ -297,7 +304,7 @@
e.stopPropagation();
conversationsStore.downloadConversation(conversation.id);
},
shortcut: ['shift', 'cmd', 's']
shortcut: SHORTCUT_EXPORT_CONVERSATION
},
{
icon: ListChecks,
@@ -309,7 +316,7 @@
label: 'Delete',
onclick: handleDelete,
separator: true,
shortcut: ['shift', 'cmd', 'd'],
shortcut: SHORTCUT_DELETE_CONVERSATION,
variant: 'destructive'
}
]}
@@ -1,16 +1,13 @@
<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;
filteredConversations: DatabaseConversation[];
conversations: DatabaseConversation[];
currentChatId: string | undefined;
isSearchModeActive: boolean;
searchQuery: string;
isSelectionMode?: boolean;
selectedIds?: Set<string>;
onSelect: (id: string) => void;
@@ -37,9 +34,8 @@
allSelectedArePinned,
allVisibleSelected,
class: className,
conversations,
currentChatId,
filteredConversations,
isSearchModeActive,
isSelectionMode = false,
onBulkDelete,
onBulkExport,
@@ -55,13 +51,12 @@
onStop,
onToggleSelect,
pinStateIsMixed,
searchQuery,
selectedIds = new Set<string>(),
someVisibleSelected,
visibleCount
}: Props = $props();
let conversationTree = $derived(buildConversationTree(filteredConversations));
let conversationTree = $derived(buildConversationTree(conversations));
let pinnedConversations = $derived(
conversationTree.filter(({ conversation }) => conversation.pinned)
@@ -70,44 +65,62 @@
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 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" />
{#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>
</div>
<span>Pinned</span>
</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)}
<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)}
<li class="group/item relative mb-1 p-0">
<SidebarNavigationConversationItem
conversation={{
@@ -133,73 +146,30 @@
/>
</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>
{/if}
<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>
</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 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}
</div>
@@ -66,14 +66,13 @@ export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
/**
* **SidebarNavigation** - Sidebar with actions menu and conversation list
*
* Collapsible sidebar displaying conversation history with search and
* management actions. Integrates with ShadCN sidebar component for
* Collapsible sidebar displaying conversation history 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
* - Manages search state and filtered results locally
* - Owns the search dialog (desktop) and routes phones to the search page
* - Handles conversation CRUD operations via conversationsStore
*
* **Navigation:**
@@ -88,7 +87,7 @@ export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
* - Delete All: Removes all conversations with confirmation
*
* **Features:**
* - Search/filter conversations by title
* - Search dialog (Cmd+K) over chats and sidebar actions
* - Conversation list with message previews (first message truncated)
* - Active conversation highlighting
* - Mobile-responsive collapse/expand via ShadCN sidebar
@@ -96,7 +95,6 @@ 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
@@ -148,17 +146,14 @@ export { default as SidebarNavigationSelectionBar } from './SidebarNavigation/Si
* **SidebarNavigationConversationList** - Grouped conversation list
*
* Pure-presentational list of conversations. Splits items into a Pinned
* section (when not in search mode) and a Recent Conversations / Search
* Results section with the unpinned items. Item selection, edit, delete,
* section and a Recent Conversations section with the unpinned items. Item selection, edit, delete,
* and stop-generation are delegated to the caller via callbacks.
*
* @example
* ```svelte
* <SidebarNavigationConversationList
* {filteredConversations}
* {conversations}
* {currentChatId}
* {isSearchModeActive}
* {searchQuery}
* onSelect={...}
* onEdit={...}
* onDelete={...}
@@ -174,9 +169,8 @@ 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 both inline inside
* {@link SidebarNavigationConversationList} (when search mode is active in the
* sidebar) and as the body of the mobile `/search` route.
* and contextual empty-state messages. Used as the body of the mobile `/search`
* route; desktop search goes through {@link DialogSearch}.
*
* The caller is expected to provide an already-filtered list via
* `filteredConversations` and a `searchQuery` for the empty-state messages.
@@ -6,7 +6,8 @@
SettingsChatImportExportTab,
SettingsChatMobileHeader,
SettingsChatToolsTab,
SettingsFooter
SettingsFooter,
SettingsResetButton
} from '$lib/components/app/settings';
import { Button } from '$lib/components/ui/button';
import {
@@ -48,6 +49,20 @@
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;
@@ -168,7 +183,9 @@
/>
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.GENERAL}
<div class="flex justify-end">
<div class="flex justify-end gap-2">
<SettingsResetButton onReset={handleReset} />
<Button onclick={() => window.location.reload()} variant="outline">
<RefreshCw class="h-3 w-3" />
Reload app
@@ -179,12 +196,12 @@
{/if}
</div>
<div class="mt-8 border-t border-border/30 pt-6">
<div class="mt-8 border-t border-border/30 pt-6 pb-6">
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
</div>
</div>
<SettingsFooter onReset={handleReset} onSave={handleSave} />
<SettingsFooter {hasChanges} onSave={handleSave} />
</div>
</div>
</div>
@@ -1,62 +1,20 @@
<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;
hasChanges?: boolean;
onSave?: () => void;
}
let { onReset, onSave }: Props = $props();
let showResetDialog = $state(false);
function handleResetClick() {
showResetDialog = true;
}
function handleConfirmReset() {
settingsStore.forceSyncWithServerDefaults();
onReset?.();
showResetDialog = false;
}
let { hasChanges = true, onSave }: Props = $props();
function handleSave() {
onSave?.();
}
</script>
<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>
{#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>
<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>
{/if}
@@ -0,0 +1,53 @@
<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,12 +35,17 @@ export { default as SettingsChatParameterSourceIndicator } from './SettingsChat/
export { default as SettingsGroup } from './SettingsGroup.svelte';
/**
* 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.
* Sticky footer with the save button for settings panel, shown only when
* there are unsaved changes. Save commits form state to config store.
*/
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,6 +1,10 @@
export const MS_PER_SECOND = 1000;
export const SECONDS_PER_MINUTE = 60;
export const SECONDS_PER_HOUR = 3600;
export const SECONDS_PER_DAY = 86400;
/** Age past which a relative time label falls back to a plain date. */
export const RELATIVE_TIME_MAX_DAYS = 30;
export const SHORT_DURATION_THRESHOLD = 1;
export const MEDIUM_DURATION_THRESHOLD = 10;
+1
View File
@@ -30,6 +30,7 @@ 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';
@@ -0,0 +1,9 @@
// 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,12 +11,20 @@ export const REASONING_EFFORT_LABELS: Record<string, string> = {
[ReasoningEffort.LOW]: 'Low',
[ReasoningEffort.MAX]: 'Max',
[ReasoningEffort.MEDIUM]: 'Medium',
[ReasoningEffort.OFF]: 'Off'
[ReasoningEffort.MINIMAL]: 'Minimal',
[ReasoningEffort.OFF]: 'Off',
[ReasoningEffort.XHIGH]: 'Extra High'
};
export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [
/** Levels of every menu, ahead of the template levels or the budget levels. */
export const REASONING_EFFORT_BASE_LEVELS: ReasoningEffortLevel[] = [
{ label: 'Default', value: ReasoningEffort.DEFAULT },
{ label: 'Off', value: ReasoningEffort.OFF },
{ 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: 'Low', value: ReasoningEffort.LOW },
{ label: 'Medium', value: ReasoningEffort.MEDIUM },
{ label: 'High', value: ReasoningEffort.HIGH },
+10 -2
View File
@@ -1,4 +1,10 @@
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';
@@ -90,14 +96,15 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{
action: SidebarAction.NEW_CHAT,
icon: SquarePen,
keys: ['shift', 'cmd', 'o'],
keys: SHORTCUT_NEW_CHAT,
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{ action: SidebarAction.SEARCH, icon: Search, keys: SHORTCUT_SEARCH, tooltip: 'Search' },
{
action: SidebarAction.MANAGE_MODELS,
activeRouteId: MODELS_ROUTE_ID,
icon: MODEL_ICON,
keys: SHORTCUT_MODELS,
tooltip: 'Models'
},
{
@@ -108,6 +115,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{
action: SidebarAction.SETTINGS,
icon: Settings,
keys: SHORTCUT_SETTINGS,
tooltip: 'Settings'
}
];
+1
View File
@@ -193,6 +193,7 @@ export enum MimeTypeAudio {
MP3 = 'audio/mp3',
MP3_MPEG = 'audio/mpeg',
MP4 = 'audio/mp4',
OGG = 'audio/ogg',
VND_WAVE = 'audio/vnd.wave',
WAV = 'audio/wav',
WAVE = 'audio/wave',
+3
View File
@@ -9,6 +9,7 @@ export enum KeyboardKey {
B_LOWER = 'b',
BRACKET_LEFT = 'BracketLeft',
BRACKET_RIGHT = 'BracketRight',
COMMA = ',',
D_LOWER = 'd',
D_UPPER = 'D',
E_UPPER = 'E',
@@ -16,6 +17,8 @@ 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.
* These values are sent to the server and mapped to token budgets.
* A level the chat template honors is sent as reasoning_effort, the others map to token budgets.
*/
export enum ReasoningEffort {
DEFAULT = 'default',
@@ -8,5 +8,7 @@ export enum ReasoningEffort {
LOW = 'low',
MAX = 'max',
MEDIUM = 'medium',
OFF = 'off'
MINIMAL = 'minimal',
OFF = 'off',
XHIGH = 'xhigh'
}
+1
View File
@@ -26,6 +26,7 @@ export enum SidebarAction {
MANAGE_MODELS = 'manage-models',
MCP = 'mcp',
NEW_CHAT = 'new-chat',
SEARCH = 'search',
SETTINGS = 'settings'
}
@@ -8,7 +8,6 @@ interface UseDraftMessagesOptions {
getFiles: () => ChatUploadedFile[];
setMessage: (message: string) => void;
setFiles: (files: ChatUploadedFile[]) => void;
getInitialMessage: () => string;
}
export function useDraftMessages(options: UseDraftMessagesOptions) {
@@ -16,7 +15,7 @@ export function useDraftMessages(options: UseDraftMessagesOptions) {
const chatId = options.getChatId();
const draft = draftMessagesStore.getDraftMessage(chatId);
if ((draft.message || draft.files.length > 0) && !options.getInitialMessage()) {
if (draft.message || draft.files.length > 0) {
options.setMessage(draft.message);
options.setFiles(draft.files);
}
@@ -4,14 +4,15 @@ 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;
}
@@ -21,8 +22,12 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
if (isCmdOrCtrl && event.key === KeyboardKey.K_LOWER) {
event.preventDefault();
callbacks.activateSearchMode?.();
callbacks.onSearchActivated?.();
callbacks.openSearch?.();
}
if (isCmdOrCtrl && event.key === KeyboardKey.COMMA) {
event.preventDefault();
callbacks.openSettings?.();
}
if (isCmdOrCtrl && event.key === KeyboardKey.B_LOWER) {
@@ -40,6 +45,15 @@ 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_LEVELS, REASONING_EFFORT_TOKENS } from '$lib/constants';
import { 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,7 +54,27 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
return modelsStore.props.supportsThinking || modelSupportsThinkingFromMessages;
});
const currentEffort = $derived(conversationsStore.preferences.getReasoningEffort());
// 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 thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
@@ -79,7 +99,7 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
return currentEffort === level.value;
},
get levels() {
return REASONING_EFFORT_LEVELS;
return levels;
},
get modelSupportsThinking() {
return modelSupportsThinking;
@@ -93,6 +113,8 @@ 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;
+7 -6
View File
@@ -15,7 +15,6 @@ import {
CONTROL_ACTION,
HEADERS,
LEGACY_AGENTIC_REGEX,
REASONING_EFFORT_TOKENS,
SETTINGS_KEYS,
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
@@ -1094,6 +1093,7 @@ export class ChatService {
stream,
// Generation parameters
temperature,
thinkingBudgetTokens,
timings_per_token,
// Tools for function calling
tools,
@@ -1165,9 +1165,6 @@ 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) {
@@ -1177,8 +1174,12 @@ export class ChatService {
};
}
if (reasoningBudgetTokens >= 0) {
requestBody.thinking_budget_tokens = reasoningBudgetTokens;
if (reasoningEffort) {
requestBody.reasoning_effort = reasoningEffort;
}
if (thinkingBudgetTokens !== undefined) {
requestBody.thinking_budget_tokens = thinkingBudgetTokens;
}
// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
+24 -28
View File
@@ -12,6 +12,7 @@
import {
COMPACTION,
CWD_CLEARED_TEXT,
REASONING_EFFORT_TOKENS,
SYSTEM_MESSAGE_PLACEHOLDER,
TITLE_GENERATION
} from '$lib/constants';
@@ -82,8 +83,6 @@ 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,
@@ -171,11 +170,11 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
return message;
}
async addSystemPrompt(): Promise<void> {
async addSystemPrompt(draft?: { message: string; files: ChatUploadedFile[] }): Promise<void> {
let activeConv = conversationsStore.activeConversation;
if (!activeConv) {
await conversationsStore.createConversation();
await conversationsStore.createConversation(undefined, draft);
activeConv = conversationsStore.activeConversation;
}
@@ -341,17 +340,6 @@ 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 {
@@ -455,13 +443,23 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
// an explicit reasoning choice overrides the server default, DEFAULT sends nothing
const effort = conversationsStore.preferences.getReasoningEffort();
// 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];
if (effort !== ReasoningEffort.DEFAULT) {
apiOptions.enableThinking = effort !== ReasoningEffort.OFF;
}
if (effort !== ReasoningEffort.OFF) apiOptions.reasoningEffort = effort;
if (modelsStore.props.getReasoningEfforts(modelId).includes(effort)) {
apiOptions.reasoningEffort = effort;
} else if (budgetTokens >= 0) {
apiOptions.thinkingBudgetTokens = budgetTokens;
}
if (hasValue(currentConfig.temperature))
@@ -564,10 +562,6 @@ 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);
}
@@ -726,11 +720,6 @@ 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;
@@ -940,7 +929,15 @@ 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);
}
@@ -1016,7 +1013,6 @@ 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,6 +16,7 @@ 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
@@ -260,9 +261,13 @@ 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): Promise<string> {
async createConversation(
name?: string,
draft?: { message: string; files: ChatUploadedFile[] }
): 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
@@ -281,8 +286,17 @@ 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 { ServerModelStatus } from '$lib/enums';
import { ReasoningEffort, 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,6 +396,13 @@ 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;
}
+69 -17
View File
@@ -10,16 +10,22 @@
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
*/
import { MODEL_PROPS_CACHE } from '$lib/constants';
import { FileTypeCategory, ModelModality } from '$lib/enums';
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 { 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 { SvelteSet } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
/**
* The slice of modelsStore the manager reads. Kept narrow on purpose so it
@@ -45,7 +51,8 @@ export class ModelPropsManager {
maxEntries: MODEL_PROPS_CACHE.MAX_ENTRIES,
ttlMs: MODEL_PROPS_CACHE.TTL_MS
});
private fetching = new SvelteSet<string>();
/** Props requests in flight, a second caller awaits the same request. */
private fetching = new SvelteMap<string, Promise<ApiLlamaCppServerProps | null>>();
/**
* Whether the selected model's chat template supports thinking/reasoning.
@@ -160,24 +167,27 @@ export class ModelPropsManager {
return null;
}
if (this.fetching.has(modelId)) return null;
const inFlight = this.fetching.get(modelId);
this.fetching.add(modelId);
if (inFlight) return inFlight;
try {
const props = await PropsService.fetchForModel(modelId);
const request = PropsService.fetchForModel(modelId)
.then((props) => {
this.cache.set(modelId, props);
this.cacheVersion++;
this.cache.set(modelId, props);
this.cacheVersion++;
return props;
})
.catch((error) => {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
return null;
})
.finally(() => this.fetching.delete(modelId));
return null;
} finally {
this.fetching.delete(modelId);
}
this.fetching.set(modelId, request);
return request;
}
getModelContextSize(modelId: string): number | null {
@@ -227,6 +237,41 @@ 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);
}
@@ -243,6 +288,13 @@ 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.
+28 -14
View File
@@ -201,7 +201,7 @@ export class ModelStatusManager {
private statusWaiters = new SvelteMap<
string,
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }[]
>();
/** Models the user asked to stop (pause or cancel); the download_failed the stop triggers is intentional, not a failure. */
private stopRequests = new SvelteMap<string, ModelDownloadStopRequest>();
@@ -369,10 +369,21 @@ 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;
await this.load(modelId);
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);
}
/**
@@ -788,12 +799,11 @@ export class ModelStatusManager {
}
private rejectStatus(modelId: string, error: Error): void {
const waiter = this.statusWaiters.get(modelId);
const waiters = this.statusWaiters.get(modelId) ?? [];
if (waiter) {
this.statusWaiters.delete(modelId);
waiter.reject(error);
}
this.statusWaiters.delete(modelId);
for (const waiter of waiters) waiter.reject(error);
}
private removeRouterModel(modelId: string): void {
@@ -840,18 +850,22 @@ export class ModelStatusManager {
}
private settleStatus(modelId: string, status: ServerModelStatus): void {
const waiter = this.statusWaiters.get(modelId);
const waiters = this.statusWaiters.get(modelId) ?? [];
const pending = waiters.filter((waiter) => waiter.target !== status);
if (waiter && waiter.target === status) {
this.statusWaiters.delete(modelId);
waiter.resolve();
}
if (pending.length) this.statusWaiters.set(modelId, pending);
else this.statusWaiters.delete(modelId);
for (const waiter of waiters) if (waiter.target === status) waiter.resolve();
}
// one operation runs per model at a time, so one waiter per model suffices
// a load and the requests that wait on it can each hold a waiter for the same model
private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
return new Promise((resolve, reject) => {
this.statusWaiters.set(modelId, { reject, resolve, target });
this.statusWaiters.set(modelId, [
...(this.statusWaiters.get(modelId) ?? []),
{ reject, resolve, target }
]);
});
}
}
@@ -33,9 +33,40 @@ 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)
*/
@@ -91,35 +122,7 @@ class SettingsStore {
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
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);
for (const [key, value] of Object.entries(this.getResetValues())) {
setConfigValue(this.config, key, value);
if (key === SETTINGS_KEYS.THEME) {
@@ -129,6 +132,10 @@ class SettingsStore {
this.userOverrides.delete(key);
}
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
this.userOverrides.delete(key);
}
this.saveConfig();
}
@@ -427,6 +434,43 @@ class SettingsStore {
}
}
/**
* Values applied by "Reset to Default": admin ui_settings first, then server
* defaults from /props for sampling params, then the factory defaults
*/
private getResetValues(): Record<string, unknown> {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
const values: Record<string, unknown> = {};
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
values[key] = uiSettings[key];
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
values[key] = '';
} else if (key in SETTING_CONFIG_DEFAULT) {
values[key] = getConfigValue(SETTING_CONFIG_DEFAULT, key);
}
}
// Non-syncable keys: reset is a full return to the instance state, the
// admin baseline value when defined, the factory default otherwise.
for (const key of Object.keys(SETTING_CONFIG_DEFAULT)) {
if (ParameterSyncService.canSyncParameter(key)) {
continue;
}
values[key] =
uiSettings && key in uiSettings && uiSettings[key] !== undefined
? uiSettings[key]
: getConfigValue(SETTING_CONFIG_DEFAULT, key);
}
return values;
}
/**
*
*
+5
View File
@@ -284,6 +284,10 @@ 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;
@@ -308,6 +312,7 @@ export interface ApiChatCompletionRequest {
tools?: ApiChatCompletionTool[];
// Reasoning parameters
reasoning_format?: string;
reasoning_effort?: string;
// Generation parameters
temperature?: number;
max_tokens?: number;
+3 -1
View File
@@ -89,8 +89,10 @@ export interface SettingsChatServiceOptions {
excludeReasoningFromContext?: boolean;
// Enable model thinking/reasoning via chat_template_kwargs
enableThinking?: boolean;
// Reasoning effort level (low/medium/high/max) for thinking models
// Reasoning effort level the chat template honors, sent as reasoning_effort
reasoningEffort?: ReasoningEffort;
// Token budget for thinking, sent as thinking_budget_tokens
thinkingBudgetTokens?: number;
tools?: OpenAIToolDefinition[];
// Generation parameters
temperature?: number;
+22 -10
View File
@@ -1,20 +1,32 @@
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 {
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
) {
if (isWavMimeType(mimeType)) {
return FileTypeAudio.WAV;
}
+1
View File
@@ -38,6 +38,7 @@ export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
case MimeTypeAudio.MP3_MPEG:
case MimeTypeAudio.MP3:
case MimeTypeAudio.MP4:
case MimeTypeAudio.OGG:
case MimeTypeAudio.WAV:
case MimeTypeAudio.WAVE:
case MimeTypeAudio.X_WAV:
+30
View File
@@ -1,6 +1,8 @@
import {
MEDIUM_DURATION_THRESHOLD,
MS_PER_SECOND,
RELATIVE_TIME_MAX_DAYS,
SECONDS_PER_DAY,
SECONDS_PER_HOUR,
SECONDS_PER_MINUTE,
SHORT_DURATION_THRESHOLD
@@ -102,6 +104,34 @@ export function formatJsonPretty(jsonString: string): string {
}
}
/**
* Formats a past timestamp as a coarse relative label.
* Examples: "just now", "37 minutes ago", "1 hour ago", "3 days ago"
*
* @param timestamp - Epoch milliseconds in the past
* @returns Relative time string
*/
export function formatRelativeTime(timestamp: number): string {
const elapsedSeconds = (Date.now() - timestamp) / MS_PER_SECOND;
const plural = (value: number, unit: string) => `${value} ${unit}${value === 1 ? '' : 's'} ago`;
if (elapsedSeconds < SECONDS_PER_MINUTE) return 'just now';
if (elapsedSeconds < SECONDS_PER_HOUR) {
return plural(Math.floor(elapsedSeconds / SECONDS_PER_MINUTE), 'minute');
}
if (elapsedSeconds < SECONDS_PER_DAY) {
return plural(Math.floor(elapsedSeconds / SECONDS_PER_HOUR), 'hour');
}
if (elapsedSeconds < RELATIVE_TIME_MAX_DAYS * SECONDS_PER_DAY) {
return plural(Math.floor(elapsedSeconds / SECONDS_PER_DAY), 'day');
}
return new Date(timestamp).toLocaleDateString();
}
/**
* Format time as HH:MM:SS in 24-hour format
*
+1
View File
@@ -99,6 +99,7 @@ export {
formatJsonPretty,
formatTime,
formatPerformanceTime,
formatRelativeTime,
formatAttachmentText
} from './formatters';
@@ -1,9 +1,11 @@
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 { FileTypeCategory } from '$lib/enums';
import { FileExtensionAudio, FileTypeCategory, MimeTypeAudio } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { getFileTypeCategory } from '$lib/utils';
@@ -127,10 +129,35 @@ export async function processFilesToChatUploaded(
});
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.AUDIO) {
// Generate preview URL for audio files
const preview = await readFileAsDataURL(file);
let audioFile = file;
results.push({ ...base, preview });
// 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);
results.push({
...base,
file: audioFile,
name: audioFile.name,
preview,
size: audioFile.size,
type: audioFile.type
});
} else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
// Generate preview URL for video files
const preview = await readFileAsDataURL(file);
+2 -16
View File
@@ -13,7 +13,6 @@
HEADERS,
MODELS_ROUTE_ID,
NEW_CHAT_TAB_ID,
ROUTES,
SETTINGS_KEYS,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
@@ -47,12 +46,7 @@
let innerHeight = $state<number | undefined>();
let innerWidth = $state(browser ? window.innerWidth : 0);
let chatSidebar:
| {
activateSearchMode?: () => void;
editActiveConversation?: () => void;
}
| undefined = $state();
let chatSidebar: { editActiveConversation?: () => void } | undefined = $state();
let showBuildVersion = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_BUILD_VERSION] as boolean
@@ -308,15 +302,7 @@
<Tooltip.Provider delayDuration={TOOLTIP_DELAY_DURATION}>
<div class="flex flex-col md:flex-row">
<SidebarNavigation
onSearchClick={() => {
if (deviceStore.isMobile) {
goto(ROUTES.SEARCH);
} else if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
}
}}
/>
<SidebarNavigation />
<!-- 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,6 +1,7 @@
<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';
@@ -85,7 +86,9 @@
await userEvent.click(logoTrigger);
const searchTrigger = screen.getByText('Search');
userEvent.click(searchTrigger);
await userEvent.click(searchTrigger);
await expect(await screen.findByRole('combobox')).toHaveFocus();
}}
>
<div class="flex-column h-screen w-72 bg-background">
+42 -2
View File
@@ -1,5 +1,12 @@
import { formatContextLength, formatParameters } from '$lib/utils/formatters';
import { describe, expect, it } from 'vitest';
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';
describe('formatContextLength', () => {
it('shows a dash when no value is known', () => {
@@ -22,3 +29,36 @@ 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());
});
});