Merge branch 'master' into pr/18039

This commit is contained in:
Georgi Gerganov
2026-03-16 15:41:24 +02:00
857 changed files with 80688 additions and 43152 deletions
+15 -8
View File
@@ -74,8 +74,8 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `--mlock` | force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | whether to memory-map model. Explicitly enabling mmap disables direct-io. (if mmap disabled, slower load but may reduce pageouts if not using mlock) (default: enabled)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | use DirectIO if available. Takes precedence over --mmap (default: enabled)<br/>(env: LLAMA_ARG_DIO) |
| `--mmap, --no-mmap` | whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock) (default: enabled)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | use DirectIO if available. (default: disabled)<br/>(env: LLAMA_ARG_DIO) |
| `--numa TYPE` | attempt optimizations that help on some NUMA systems<br/>- distribute: spread execution evenly over all nodes<br/>- isolate: only spawn threads on CPUs on the node that execution started on<br/>- numactl: use the CPU map provided by numactl<br/>if run without this previously, it is recommended to drop the system page cache before using this<br/>see https://github.com/ggml-org/llama.cpp/issues/1437<br/>(env: LLAMA_ARG_NUMA) |
| `-dev, --device <dev1,dev2,..>` | comma-separated list of devices to use for offloading (none = don't offload)<br/>use --list-devices to see a list of available devices<br/>(env: LLAMA_ARG_DEVICE) |
| `--list-devices` | print list of available devices and exit |
@@ -126,14 +126,14 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-s, --seed SEED` | RNG seed (default: -1, use random seed for -1) |
| `--sampler-seq, --sampling-seq SEQUENCE` | simplified sequence for samplers that will be used (default: edskypmxt) |
| `--ignore-eos` | ignore end of stream token and continue generating (implies --logit-bias EOS-inf) |
| `--temp N` | temperature (default: 0.80) |
| `--temp, --temperature N` | temperature (default: 0.80) |
| `--top-k N` | top-k sampling (default: 40, 0 = disabled)<br/>(env: LLAMA_ARG_TOP_K) |
| `--top-p N` | top-p sampling (default: 0.95, 1.0 = disabled) |
| `--min-p N` | min-p sampling (default: 0.05, 0.0 = disabled) |
| `--top-nsigma N` | top-n-sigma sampling (default: -1.00, -1.0 = disabled) |
| `--top-nsigma, --top-n-sigma N` | top-n-sigma sampling (default: -1.00, -1.0 = disabled) |
| `--xtc-probability N` | xtc probability (default: 0.00, 0.0 = disabled) |
| `--xtc-threshold N` | xtc threshold (default: 0.10, 1.0 = disabled) |
| `--typical N` | locally typical sampling, parameter p (default: 1.00, 1.0 = disabled) |
| `--typical, --typical-p N` | locally typical sampling, parameter p (default: 1.00, 1.0 = disabled) |
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled, -1 = ctx_size) |
| `--repeat-penalty N` | penalize repeat sequence of tokens (default: 1.00, 1.0 = disabled) |
| `--presence-penalty N` | repeat alpha presence penalty (default: 0.00, 0.0 = disabled) |
@@ -162,9 +162,11 @@ For the full list of features, please refer to [server's changelog](https://gith
| Argument | Explanation |
| -------- | ----------- |
| `-lcs, --lookup-cache-static FNAME` | path to static lookup cache to use for lookup decoding (not updated by generation) |
| `-lcd, --lookup-cache-dynamic FNAME` | path to dynamic lookup cache to use for lookup decoding (updated by generation) |
| `--ctx-checkpoints, --swa-checkpoints N` | max number of context checkpoints to create per slot (default: 8)[(more info)](https://github.com/ggml-org/llama.cpp/pull/15293)<br/>(env: LLAMA_ARG_CTX_CHECKPOINTS) |
| `-cram, --cache-ram N` | set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)[(more info)](https://github.com/ggml-org/llama.cpp/pull/16391)<br/>(env: LLAMA_ARG_CACHE_RAM) |
| `-kvu, --kv-unified` | use single unified KV buffer shared across all sequences (default: enabled if number of slots is auto)<br/>(env: LLAMA_ARG_KV_UNIFIED) |
| `-kvu, --kv-unified, -no-kvu, --no-kv-unified` | use single unified KV buffer shared across all sequences (default: enabled if number of slots is auto)<br/>(env: LLAMA_ARG_KV_UNIFIED) |
| `--context-shift, --no-context-shift` | whether to use context shift on infinite text generation (default: disabled)<br/>(env: LLAMA_ARG_CONTEXT_SHIFT) |
| `-r, --reverse-prompt PROMPT` | halt generation at PROMPT, return control in interactive mode |
| `-sp, --special` | special tokens output enabled (default: false) |
@@ -182,7 +184,8 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-otd, --override-tensor-draft <tensor name pattern>=<buffer type>,...` | override tensor buffer type for draft model |
| `-cmoed, --cpu-moe-draft` | keep all Mixture of Experts (MoE) weights in the CPU for the draft model<br/>(env: LLAMA_ARG_CPU_MOE_DRAFT) |
| `-ncmoed, --n-cpu-moe-draft N` | keep the Mixture of Experts (MoE) weights of the first N layers in the CPU for the draft model<br/>(env: LLAMA_ARG_N_CPU_MOE_DRAFT) |
| `-a, --alias STRING` | set alias for model name (to be used by REST API)<br/>(env: LLAMA_ARG_ALIAS) |
| `-a, --alias STRING` | set model name aliases, comma-separated (to be used by API)<br/>(env: LLAMA_ARG_ALIAS) |
| `--tags STRING` | set model tags, comma-separated (informational, not used for routing)<br/>(env: LLAMA_ARG_TAGS) |
| `--host HOST` | ip address to listen, or bind to an UNIX socket if the address ends with .sock (default: 127.0.0.1)<br/>(env: LLAMA_ARG_HOST) |
| `--port PORT` | port to listen (default: 8080)<br/>(env: LLAMA_ARG_PORT) |
| `--path PATH` | path to serve static files from (default: )<br/>(env: LLAMA_ARG_STATIC_PATH) |
@@ -229,6 +232,10 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-ngld, --gpu-layers-draft, --n-gpu-layers-draft N` | max. number of draft model layers to store in VRAM, either an exact number, 'auto', or 'all' (default: auto)<br/>(env: LLAMA_ARG_N_GPU_LAYERS_DRAFT) |
| `-md, --model-draft FNAME` | draft model for speculative decoding (default: unused)<br/>(env: LLAMA_ARG_MODEL_DRAFT) |
| `--spec-replace TARGET DRAFT` | translate the string in TARGET into DRAFT if the draft model and main model are not compatible |
| `--spec-type [none\|ngram-cache\|ngram-simple\|ngram-map-k\|ngram-map-k4v\|ngram-mod]` | type of speculative decoding to use when no draft model is provided (default: none) |
| `--spec-ngram-size-n N` | ngram size N for ngram-simple/ngram-map speculative decoding, length of lookup n-gram (default: 12) |
| `--spec-ngram-size-m N` | ngram size M for ngram-simple/ngram-map speculative decoding, length of draft m-gram (default: 48) |
| `--spec-ngram-min-hits N` | minimum hits for ngram-map speculative decoding (default: 1) |
| `-mv, --model-vocoder FNAME` | vocoder model for audio generation (default: unused) |
| `--tts-use-guide-tokens` | Use guide tokens to improve TTS word recall |
| `--embd-gemma-default` | use default EmbeddingGemma model (note: can download weights from the internet) |
@@ -1510,7 +1517,7 @@ version = 1
; If the same key is defined in a specific preset, it will override the value in this global section.
[*]
c = 8192
n-gpu-layer = 8
n-gpu-layers = 8
; If the key corresponds to an existing model on the server,
; this will be used as the default config for that model
Binary file not shown.
+3 -3
View File
@@ -36,7 +36,7 @@
const params = signal({
n_predict: 358, // 358 is a nice number
temperature: 0.8, // adapt all following parameters to optimized min-p requierements. If for non-english, set to 0.6 or lower
temperature: 0.8, // adapt all following parameters to optimized min-p requirements. If for non-english, set to 0.6 or lower
repeat_last_n: 0, // 0 = disable penalty, -1 = context size
repeat_penalty: 1.0, // 1.0 = disabled
dry_multiplier: 0.0, // 0.0 = disabled, 0.8 works well
@@ -108,7 +108,7 @@
let importedTemplates = local_storage_getDataAsObject('user_templates')
if (importedTemplates) {
// saved templates were successfuly imported.
// saved templates were successfully imported.
console.log('Processing saved templates and updating default template')
params.value = { ...params.value, image_data: [] };
@@ -129,7 +129,7 @@
}
function userTemplateResetToDefault() {
console.log('Reseting themplate to default')
console.log('Reseting template to default')
selectedUserTemplate.value.name = 'default';
selectedUserTemplate.value.data = savedUserTemplates.value['default'];
}
@@ -729,6 +729,10 @@ export class SchemaConverter {
return this._addRule(ruleName, out.join(''));
} else if ((schemaType === 'object') || (Object.keys(schema).length === 0)) {
return this._addRule(ruleName, this._addPrimitive('object', PRIMITIVE_RULES['object']));
} else if (schemaType === undefined && typeof schema === 'object' && !Array.isArray(schema) && schema !== null) {
// No type constraint and no recognized structural keywords (e.g. {"description": "..."}).
// Per JSON Schema semantics this is equivalent to {} and accepts any value.
return this._addRule(ruleName, this._addPrimitive('value', PRIMITIVE_RULES['value']));
} else {
if (!(schemaType in PRIMITIVE_RULES)) {
throw new Error(`Unrecognized schema: ${JSON.stringify(schema)}`);
+1 -1
View File
@@ -63,7 +63,7 @@ export function trim_repeat_garbage_at_end(sIn, maxSubL=10, maxMatchLenThreshold
/**
* Simple minded logic to help remove repeating garbage at end of the string, till it cant.
* Simple minded logic to help remove repeating garbage at end of the string, till it can't.
* If its not able to trim, then it will try to skip a char at end and then trim, a few times.
* This ensures that even if there are multiple runs of garbage with different patterns, the
* logic still tries to munch through them.
+8 -8
View File
@@ -30,7 +30,7 @@ The UI follows a responsive web design so that the layout can adapt to available
enough manner, in general.
Allows developer/end-user to control some of the behaviour by updating gMe members from browser's devel-tool
console. Parallely some of the directly useful to end-user settings can also be changed using the provided
console. Parallelly some of the directly useful to end-user settings can also be changed using the provided
settings ui.
NOTE: Current web service api doesnt expose the model context length directly, so client logic doesnt provide
@@ -38,7 +38,7 @@ any adaptive culling of old messages nor of replacing them with summary of their
is a optional sliding window based chat logic, which provides a simple minded culling of old messages from
the chat history before sending to the ai model.
NOTE: Wrt options sent with the request, it mainly sets temperature, max_tokens and optionaly stream for now.
NOTE: Wrt options sent with the request, it mainly sets temperature, max_tokens and optionally stream for now.
However if someone wants they can update the js file or equivalent member in gMe as needed.
NOTE: One may be able to use this to chat with openai api web-service /chat/completions endpoint, in a very
@@ -88,7 +88,7 @@ Once inside
then the end user needs to enter the same.
This keeps the logic simple, while still giving flexibility to the end user to
manage any templating/tagging requirement wrt their messages to the model.
* the logic doesnt insert newline at the begining and end wrt the prompt message generated.
* the logic doesnt insert newline at the beginning and end wrt the prompt message generated.
However if the chat being sent to /completions end point has more than one role's message,
then insert newline when moving from one role's message to the next role's message, so
that it can be clearly identified/distinguished.
@@ -101,8 +101,8 @@ Once inside
Normally Completion mode doesnt need system prompt, while Chat mode can generate better/interesting
responses with a suitable system prompt.
* if chat.add_system_begin is used
* you cant change the system prompt, after it is has been submitted once along with user query.
* you cant set a system prompt, after you have submitted any user query
* you can't change the system prompt, after it is has been submitted once along with user query.
* you can't set a system prompt, after you have submitted any user query
* if chat.add_system_anytime is used
* one can change the system prompt any time during chat, by changing the contents of system prompt.
* inturn the updated/changed system prompt will be inserted into the chat session.
@@ -129,7 +129,7 @@ Once inside
### Reason behind this
The idea is to be easy enough to use for basic purposes, while also being simple and easily discernable
The idea is to be easy enough to use for basic purposes, while also being simple and easily discernible
by developers who may not be from web frontend background (so inturn may not be familiar with template /
end-use-specific-language-extensions driven flows) so that they can use it to explore/experiment things.
@@ -167,7 +167,7 @@ It is attached to the document object. Some of these can also be updated using t
messages that get inserted into prompt field wrt /Completion endpoint.
bTrimGarbage - whether garbage repeatation at the end of the generated ai response, should be
trimmed or left as is. If enabled, it will be trimmed so that it wont be sent back as part of
trimmed or left as is. If enabled, it will be trimmed so that it won't be sent back as part of
subsequent chat history. At the same time the actual trimmed text is shown to the user, once
when it was generated, so user can check if any useful info/data was there in the response.
@@ -244,7 +244,7 @@ full chat history. This way if there is any response with garbage/repeatation, i
mess with things beyond the next question/request/query, in some ways. The trim garbage
option also tries to help avoid issues with garbage in the context to an extent.
Set max_tokens to 1024, so that a relatively large previous reponse doesnt eat up the space
Set max_tokens to 1024, so that a relatively large previous response doesnt eat up the space
available wrt next query-response. However dont forget that the server when started should
also be started with a model context size of 1k or more, to be on safe side.
+2 -2
View File
@@ -318,7 +318,7 @@ class SimpleChat {
}
/**
* Allow setting of system prompt, but only at begining.
* Allow setting of system prompt, but only at beginning.
* @param {string} sysPrompt
* @param {string} msgTag
*/
@@ -333,7 +333,7 @@ class SimpleChat {
console.error(`ERRR:SimpleChat:SC:${msgTag}:You need to specify system prompt before any user query, ignoring...`);
} else {
if (this.xchat[0].content !== sysPrompt) {
console.error(`ERRR:SimpleChat:SC:${msgTag}:You cant change system prompt, mid way through, ignoring...`);
console.error(`ERRR:SimpleChat:SC:${msgTag}:You can't change system prompt, mid way through, ignoring...`);
}
}
}
+2 -2
View File
@@ -44,7 +44,7 @@ export function el_create_button(id, callback, name=undefined, innerText=undefin
}
/**
* Create a para and set it up. Optionaly append it to a passed parent.
* Create a para and set it up. Optionally append it to a passed parent.
* @param {string} text
* @param {HTMLElement | undefined} elParent
* @param {string | undefined} id
@@ -111,7 +111,7 @@ export function el_creatediv_boolbutton(id, label, texts, defaultValue, cb, clas
/**
* Create a select ui element, with a set of options to select from.
* * options: an object which contains name-value pairs
* * defaultOption: the value whose name should be choosen, by default.
* * defaultOption: the value whose name should be chosen, by default.
* * cb : the call back returns the name string of the option selected.
*
* @param {string} id
+89 -9
View File
@@ -231,19 +231,77 @@ server_tokens::server_tokens(mtmd::input_chunks & mtmd_chunks, bool has_mtmd) :
server_tokens::server_tokens(const llama_tokens & tokens, bool has_mtmd) : has_mtmd(has_mtmd), tokens(tokens) {
}
llama_pos server_tokens::pos_next() const {
llama_pos server_tokens::pos_next(int64_t n_tokens) const {
if (!has_mtmd) {
return tokens.size();
if (n_tokens < 0) {
return tokens.size();
}
return n_tokens;
}
llama_pos res = tokens.size();
if (n_tokens < 0) {
llama_pos res = tokens.size();
for (auto it = map_idx_to_media.begin(); it != map_idx_to_media.end(); ++it) {
const auto & chunk = it->second;
res += mtmd_input_chunk_get_n_pos(chunk.get()) - mtmd_input_chunk_get_n_tokens(chunk.get());
for (auto it = map_idx_to_media.begin(); it != map_idx_to_media.end(); ++it) {
const auto & chunk = it->second;
res += mtmd_input_chunk_get_n_pos(chunk.get()) - mtmd_input_chunk_get_n_tokens(chunk.get());
}
return res;
}
return res;
int64_t idx = 0;
llama_pos pos = 0;
GGML_ASSERT(n_tokens <= (int64_t)tokens.size());
while (idx < n_tokens) {
const auto media_it = map_idx_to_media.find(idx);
if (media_it != map_idx_to_media.end()) {
const auto & chunk = media_it->second;
const llama_pos n_pos = mtmd_input_chunk_get_n_pos(chunk.get());
const size_t n_tok = mtmd_input_chunk_get_n_tokens(chunk.get());
pos += n_pos;
idx += n_tok;
} else {
pos++;
idx++;
}
}
return pos;
}
size_t server_tokens::size_up_to_pos(llama_pos max_pos) const {
if (!has_mtmd) {
return std::min((size_t)max_pos, tokens.size());
}
size_t idx = 0;
llama_pos pos = 0;
while (idx < tokens.size()) {
const auto media_it = map_idx_to_media.find(idx);
if (media_it != map_idx_to_media.end()) {
const auto & chunk = media_it->second;
const llama_pos n_pos = mtmd_input_chunk_get_n_pos(chunk.get());
const size_t n_tok = mtmd_input_chunk_get_n_tokens(chunk.get());
pos += n_pos;
idx += n_tok;
} else {
pos++;
idx++;
}
if (pos >= max_pos) {
break;
}
}
return idx;
}
std::string server_tokens::str() const {
@@ -1043,6 +1101,22 @@ json oaicompat_chat_params_parse(
llama_params["chat_parser"] = chat_params.parser;
}
// Reasoning budget: pass parameters through to sampling layer
{
int reasoning_budget = opt.reasoning_budget;
if (reasoning_budget == -1 && body.contains("thinking_budget_tokens")) {
reasoning_budget = json_value(body, "thinking_budget_tokens", -1);
}
if (reasoning_budget >= 0 && !chat_params.thinking_end_tag.empty()) {
llama_params["reasoning_budget_tokens"] = reasoning_budget;
llama_params["reasoning_budget_start_tag"] = chat_params.thinking_start_tag;
llama_params["reasoning_budget_end_tag"] = chat_params.thinking_end_tag;
llama_params["reasoning_budget_message"] = opt.reasoning_budget_message;
llama_params["reasoning_budget_activate_immediately"] = chat_params.thinking_forced_open;
}
}
// Handle "logprobs" field
// TODO: The response format of this option is not yet OAI-compatible, but seems like no one really using it; We may need to fix it in the future
if (json_value(body, "logprobs", false)) {
@@ -1405,6 +1479,7 @@ json convert_anthropic_to_oai(const json & body) {
json tool_calls = json::array();
json converted_content = json::array();
json tool_results = json::array();
std::string reasoning_content;
bool has_tool_calls = false;
for (const auto & block : content) {
@@ -1412,6 +1487,8 @@ json convert_anthropic_to_oai(const json & body) {
if (type == "text") {
converted_content.push_back(block);
} else if (type == "thinking") {
reasoning_content += json_value(block, "thinking", std::string());
} else if (type == "image") {
json source = json_value(block, "source", json::object());
std::string source_type = json_value(source, "type", std::string());
@@ -1470,16 +1547,19 @@ json convert_anthropic_to_oai(const json & body) {
}
}
if (!converted_content.empty() || has_tool_calls) {
if (!converted_content.empty() || has_tool_calls || !reasoning_content.empty()) {
json new_msg = {{"role", role}};
if (!converted_content.empty()) {
new_msg["content"] = converted_content;
} else if (has_tool_calls) {
} else if (has_tool_calls || !reasoning_content.empty()) {
new_msg["content"] = "";
}
if (!tool_calls.empty()) {
new_msg["tool_calls"] = tool_calls;
}
if (!reasoning_content.empty()) {
new_msg["reasoning_content"] = reasoning_content;
}
oai_messages.push_back(new_msg);
}
+8 -1
View File
@@ -167,7 +167,12 @@ public:
// for debugging
std::string str() const;
llama_pos pos_next() const;
// the next position after n_tokens. if n_tokens < 0, return the next position after all tokens.
llama_pos pos_next(int64_t n_tokens = -1) const;
// number of tokens with position < max_pos
size_t size_up_to_pos(llama_pos max_pos) const;
const mtmd::input_chunk_ptr & find_chunk(size_t idx) const;
void push_back(llama_token tok);
@@ -282,6 +287,8 @@ struct server_chat_params {
bool allow_image;
bool allow_audio;
bool enable_thinking = true;
int reasoning_budget = -1;
std::string reasoning_budget_message;
std::string media_path;
};
+140 -62
View File
@@ -12,6 +12,7 @@
#include "mtmd.h"
#include "mtmd-helper.h"
#include <algorithm>
#include <cstddef>
#include <cinttypes>
#include <memory>
@@ -561,7 +562,7 @@ private:
llama_model_ptr model_dft;
bool add_bos_token = true;
bool add_bos_token = true;
int32_t n_ctx; // total context for all clients / slots
@@ -569,6 +570,7 @@ private:
std::vector<server_slot> slots;
int slots_debug = 0;
int n_empty_consecutive = 0;
std::unique_ptr<server_prompt_cache> prompt_cache;
@@ -580,6 +582,8 @@ private:
float slot_prompt_similarity = 0.0f;
std::string model_name; // name of the loaded model, to be used by API
std::set<std::string> model_aliases; // additional names for the model
std::set<std::string> model_tags; // informational tags
bool sleeping = false;
@@ -736,6 +740,13 @@ private:
}
}
if (llama_model_n_swa(model) == 0) {
if (params_base.swa_full) {
params_base.swa_full = false;
SRV_WRN("%s\n", "swa_full is not supported by this model, it will be disabled");
}
}
// Necessary similarity of prompt for slot selection
slot_prompt_similarity = params_base.slot_prompt_similarity;
@@ -824,10 +835,9 @@ private:
SRV_WRN("%s", "for more info see https://github.com/ggml-org/llama.cpp/pull/16391\n");
if (!params_base.model_alias.empty()) {
// user explicitly specified model name
model_name = params_base.model_alias;
// backward compat: use first alias as model name
model_name = *params_base.model_alias.begin();
} else if (!params_base.model.name.empty()) {
// use model name in registry format (for models in cache)
model_name = params_base.model.name;
} else {
// fallback: derive model name from file name
@@ -835,6 +845,9 @@ private:
model_name = model_path.filename().string();
}
model_aliases = params_base.model_alias;
model_tags = params_base.model_tags;
if (!is_resume) {
return init();
}
@@ -891,9 +904,10 @@ private:
}
// thinking is enabled if:
// 1. It's not explicitly disabled (reasoning_budget == 0)
// 1. It's not explicitly disabled via --reasoning off
// 2. The chat template supports it
const bool enable_thinking = params_base.use_jinja && params_base.reasoning_budget != 0 && common_chat_templates_support_enable_thinking(chat_templates.get());
const bool template_supports_thinking = params_base.use_jinja && common_chat_templates_support_enable_thinking(chat_templates.get());
const bool enable_thinking = params_base.enable_reasoning != 0 && template_supports_thinking;
SRV_INF("%s: chat template, thinking = %d\n", __func__, enable_thinking);
chat_params = {
@@ -905,6 +919,8 @@ private:
/* allow_image */ mctx ? mtmd_support_vision(mctx) : false,
/* allow_audio */ mctx ? mtmd_support_audio (mctx) : false,
/* enable_thinking */ enable_thinking,
/* reasoning_budget */ params_base.reasoning_budget,
/* reasoning_budget_msg */ params_base.reasoning_budget_message,
/* media_path */ params_base.media_path,
};
}
@@ -1006,9 +1022,6 @@ private:
// don't update the cache if the slot's context is empty
update_cache = update_cache && tokens.size() > 0;
// TODO: mtmd does not support prompt cache
update_cache = update_cache && (ret->mctx == nullptr);
if (update_cache) {
SRV_WRN("%s", "updating prompt cache\n");
@@ -1187,6 +1200,9 @@ private:
? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
: SLOT_STATE_STARTED;
// reset server kill-switch counter
n_empty_consecutive = 0;
SLT_INF(slot, "processing task, is_child = %d\n", slot.task->is_child());
return true;
}
@@ -1453,7 +1469,7 @@ private:
res->id = slot.task->id;
res->id_slot = slot.id;
res->index = slot.task->index;
res->index = slot.task->index;
// keep copy of last generated text for debugging purposes
if (slots_debug) {
@@ -2142,6 +2158,9 @@ private:
if (slot.state == SLOT_STATE_PROCESSING_PROMPT || slot.state == SLOT_STATE_STARTED) {
const auto & input_tokens = slot.task->tokens;
// used to determine the number of tokens added to the batch for the current slot
const auto n_tokens_prev = batch.n_tokens;
// TODO: maybe move branch to outside of this loop in the future
if (slot.state == SLOT_STATE_STARTED) {
slot.t_start_process_prompt = ggml_time_us();
@@ -2293,15 +2312,15 @@ private:
n_past = 0;
}
llama_pos pos_next = slot.prompt.tokens.pos_next(n_past);
// note: when n_swa == 0, the model does not use SWA, which is equivalent to a window of 1
const auto n_swa = std::max(1, llama_model_n_swa(model));
// the largest pos_min required for a checkpoint to be useful
const auto pos_min_thold = std::max(0, n_past - n_swa);
const auto pos_min_thold = std::max(0, pos_next - n_swa);
// note: disallow with mtmd contexts for now
// https://github.com/ggml-org/llama.cpp/issues/17043
if (!mctx && n_past > 0 && n_past < slot.prompt.n_tokens()) {
if (n_past > 0 && n_past < slot.prompt.n_tokens()) {
const auto pos_min = llama_memory_seq_pos_min(llama_get_memory(ctx), slot.id);
if (pos_min == -1) {
SLT_ERR(slot, "n_past = %d, slot.prompt.tokens.size() = %d, seq_id = %d, pos_min = %d\n", n_past, (int) slot.prompt.tokens.size(), slot.id, pos_min);
@@ -2352,17 +2371,16 @@ private:
}
if (pos_min > pos_min_thold) {
// TODO: support can be added in the future when corresponding vision models get released
GGML_ASSERT(!slot.prompt.tokens.has_mtmd);
SLT_WRN(slot, "n_past = %d, slot.prompt.tokens.size() = %d, seq_id = %d, pos_min = %d, n_swa = %d\n", n_past, (int) slot.prompt.tokens.size(), slot.id, pos_min, n_swa);
// search for a context checkpoint
const auto it = std::find_if(
slot.prompt.checkpoints.rbegin(),
slot.prompt.checkpoints.rend(),
[&](const auto & cur) {
[&, func_name = __func__](const auto & cur) {
// guarantee that a checkpoint will result in at least one token being processed [TAG_PROMPT_LOGITS]
LOG_INF("slot %12.*s: id %2d | task %d | Checking checkpoint with [%d, %d] against %d...\n", 12,
func_name, (slot).id, ((slot).task ? (slot).task->id : -1), cur.pos_min, cur.pos_max, pos_min_thold);
return cur.pos_min < pos_min_thold;
}
);
@@ -2375,18 +2393,20 @@ private:
const size_t n = llama_state_seq_set_data_ext(ctx, it->data.data(), checkpoint_size, slot.id, LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
if (n != checkpoint_size) {
SLT_ERR(slot, "failed to restore context checkpoint (pos_min = %d, pos_max = %d, size = %.3f MiB)\n", it->pos_min, it->pos_max, (float) checkpoint_size / 1024 / 1024);
SLT_ERR(slot, "failed to restore context checkpoint (pos_min = %d, pos_max = %d, n_tokens = %" PRId64 ", size = %.3f MiB)\n", it->pos_min, it->pos_max, it->n_tokens, (float) checkpoint_size / 1024 / 1024);
do_reset = true;
//printf("[DEBUG] `do_reset` was set to `true` after failing to restore a checkpoint");
} else {
n_past = std::min(n_past, std::max(it->pos_min + 1, it->pos_max));
SLT_WRN(slot, "restored context checkpoint (pos_min = %d, pos_max = %d, size = %.3f MiB)\n", it->pos_min, it->pos_max, (float) checkpoint_size / 1024 / 1024);
pos_next = std::min(pos_next, std::max(it->pos_min + 1, it->pos_max));
n_past = std::min(slot.prompt.tokens.size_up_to_pos(pos_next), (size_t) it->n_tokens);
SLT_WRN(slot, "restored context checkpoint (pos_min = %d, pos_max = %d, n_tokens = %" PRId64 ", n_past = %d, size = %.3f MiB)\n", it->pos_min, it->pos_max, it->n_tokens, n_past, (float) checkpoint_size / 1024 / 1024);
}
}
if (do_reset) {
SLT_WRN(slot, "forcing full prompt re-processing due to lack of cache data (likely due to SWA or hybrid/recurrent memory, see %s)\n",
"https://github.com/ggml-org/llama.cpp/pull/13194#issuecomment-2868343055");
pos_next = 0;
n_past = 0;
}
}
@@ -2397,7 +2417,7 @@ private:
for (auto it = slot.prompt.checkpoints.begin(); it != slot.prompt.checkpoints.end();) {
const auto & cur = *it;
if (cur.pos_min > pos_min_thold) {
SLT_WRN(slot, "erased invalidated context checkpoint (pos_min = %d, pos_max = %d, n_swa = %d, size = %.3f MiB)\n", cur.pos_min, cur.pos_max, n_swa, (float) cur.data.size() / 1024 / 1024);
SLT_WRN(slot, "erased invalidated context checkpoint (pos_min = %d, pos_max = %d, n_tokens = %" PRId64 ", n_swa = %d, size = %.3f MiB)\n", cur.pos_min, cur.pos_max, cur.n_tokens, n_swa, (float) cur.data.size() / 1024 / 1024);
it = slot.prompt.checkpoints.erase(it);
} else {
++it;
@@ -2413,7 +2433,7 @@ private:
SLT_WRN(slot, "n_past was set to %d\n", n_past);
}
slot.n_prompt_tokens_cache = n_past;
slot.n_prompt_tokens_cache = n_past;
slot.n_prompt_tokens_processed = 0;
slot.prompt.tokens.keep_first(n_past);
@@ -2446,6 +2466,8 @@ private:
slot.n_prompt_tokens_cache = 0;
}
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
// check if we should process the image
if (slot.prompt.n_tokens() < slot.task->n_tokens() && input_tokens[slot.prompt.n_tokens()] == LLAMA_TOKEN_NULL) {
// process the image
@@ -2465,6 +2487,8 @@ private:
const auto & chunk = input_tokens.find_chunk(slot.prompt.n_tokens());
slot.prompt.tokens.push_back(chunk.get()); // copy
}
do_checkpoint = false; // do not checkpoint right after an image chunk
}
// If using an alora, there may be uncached tokens that come
@@ -2481,8 +2505,6 @@ private:
alora_disabled_id = enabled_loras[0];
}
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
// make checkpoints only for completion tasks
do_checkpoint = do_checkpoint && slot.task->type == SERVER_TASK_TYPE_COMPLETION;
@@ -2525,15 +2547,29 @@ private:
slot.n_prompt_tokens_processed++;
// process the last few tokens of the prompt separately in order to allow for a checkpoint to be created.
const int n_last = std::min(n_batch, 512);
if (do_checkpoint && slot.task->n_tokens() == slot.prompt.n_tokens() + n_last) {
break;
// create checkpoints that many tokens before the end of the prompt:
// - 4 + n_ubatch
// - 4
// ref: https://github.com/ggml-org/llama.cpp/pull/20288
{
static const int checkpoint_offsets[] = {4 + n_ubatch, 4};
bool should_break = false;
for (int offset : checkpoint_offsets) {
const int n_last = std::min(n_batch, offset);
if (do_checkpoint && slot.task->n_tokens() == slot.prompt.n_tokens() + n_last) {
should_break = true;
break;
}
}
if (should_break) {
break;
}
}
}
// SLT_INF(slot, "new slot.prompt.tokens: %s\n", slot.slot.prompt.tokens.str().c_str());
SLT_INF(slot, "prompt processing progress, n_tokens = %d, batch.n_tokens = %d, progress = %f\n", slot.prompt.n_tokens(), batch.n_tokens, (float) slot.prompt.n_tokens() / slot.task->n_tokens());
// the number of tokens added to the batch for the current slot
const auto n_tokens_cur = batch.n_tokens - n_tokens_prev;
// entire prompt has been processed
if (slot.prompt.n_tokens() == slot.task->n_tokens()) {
@@ -2547,43 +2583,75 @@ private:
slot.n_decoded = 0;
slot.i_batch = batch.n_tokens - 1;
SLT_INF(slot, "prompt done, n_tokens = %d, batch.n_tokens = %d\n", slot.prompt.n_tokens(), batch.n_tokens);
slot.init_sampler();
SLT_INF(slot, "prompt processing done, n_tokens = %d, batch.n_tokens = %d\n", slot.prompt.n_tokens(), batch.n_tokens);
} else {
if (slot.task->n_tokens() < slot.prompt.n_tokens() + n_ubatch) {
// near the end of the prompt
do_checkpoint = do_checkpoint && true;
} else {
// only do non-end checkpoints if the "checkpoint every n tokens" option is set
do_checkpoint = do_checkpoint && params_base.checkpoint_every_nt > 0;
const auto pos_min = llama_memory_seq_pos_min(llama_get_memory(ctx), slot.id);
const auto pos_max = llama_memory_seq_pos_max(llama_get_memory(ctx), slot.id);
if (do_checkpoint) {
llama_pos last_checkpoint = 0;
if (!slot.prompt.checkpoints.empty()) {
last_checkpoint = slot.prompt.checkpoints.back().n_tokens;
}
// no need for empty or small checkpoints
do_checkpoint = do_checkpoint && (pos_min >= 0 && pos_max >= 64);
do_checkpoint = do_checkpoint && slot.prompt.n_tokens() - batch.n_tokens - last_checkpoint >= params_base.checkpoint_every_nt;
// no need to create checkpoints that are too close together
do_checkpoint = do_checkpoint && (slot.prompt.checkpoints.empty() || pos_max > slot.prompt.checkpoints.back().pos_max + 64);
if (do_checkpoint) {
while (slot.prompt.checkpoints.size() >= (size_t) params_base.n_ctx_checkpoints) {
// make room for the new checkpoint, if needed
const auto & cur = slot.prompt.checkpoints.front();
SLT_WRN(slot, "erasing old context checkpoint (pos_min = %d, pos_max = %d, size = %.3f MiB)\n",
cur.pos_min, cur.pos_max, (float) cur.data.size() / 1024 / 1024);
slot.prompt.checkpoints.erase(slot.prompt.checkpoints.begin());
if (do_checkpoint) {
SLT_INF(slot, "%d tokens since last checkpoint at %d, creating new checkpoint during processing at position %d\n", params_base.checkpoint_every_nt, last_checkpoint, slot.prompt.n_tokens());
}
}
const size_t checkpoint_size = llama_state_seq_get_size_ext(ctx, slot.id, LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
auto & cur = slot.prompt.checkpoints.emplace_back(server_prompt_checkpoint{
/*.pos_min = */ pos_min,
/*.pos_max = */ pos_max,
/*.data = */ std::vector<uint8_t>(checkpoint_size),
});
llama_state_seq_get_data_ext(ctx, cur.data.data(), checkpoint_size, slot.id, LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
SLT_WRN(slot, "created context checkpoint %d of %d (pos_min = %d, pos_max = %d, size = %.3f MiB)\n",
(int) slot.prompt.checkpoints.size(), params_base.n_ctx_checkpoints, cur.pos_min, cur.pos_max, (float) cur.data.size() / 1024 / 1024);
}
SLT_INF(slot, "prompt processing progress, n_tokens = %d, batch.n_tokens = %d, progress = %f\n", slot.prompt.n_tokens(), batch.n_tokens, (float) slot.prompt.n_tokens() / slot.task->n_tokens());
}
const auto pos_min = llama_memory_seq_pos_min(llama_get_memory(ctx), slot.id);
const auto pos_max = llama_memory_seq_pos_max(llama_get_memory(ctx), slot.id);
// no need for empty or small checkpoints
do_checkpoint = do_checkpoint && (pos_min >= 0 && pos_max >= 64);
// no need to create checkpoints that are too close together
do_checkpoint = do_checkpoint && (slot.prompt.checkpoints.empty() || pos_max > slot.prompt.checkpoints.back().pos_max + 64);
// note: we create the checkpoint before calling llama_decode(), so the current batch is not
// yet processed and therefore it is not part of the checkpoint.
if (do_checkpoint) {
while (slot.prompt.checkpoints.size() >= (size_t) params_base.n_ctx_checkpoints) {
// make room for the new checkpoint, if needed
const auto & cur = slot.prompt.checkpoints.front();
SLT_WRN(slot,
"erasing old context checkpoint (pos_min = %d, pos_max = %d, n_tokens = %" PRId64
", size = %.3f MiB)\n",
cur.pos_min, cur.pos_max, cur.n_tokens, (float) cur.data.size() / 1024 / 1024);
slot.prompt.checkpoints.erase(slot.prompt.checkpoints.begin());
}
const size_t checkpoint_size =
llama_state_seq_get_size_ext(ctx, slot.id, LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
auto & cur = slot.prompt.checkpoints.emplace_back(server_prompt_checkpoint{
/*.pos_min = */ pos_min,
/*.pos_max = */ pos_max,
/*.n_tokens = */ slot.prompt.n_tokens() - n_tokens_cur,
/*.data = */ std::vector<uint8_t>(checkpoint_size),
});
llama_state_seq_get_data_ext(ctx, cur.data.data(), checkpoint_size, slot.id,
LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY);
SLT_WRN(slot,
"created context checkpoint %d of %d (pos_min = %d, pos_max = %d, n_tokens = %" PRId64
", size = %.3f MiB)\n",
(int) slot.prompt.checkpoints.size(), params_base.n_ctx_checkpoints, cur.pos_min,
cur.pos_max, cur.n_tokens, (float) cur.data.size() / 1024 / 1024);
}
}
@@ -2615,6 +2683,12 @@ private:
if (batch.n_tokens == 0) {
SRV_WRN("%s", "no tokens to decode\n");
if (++n_empty_consecutive > 3) {
GGML_ABORT("fatal error - please provide logs and repro in %s\n", "https://github.com/ggml-org/llama.cpp/pull/20277");
}
} else {
n_empty_consecutive = 0;
}
int32_t i_next = 0;
@@ -2906,6 +2980,8 @@ server_context_meta server_context::get_meta() const {
return server_context_meta {
/* build_info */ build_info,
/* model_name */ impl->model_name,
/* model_aliases */ impl->model_aliases,
/* model_tags */ impl->model_tags,
/* model_path */ impl->params_base.model.path,
/* has_mtmd */ impl->mctx != nullptr,
/* has_inp_image */ impl->chat_params.allow_image,
@@ -3702,6 +3778,8 @@ void server_routes::init_routes() {
{"data", {
{
{"id", meta->model_name},
{"aliases", meta->model_aliases},
{"tags", meta->model_tags},
{"object", "model"},
{"created", std::time(0)},
{"owned_by", "llamacpp"},
+5
View File
@@ -1,3 +1,5 @@
#pragma once
#include "server-http.h"
#include "server-task.h"
#include "server-queue.h"
@@ -6,12 +8,15 @@
#include <cstddef>
#include <memory>
#include <set>
struct server_context_impl; // private implementation
struct server_context_meta {
std::string build_info;
std::string model_name;
std::set<std::string> model_aliases;
std::set<std::string> model_tags;
std::string model_path;
bool has_mtmd;
bool has_inp_image;
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "common.h"
#include "http.h"
#include <string>
#include <unordered_set>
#include <list>
#include <map>
#include "server-http.h"
static server_http_res_ptr proxy_request(const server_http_req & req, std::string method) {
std::string target_url = req.get_param("url");
common_http_url parsed_url = common_http_parse_url(target_url);
if (parsed_url.host.empty()) {
throw std::runtime_error("invalid target URL: missing host");
}
if (parsed_url.path.empty()) {
parsed_url.path = "/";
}
if (!parsed_url.password.empty()) {
throw std::runtime_error("authentication in target URL is not supported");
}
if (parsed_url.scheme != "http" && parsed_url.scheme != "https") {
throw std::runtime_error("unsupported URL scheme in target URL: " + parsed_url.scheme);
}
SRV_INF("proxying %s request to %s://%s:%i%s\n", method.c_str(), parsed_url.scheme.c_str(), parsed_url.host.c_str(), parsed_url.port, parsed_url.path.c_str());
auto proxy = std::make_unique<server_http_proxy>(
method,
parsed_url.scheme,
parsed_url.host,
parsed_url.port,
parsed_url.path,
req.headers,
req.body,
req.should_stop,
600, // timeout_read (default to 10 minutes)
600 // timeout_write (default to 10 minutes)
);
return proxy;
}
static server_http_context::handler_t proxy_handler_post = [](const server_http_req & req) -> server_http_res_ptr {
return proxy_request(req, "POST");
};
static server_http_context::handler_t proxy_handler_get = [](const server_http_req & req) -> server_http_res_ptr {
return proxy_request(req, "GET");
};
+13
View File
@@ -339,6 +339,17 @@ static std::map<std::string, std::string> get_headers(const httplib::Request & r
return headers;
}
static std::string build_query_string(const httplib::Request & req) {
std::string qs;
for (const auto & [key, value] : req.params) {
if (!qs.empty()) {
qs += '&';
}
qs += httplib::encode_query_component(key) + "=" + httplib::encode_query_component(value);
}
return qs;
}
// using unique_ptr for request to allow safe capturing in lambdas
using server_http_req_ptr = std::unique_ptr<server_http_req>;
@@ -382,6 +393,7 @@ void server_http_context::get(const std::string & path, const server_http_contex
get_params(req),
get_headers(req),
req.path,
build_query_string(req),
req.body,
req.is_connection_closed
});
@@ -396,6 +408,7 @@ void server_http_context::post(const std::string & path, const server_http_conte
get_params(req),
get_headers(req),
req.path,
build_query_string(req),
req.body,
req.is_connection_closed
});
+2 -1
View File
@@ -36,7 +36,8 @@ using server_http_res_ptr = std::unique_ptr<server_http_res>;
struct server_http_req {
std::map<std::string, std::string> params; // path_params + query_params
std::map<std::string, std::string> headers; // reserved for future use
std::string path; // reserved for future use
std::string path;
std::string query_string; // query parameters string (e.g. "action=save")
std::string body;
const std::function<bool()> & should_stop;
+121 -13
View File
@@ -184,6 +184,51 @@ void server_models::add_model(server_model_meta && meta) {
if (mapping.find(meta.name) != mapping.end()) {
throw std::runtime_error(string_format("model '%s' appears multiple times", meta.name.c_str()));
}
// check model name does not conflict with existing aliases
for (const auto & [key, inst] : mapping) {
if (inst.meta.aliases.count(meta.name)) {
throw std::runtime_error(string_format("model name '%s' conflicts with alias of model '%s'",
meta.name.c_str(), key.c_str()));
}
}
// parse aliases from preset's --alias option (comma-separated)
std::string alias_str;
if (meta.preset.get_option("LLAMA_ARG_ALIAS", alias_str) && !alias_str.empty()) {
for (auto & alias : string_split<std::string>(alias_str, ',')) {
alias = string_strip(alias);
if (!alias.empty()) {
meta.aliases.insert(alias);
}
}
}
// parse tags from preset's --tags option (comma-separated)
std::string tags_str;
if (meta.preset.get_option("LLAMA_ARG_TAGS", tags_str) && !tags_str.empty()) {
for (auto & tag : string_split<std::string>(tags_str, ',')) {
tag = string_strip(tag);
if (!tag.empty()) {
meta.tags.insert(tag);
}
}
}
// validate aliases do not conflict with existing names or aliases
for (const auto & alias : meta.aliases) {
if (mapping.find(alias) != mapping.end()) {
throw std::runtime_error(string_format("alias '%s' for model '%s' conflicts with existing model name",
alias.c_str(), meta.name.c_str()));
}
for (const auto & [key, inst] : mapping) {
if (inst.meta.aliases.count(alias)) {
throw std::runtime_error(string_format("alias '%s' for model '%s' conflicts with alias of model '%s'",
alias.c_str(), meta.name.c_str(), key.c_str()));
}
}
}
meta.update_args(ctx_preset, bin_path); // render args
std::string name = meta.name;
mapping[name] = instance_t{
@@ -249,6 +294,8 @@ void server_models::load_models() {
server_model_meta meta{
/* preset */ preset.second,
/* name */ preset.first,
/* aliases */ {},
/* tags */ {},
/* port */ 0,
/* status */ SERVER_MODEL_STATUS_UNLOADED,
/* last_used */ 0,
@@ -265,10 +312,28 @@ void server_models::load_models() {
for (const auto & [name, preset] : custom_presets) {
custom_names.insert(name);
}
auto join_set = [](const std::set<std::string> & s) {
std::string result;
for (const auto & v : s) {
if (!result.empty()) {
result += ", ";
}
result += v;
}
return result;
};
SRV_INF("Available models (%zu) (*: custom preset)\n", mapping.size());
for (const auto & [name, inst] : mapping) {
bool has_custom = custom_names.find(name) != custom_names.end();
SRV_INF(" %c %s\n", has_custom ? '*' : ' ', name.c_str());
std::string info;
if (!inst.meta.aliases.empty()) {
info += " (aliases: " + join_set(inst.meta.aliases) + ")";
}
if (!inst.meta.tags.empty()) {
info += " [tags: " + join_set(inst.meta.tags) + "]";
}
SRV_INF(" %c %s%s\n", has_custom ? '*' : ' ', name.c_str(), info.c_str());
}
}
@@ -291,7 +356,9 @@ void server_models::load_models() {
for (const auto & [name, inst] : mapping) {
std::string val;
if (inst.meta.preset.get_option(COMMON_ARG_PRESET_LOAD_ON_STARTUP, val)) {
models_to_load.push_back(name);
if (common_arg_utils::is_truthy(val)) {
models_to_load.push_back(name);
}
}
}
if ((int)models_to_load.size() > base_params.models_max) {
@@ -318,7 +385,15 @@ void server_models::update_meta(const std::string & name, const server_model_met
bool server_models::has_model(const std::string & name) {
std::lock_guard<std::mutex> lk(mutex);
return mapping.find(name) != mapping.end();
if (mapping.find(name) != mapping.end()) {
return true;
}
for (const auto & [key, inst] : mapping) {
if (inst.meta.aliases.count(name)) {
return true;
}
}
return false;
}
std::optional<server_model_meta> server_models::get_meta(const std::string & name) {
@@ -327,6 +402,11 @@ std::optional<server_model_meta> server_models::get_meta(const std::string & nam
if (it != mapping.end()) {
return it->second.meta;
}
for (const auto & [key, inst] : mapping) {
if (inst.meta.aliases.count(name)) {
return inst.meta;
}
}
return std::nullopt;
}
@@ -697,11 +777,16 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
mapping[name].meta.last_used = ggml_time_ms();
}
SRV_INF("proxying request to model %s on port %d\n", name.c_str(), meta->port);
std::string proxy_path = req.path;
if (!req.query_string.empty()) {
proxy_path += '?' + req.query_string;
}
auto proxy = std::make_unique<server_http_proxy>(
method,
"http",
CHILD_ADDR,
meta->port,
req.path,
proxy_path,
req.headers,
req.body,
req.should_stop,
@@ -760,7 +845,7 @@ static void res_err(std::unique_ptr<server_http_res> & res, const json & error_d
res->data = safe_json_to_str({{ "error", error_data }});
}
static bool router_validate_model(const std::string & name, server_models & models, bool models_autoload, std::unique_ptr<server_http_res> & res) {
static bool router_validate_model(std::string & name, server_models & models, bool models_autoload, std::unique_ptr<server_http_res> & res) {
if (name.empty()) {
res_err(res, format_error_response("model name is missing from the request", ERROR_TYPE_INVALID_REQUEST));
return false;
@@ -770,6 +855,8 @@ static bool router_validate_model(const std::string & name, server_models & mode
res_err(res, format_error_response(string_format("model '%s' not found", name.c_str()), ERROR_TYPE_INVALID_REQUEST));
return false;
}
// resolve alias to canonical model name
name = meta->name;
if (models_autoload) {
models.ensure_model_loaded(name);
} else {
@@ -841,16 +928,16 @@ void server_models_routes::init_routes() {
auto res = std::make_unique<server_http_res>();
json body = json::parse(req.body);
std::string name = json_value(body, "model", std::string());
auto model = models.get_meta(name);
if (!model.has_value()) {
auto meta = models.get_meta(name);
if (!meta.has_value()) {
res_err(res, format_error_response("model is not found", ERROR_TYPE_NOT_FOUND));
return res;
}
if (model->status == SERVER_MODEL_STATUS_LOADED) {
if (meta->status == SERVER_MODEL_STATUS_LOADED) {
res_err(res, format_error_response("model is already loaded", ERROR_TYPE_INVALID_REQUEST));
return res;
}
models.load(name);
models.load(meta->name);
res_ok(res, {{"success", true}});
return res;
};
@@ -871,6 +958,7 @@ void server_models_routes::init_routes() {
preset_copy.unset_option("LLAMA_ARG_HOST");
preset_copy.unset_option("LLAMA_ARG_PORT");
preset_copy.unset_option("LLAMA_ARG_ALIAS");
preset_copy.unset_option("LLAMA_ARG_TAGS");
status["preset"] = preset_copy.to_ini();
}
if (meta.is_failed()) {
@@ -879,6 +967,8 @@ void server_models_routes::init_routes() {
}
models_json.push_back(json {
{"id", meta.name},
{"aliases", meta.aliases},
{"tags", meta.tags},
{"object", "model"}, // for OAI-compat
{"owned_by", "llamacpp"}, // for OAI-compat
{"created", t}, // for OAI-compat
@@ -906,7 +996,7 @@ void server_models_routes::init_routes() {
res_err(res, format_error_response("model is not loaded", ERROR_TYPE_INVALID_REQUEST));
return res;
}
models.unload(name);
models.unload(model->name);
res_ok(res, {{"success", true}});
return res;
};
@@ -990,6 +1080,7 @@ static bool should_strip_proxy_header(const std::string & header_name) {
server_http_proxy::server_http_proxy(
const std::string & method,
const std::string & scheme,
const std::string & host,
int port,
const std::string & path,
@@ -1000,11 +1091,20 @@ server_http_proxy::server_http_proxy(
int32_t timeout_write
) {
// shared between reader and writer threads
auto cli = std::make_shared<httplib::Client>(host, port);
auto cli = std::make_shared<httplib::ClientImpl>(host, port);
auto pipe = std::make_shared<pipe_t<msg_t>>();
if (scheme == "https") {
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
cli.reset(new httplib::SSLClient(host, port));
#else
throw std::runtime_error("HTTPS requested but CPPHTTPLIB_OPENSSL_SUPPORT is not defined");
#endif
}
// setup Client
cli->set_connection_timeout(0, 200000); // 200 milliseconds
cli->set_follow_location(true);
cli->set_connection_timeout(5, 0); // 5 seconds
cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
cli->set_read_timeout(timeout_write, 0);
this->status = 500; // to be overwritten upon response
@@ -1053,7 +1153,15 @@ server_http_proxy::server_http_proxy(
req.method = method;
req.path = path;
for (const auto & [key, value] : headers) {
req.set_header(key, value);
if (key == "Accept-Encoding") {
// disable Accept-Encoding to avoid compressed responses
continue;
}
if (key == "Host" || key == "host") {
req.set_header(key, host);
} else {
req.set_header(key, value);
}
}
req.body = body;
req.response_handler = response_handler;
+3
View File
@@ -52,6 +52,8 @@ static std::string server_model_status_to_string(server_model_status status) {
struct server_model_meta {
common_preset preset;
std::string name;
std::set<std::string> aliases; // additional names that resolve to this model
std::set<std::string> tags; // informational tags, not used for routing
int port = 0;
server_model_status status = SERVER_MODEL_STATUS_UNLOADED;
int64_t last_used = 0; // for LRU unloading
@@ -178,6 +180,7 @@ struct server_http_proxy : server_http_res {
std::function<void()> cleanup = nullptr;
public:
server_http_proxy(const std::string & method,
const std::string & scheme,
const std::string & host,
int port,
const std::string & path,
+97 -11
View File
@@ -1,12 +1,12 @@
#include "server-common.h"
#include "server-task.h"
#include "common.h"
#include "llama.h"
#include "chat.h"
#include "common.h"
#include "json-schema-to-grammar.h"
#include "llama.h"
#include "sampling.h"
#include "speculative.h"
#include "json-schema-to-grammar.h"
#include "server-common.h"
using json = nlohmann::ordered_json;
@@ -157,7 +157,8 @@ json task_params::to_json(bool only_metrics) const {
common_chat_msg task_result_state::update_chat_msg(
const std::string & text_added,
bool is_partial,
std::vector<common_chat_msg_diff> & diffs) {
std::vector<common_chat_msg_diff> & diffs,
bool filter_tool_calls) {
generated_text += text_added;
auto msg_prv_copy = chat_msg;
SRV_DBG("Parsing chat message: %s\n", generated_text.c_str());
@@ -168,7 +169,64 @@ common_chat_msg task_result_state::update_chat_msg(
if (!new_msg.empty()) {
new_msg.set_tool_call_ids(generated_tool_call_ids, gen_tool_call_id);
chat_msg = new_msg;
diffs = common_chat_msg_diff::compute_diffs(msg_prv_copy, new_msg.empty() ? msg_prv_copy : new_msg);
auto all_diffs = common_chat_msg_diff::compute_diffs(msg_prv_copy, chat_msg);
if (!filter_tool_calls) {
diffs = std::move(all_diffs);
} else {
for (auto & d : all_diffs) {
// If this is a new type of delta, flush all currently pending tool call names
for (size_t i = 0; i < chat_msg.tool_calls.size(); ++i) {
if (sent_tool_call_names.count(i) || chat_msg.tool_calls[i].name.empty()) {
continue;
}
if (d.tool_call_index != i || !d.tool_call_delta.arguments.empty()) {
common_chat_msg_diff header;
header.tool_call_index = i;
header.tool_call_delta.id = chat_msg.tool_calls[i].id;
header.tool_call_delta.name = chat_msg.tool_calls[i].name;
diffs.push_back(std::move(header));
sent_tool_call_names.insert(i);
}
}
if (d.tool_call_index == std::string::npos) {
diffs.push_back(std::move(d));
} else {
size_t i = d.tool_call_index;
if (sent_tool_call_names.count(i)) {
if (!d.tool_call_delta.arguments.empty()) {
d.tool_call_delta.name = "";
d.tool_call_delta.id = "";
diffs.push_back(std::move(d));
}
} else {
// Not sent yet.
if (!d.tool_call_delta.arguments.empty() || !is_partial) {
d.tool_call_delta.name = chat_msg.tool_calls[i].name;
d.tool_call_delta.id = chat_msg.tool_calls[i].id;
diffs.push_back(std::move(d));
sent_tool_call_names.insert(i);
} else {
// Suppress
}
}
}
}
// Final check at EOF
if (!is_partial) {
for (size_t i = 0; i < chat_msg.tool_calls.size(); ++i) {
if (!sent_tool_call_names.count(i) && !chat_msg.tool_calls[i].name.empty()) {
common_chat_msg_diff header;
header.tool_call_index = i;
header.tool_call_delta.id = chat_msg.tool_calls[i].id;
header.tool_call_delta.name = chat_msg.tool_calls[i].name;
diffs.push_back(std::move(header));
sent_tool_call_names.insert(i);
}
}
}
}
}
return chat_msg;
}
@@ -204,7 +262,8 @@ task_params server_task::params_from_json_cmpl(
params.cache_prompt = json_value(data, "cache_prompt", defaults.cache_prompt);
params.return_tokens = json_value(data, "return_tokens", false);
params.return_progress = json_value(data, "return_progress", false);
params.n_predict = json_value(data, "n_predict", json_value(data, "max_tokens", defaults.n_predict));
auto max_tokens = json_value(data, "max_tokens", defaults.n_predict);
params.n_predict = json_value(data, "n_predict", json_value(data, "max_completion_tokens", max_tokens));
params.n_indent = json_value(data, "n_indent", defaults.n_indent);
params.n_keep = json_value(data, "n_keep", defaults.n_keep);
params.n_discard = json_value(data, "n_discard", defaults.n_discard);
@@ -403,6 +462,34 @@ task_params server_task::params_from_json_cmpl(
}
}
// Parse reasoning budget sampler parameters
{
const int32_t budget = json_value(data, "reasoning_budget_tokens", (int32_t) -1);
if (budget >= 0) {
const auto start_tag = json_value(data, "reasoning_budget_start_tag", std::string());
const auto end_tag = json_value(data, "reasoning_budget_end_tag", std::string());
const auto message = json_value(data, "reasoning_budget_message", std::string());
const bool activate_imm = json_value(data, "reasoning_budget_activate_immediately", false);
params.sampling.reasoning_budget_tokens = budget;
params.sampling.reasoning_budget_activate_immediately = activate_imm;
if (!start_tag.empty()) {
params.sampling.reasoning_budget_start = common_tokenize(vocab, start_tag, false, true);
}
if (!end_tag.empty()) {
params.sampling.reasoning_budget_end = common_tokenize(vocab, end_tag, false, true);
params.sampling.reasoning_budget_forced = common_tokenize(vocab, message + end_tag, false, true);
}
SRV_DBG("reasoning budget: tokens=%d, activate_immediately=%s, start=%zu toks, end=%zu toks, forced=%zu toks\n",
budget, activate_imm ? "true" : "false",
params.sampling.reasoning_budget_start.size(),
params.sampling.reasoning_budget_end.size(),
params.sampling.reasoning_budget_forced.size());
}
}
{
params.sampling.logit_bias.clear();
@@ -768,7 +855,7 @@ json server_task_result_cmpl_final::to_json_oaicompat_chat_stream() {
{"choices", json::array({
json {
{"finish_reason", nullptr},
{"index", 0},
{"index", index},
{"delta", common_chat_msg_diff_to_json_oaicompat(diff)},
},
})},
@@ -784,7 +871,7 @@ json server_task_result_cmpl_final::to_json_oaicompat_chat_stream() {
{"choices", json::array({
json {
{"finish_reason", finish_reason},
{"index", 0},
{"index", index},
{"delta", json::object()},
},
})},
@@ -1899,10 +1986,9 @@ server_prompt * server_prompt_cache::alloc(const server_prompt & prompt, size_t
return nullptr;
}
// TODO: for some reason we can't copy server_tokens, so we have to do this workaround
auto & cur = states.emplace_back();
cur = {
/*.tokens =*/ server_tokens(prompt.tokens.get_text_tokens(), false),
/*.tokens =*/ prompt.tokens.clone(),
/*.data =*/ std::move(state_data),
/*.checkpoints =*/ prompt.checkpoints,
};
+5 -1
View File
@@ -98,6 +98,7 @@ struct task_result_state {
common_chat_msg chat_msg;
std::string generated_text; // append new chunks of generated text here
std::vector<std::string> generated_tool_call_ids;
std::unordered_set<size_t> sent_tool_call_names;
// for OpenAI Responses and Anthropic streaming API:
// track output item / content block state across chunks
@@ -120,7 +121,8 @@ struct task_result_state {
common_chat_msg update_chat_msg(
const std::string & text_added,
bool is_partial,
std::vector<common_chat_msg_diff> & diffs);
std::vector<common_chat_msg_diff> & diffs,
bool filter_tool_calls = false);
};
struct server_task {
@@ -557,6 +559,8 @@ struct server_prompt_checkpoint {
llama_pos pos_min;
llama_pos pos_max;
int64_t n_tokens;
std::vector<uint8_t> data;
size_t size() const {
+15 -1
View File
@@ -1,6 +1,7 @@
#include "server-context.h"
#include "server-http.h"
#include "server-models.h"
#include "server-cors-proxy.h"
#include "arg.h"
#include "common.h"
@@ -8,6 +9,7 @@
#include "log.h"
#include <atomic>
#include <clocale>
#include <exception>
#include <signal.h>
#include <thread> // for std::thread::hardware_concurrency
@@ -67,6 +69,8 @@ static server_http_context::handler_t ex_wrapper(server_http_context::handler_t
}
int main(int argc, char ** argv) {
std::setlocale(LC_NUMERIC, "C");
// own arguments required by this example
common_params params;
@@ -92,7 +96,7 @@ int main(int argc, char ** argv) {
// for consistency between server router mode and single-model mode, we set the same model name as alias
if (params.model_alias.empty() && !params.model.name.empty()) {
params.model_alias = params.model.name;
params.model_alias.insert(params.model.name);
}
common_init();
@@ -178,6 +182,7 @@ int main(int argc, char ** argv) {
ctx_http.post("/v1/chat/completions", ex_wrapper(routes.post_chat_completions));
ctx_http.post("/api/chat", ex_wrapper(routes.post_chat_completions)); // ollama specific endpoint
ctx_http.post("/v1/responses", ex_wrapper(routes.post_responses_oai));
ctx_http.post("/responses", ex_wrapper(routes.post_responses_oai));
ctx_http.post("/v1/messages", ex_wrapper(routes.post_anthropic_messages)); // anthropic messages API
ctx_http.post("/v1/messages/count_tokens", ex_wrapper(routes.post_anthropic_count_tokens)); // anthropic token counting
ctx_http.post("/infill", ex_wrapper(routes.post_infill));
@@ -197,6 +202,15 @@ int main(int argc, char ** argv) {
// Save & load slots
ctx_http.get ("/slots", ex_wrapper(routes.get_slots));
ctx_http.post("/slots/:id_slot", ex_wrapper(routes.post_slots));
// CORS proxy (EXPERIMENTAL, only used by the Web UI for MCP)
if (params.webui_mcp_proxy) {
SRV_WRN("%s", "-----------------\n");
SRV_WRN("%s", "CORS proxy is enabled, do not expose server to untrusted environments\n");
SRV_WRN("%s", "This feature is EXPERIMENTAL and may be removed or changed in future versions\n");
SRV_WRN("%s", "-----------------\n");
ctx_http.get ("/cors-proxy", ex_wrapper(proxy_handler_get));
ctx_http.post("/cors-proxy", ex_wrapper(proxy_handler_post));
}
//
// Start the server
+1 -1
View File
@@ -57,7 +57,7 @@ To run a single test:
./tests.sh unit/test_chat_completion.py::test_invalid_chat_completion_req
```
Hint: You can compile and run test in single command, useful for local developement:
Hint: You can compile and run test in single command, useful for local development:
```shell
cmake --build build -j --target llama-server && ./tools/server/tests/tests.sh
+17
View File
@@ -94,3 +94,20 @@ def test_no_webui():
server.start()
res = requests.get(url)
assert res.status_code == 404
def test_server_model_aliases_and_tags():
global server
server.model_alias = "tinyllama-2,fim,code"
server.model_tags = "chat,fim,small"
server.start()
res = server.make_request("GET", "/models")
assert res.status_code == 200
assert len(res.body["data"]) == 1
model = res.body["data"][0]
# aliases field must contain all aliases
assert set(model["aliases"]) == {"tinyllama-2", "fim", "code"}
# tags field must contain all tags
assert set(model["tags"]) == {"chat", "fim", "small"}
# id is derived from first alias (alphabetical order from std::set)
assert model["id"] == "code"
@@ -809,6 +809,139 @@ def test_anthropic_vs_openai_different_response_format():
# Extended thinking tests with reasoning models
# The next two tests cover the input path (conversation history):
# Client sends thinking blocks -> convert_anthropic_to_oai -> reasoning_content -> template
def test_anthropic_thinking_history_in_count_tokens():
"""Test that interleaved thinking blocks in conversation history are not dropped during conversion."""
global server
server.jinja = True
server.chat_template_file = '../../../models/templates/Qwen-Qwen3-0.6B.jinja'
server.start()
tool = {
"name": "list_files",
"description": "List files",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}
}
messages_without_thinking = [
{"role": "user", "content": "Fix the bug"},
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_1", "name": "list_files", "input": {"path": "."}}
]
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": "main.py"}
]
},
]
messages_with_thinking = [
{"role": "user", "content": "Fix the bug"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "I should check the project structure first to understand the codebase layout."},
{"type": "tool_use", "id": "call_1", "name": "list_files", "input": {"path": "."}}
]
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": "main.py"}
]
},
]
res_without = server.make_request("POST", "/v1/messages/count_tokens", data={
"model": "test",
"messages": messages_without_thinking,
"tools": [tool],
})
assert res_without.status_code == 200, f"Expected 200: {res_without.body}"
res_with = server.make_request("POST", "/v1/messages/count_tokens", data={
"model": "test",
"messages": messages_with_thinking,
"tools": [tool],
})
assert res_with.status_code == 200, f"Expected 200: {res_with.body}"
# Thinking blocks should increase the token count
assert res_with.body["input_tokens"] > res_without.body["input_tokens"], \
f"Expected more tokens with thinking ({res_with.body['input_tokens']}) than without ({res_without.body['input_tokens']})"
def test_anthropic_thinking_history_in_template():
"""Test that reasoning_content from converted interleaved thinking blocks renders in the prompt."""
global server
server.jinja = True
server.chat_template_file = '../../../models/templates/Qwen-Qwen3-0.6B.jinja'
server.start()
reasoning_1 = "I should check the project structure first."
reasoning_2 = "Now I need to read the main file."
res = server.make_request("POST", "/apply-template", data={
"messages": [
{"role": "user", "content": "Fix the bug in main.py"},
{
"role": "assistant",
"content": "",
"reasoning_content": reasoning_1,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "list_files", "arguments": "{\"path\": \".\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_1", "content": "main.py\nutils.py"},
{
"role": "assistant",
"content": "",
"reasoning_content": reasoning_2,
"tool_calls": [{
"id": "call_2",
"type": "function",
"function": {"name": "read_file", "arguments": "{\"path\": \"main.py\"}"}
}]
},
{"role": "tool", "tool_call_id": "call_2", "content": "print('hello')"},
],
"tools": [{
"type": "function",
"function": {
"name": "list_files",
"description": "List files",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
}
}, {
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
}
}],
})
assert res.status_code == 200, f"Expected 200, got {res.status_code}: {res.body}"
prompt = res.body["prompt"]
# Both reasoning_content values should be rendered in <think> tags
assert reasoning_1 in prompt, f"Expected first reasoning text in prompt: {prompt}"
assert reasoning_2 in prompt, f"Expected second reasoning text in prompt: {prompt}"
assert prompt.count("<think>") >= 2, f"Expected at least 2 <think> blocks in prompt: {prompt}"
@pytest.mark.slow
@pytest.mark.parametrize("stream", [False, True])
def test_anthropic_thinking_with_reasoning_model(stream):
+1 -1
View File
@@ -563,7 +563,7 @@ def test_cancel_request():
except requests.exceptions.ReadTimeout:
pass # expected
# make sure the slot is free
time.sleep(1) # wait for HTTP_POLLING_SECONDS
time.sleep(2)
res = server.make_request("GET", "/slots")
assert res.body[0]["is_processing"] == False
+34
View File
@@ -101,6 +101,40 @@ def test_embedding_mixed_input(input, is_multi_prompt: bool):
assert len(data[0]['embedding']) > 1
def test_embedding_pooling_mean():
global server
server.pooling = 'mean'
server.start()
res = server.make_request("POST", "/v1/embeddings", data={
"input": "I believe the meaning of life is",
})
assert res.status_code == 200
assert len(res.body['data']) == 1
assert 'embedding' in res.body['data'][0]
assert len(res.body['data'][0]['embedding']) > 1
# make sure embedding vector is normalized
assert abs(sum([x ** 2 for x in res.body['data'][0]['embedding']]) - 1) < EPSILON
def test_embedding_pooling_mean_multiple():
global server
server.pooling = 'mean'
server.start()
res = server.make_request("POST", "/v1/embeddings", data={
"input": [
"I believe the meaning of life is",
"Write a joke about AI",
"This is a test",
],
})
assert res.status_code == 200
assert len(res.body['data']) == 3
for d in res.body['data']:
assert 'embedding' in d
assert len(d['embedding']) > 1
def test_embedding_pooling_none():
global server
server.pooling = 'none'
+41
View File
@@ -0,0 +1,41 @@
import pytest
from utils import *
server = ServerPreset.tinyllama2()
@pytest.fixture(autouse=True)
def create_server():
global server
server = ServerPreset.tinyllama2()
def test_mcp_no_proxy():
global server
server.webui_mcp_proxy = False
server.start()
res = server.make_request("GET", "/cors-proxy")
assert res.status_code == 404
def test_mcp_proxy():
global server
server.webui_mcp_proxy = True
server.start()
url = f"http://{server.server_host}:{server.server_port}/cors-proxy?url=http://example.com"
res = requests.get(url)
assert res.status_code == 200
assert "Example Domain" in res.text
def test_mcp_proxy_custom_port():
global server
server.webui_mcp_proxy = True
server.start()
# try getting the server's models API via the proxy
res = server.make_request("GET", f"/cors-proxy?url=http://{server.server_host}:{server.server_port}/models")
assert res.status_code == 200
assert "data" in res.body
+13 -12
View File
@@ -11,6 +11,7 @@ sys.path.insert(0, str(path))
import datetime
from utils import *
from typing import Literal
server: ServerProcess
@@ -23,24 +24,24 @@ def create_server():
@pytest.mark.parametrize("tools", [None, [], [TEST_TOOL]])
@pytest.mark.parametrize("template_name,reasoning_budget,expected_end", [
("deepseek-ai-DeepSeek-R1-Distill-Qwen-32B", None, "<think>\n"),
("deepseek-ai-DeepSeek-R1-Distill-Qwen-32B", -1, "<think>\n"),
("deepseek-ai-DeepSeek-R1-Distill-Qwen-32B", 0, "<think>\n</think>"),
@pytest.mark.parametrize("template_name,reasoning,expected_end", [
("deepseek-ai-DeepSeek-R1-Distill-Qwen-32B", "on", "<think>\n"),
("deepseek-ai-DeepSeek-R1-Distill-Qwen-32B","auto", "<think>\n"),
("deepseek-ai-DeepSeek-R1-Distill-Qwen-32B", "off", "<think>\n</think>"),
("Qwen-Qwen3-0.6B", -1, "<|im_start|>assistant\n"),
("Qwen-Qwen3-0.6B", 0, "<|im_start|>assistant\n<think>\n\n</think>\n\n"),
("Qwen-Qwen3-0.6B","auto", "<|im_start|>assistant\n"),
("Qwen-Qwen3-0.6B", "off", "<|im_start|>assistant\n<think>\n\n</think>\n\n"),
("Qwen-QwQ-32B", -1, "<|im_start|>assistant\n<think>\n"),
("Qwen-QwQ-32B", 0, "<|im_start|>assistant\n<think>\n</think>"),
("Qwen-QwQ-32B","auto", "<|im_start|>assistant\n<think>\n"),
("Qwen-QwQ-32B", "off", "<|im_start|>assistant\n<think>\n</think>"),
("CohereForAI-c4ai-command-r7b-12-2024-tool_use", -1, "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>"),
("CohereForAI-c4ai-command-r7b-12-2024-tool_use", 0, "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_THINKING|><|END_THINKING|>"),
("CohereForAI-c4ai-command-r7b-12-2024-tool_use","auto", "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>"),
("CohereForAI-c4ai-command-r7b-12-2024-tool_use", "off", "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_THINKING|><|END_THINKING|>"),
])
def test_reasoning_budget(template_name: str, reasoning_budget: int | None, expected_end: str, tools: list[dict]):
def test_reasoning(template_name: str, reasoning: Literal['on', 'off', 'auto'] | None, expected_end: str, tools: list[dict]):
global server
server.jinja = True
server.reasoning_budget = reasoning_budget
server.reasoning = reasoning
server.chat_template_file = f'../../../models/templates/{template_name}.jinja'
server.start()
+7 -6
View File
@@ -100,18 +100,19 @@ def do_test_completion_with_required_tool_tiny(server: ServerProcess, tool: dict
assert choice["message"].get("content") in (None, ""), f'Expected no content in {choice["message"]}'
# assert len(tool_call.get("id", "")) > 0, f'Expected non empty tool call id in {tool_call}'
expected_function_name = "python" if tool["type"] == "code_interpreter" else tool["function"]["name"]
assert expected_function_name == tool_call["function"]["name"]
assert expected_function_name == tool_call["function"]["name"], f'Expected tool name to be {tool_call["function"]["name"]} in {choice["message"]}'
actual_arguments = tool_call["function"]["arguments"]
assert isinstance(actual_arguments, str)
assert isinstance(actual_arguments, dict) or isinstance(actual_arguments, str), f'Expected arguments to be a dict or str, got: {actual_arguments}'
if argument_key is not None:
actual_arguments = json.loads(actual_arguments)
assert argument_key in actual_arguments, f"tool arguments: {json.dumps(actual_arguments)}, expected: {argument_key}"
if (isinstance(actual_arguments, str)):
actual_arguments = json.loads(actual_arguments)
assert argument_key in actual_arguments, f"tool arguments: {actual_arguments}, expected: {argument_key}"
@pytest.mark.parametrize("stream", [CompletionMode.NORMAL, CompletionMode.STREAMED])
@pytest.mark.parametrize("template_name,tool,argument_key", [
("google-gemma-2-2b-it", TEST_TOOL, "success"),
("google-gemma-2-2b-it", TEST_TOOL, "success"),
("Qwen3-Coder", TEST_TOOL, "success"),
("Qwen3-Coder", TEST_TOOL, "success"),
("meta-llama-Llama-3.3-70B-Instruct", TEST_TOOL, "success"),
("meta-llama-Llama-3.3-70B-Instruct", TEST_TOOL, "success"),
("meta-llama-Llama-3.3-70B-Instruct", PYTHON_TOOL, "code"),
+9 -3
View File
@@ -56,6 +56,7 @@ class ServerProcess:
# custom options
model_alias: str | None = None
model_tags: str | None = None
model_url: str | None = None
model_file: str | None = None
model_draft: str | None = None
@@ -94,13 +95,14 @@ class ServerProcess:
no_webui: bool | None = None
jinja: bool | None = None
reasoning_format: Literal['deepseek', 'none', 'nothink'] | None = None
reasoning_budget: int | None = None
reasoning: Literal['on', 'off', 'auto'] | None = None
chat_template: str | None = None
chat_template_file: str | None = None
server_path: str | None = None
mmproj_url: str | None = None
media_path: str | None = None
sleep_idle_seconds: int | None = None
webui_mcp_proxy: bool = False
# session variables
process: subprocess.Popen | None = None
@@ -180,6 +182,8 @@ class ServerProcess:
server_args.extend(["--pooling", self.pooling])
if self.model_alias:
server_args.extend(["--alias", self.model_alias])
if self.model_tags:
server_args.extend(["--tags", self.model_tags])
if self.n_ctx:
server_args.extend(["--ctx-size", self.n_ctx])
if self.n_slots:
@@ -221,8 +225,8 @@ class ServerProcess:
server_args.append("--no-jinja")
if self.reasoning_format is not None:
server_args.extend(("--reasoning-format", self.reasoning_format))
if self.reasoning_budget is not None:
server_args.extend(("--reasoning-budget", self.reasoning_budget))
if self.reasoning is not None:
server_args.extend(("--reasoning", self.reasoning))
if self.chat_template:
server_args.extend(["--chat-template", self.chat_template])
if self.chat_template_file:
@@ -233,6 +237,8 @@ class ServerProcess:
server_args.extend(["--media-path", self.media_path])
if self.sleep_idle_seconds is not None:
server_args.extend(["--sleep-idle-seconds", self.sleep_idle_seconds])
if self.webui_mcp_proxy:
server_args.append("--webui-mcp-proxy")
args = [str(arg) for arg in [server_path, *server_args]]
print(f"tests: starting server with: {' '.join(args)}")
@@ -12,9 +12,13 @@ flowchart TB
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
C_ChatMessageAgenticContent["ChatMessageAgenticContent"]
C_MessageEditForm["ChatMessageEditForm"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
C_McpSettings["McpServersSettings"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpServersSelector["McpServersSelector"]
end
subgraph Hooks["🪝 Hooks"]
@@ -24,10 +28,13 @@ flowchart TB
subgraph Stores["🗄️ Stores"]
S1["chatStore<br/><i>Chat interactions & streaming</i>"]
S2["conversationsStore<br/><i>Conversation data & messages</i>"]
SA["agenticStore<br/><i>Multi-turn agentic loop orchestration</i>"]
S2["conversationsStore<br/><i>Conversation data, messages & MCP overrides</i>"]
S3["modelsStore<br/><i>Model selection & loading</i>"]
S4["serverStore<br/><i>Server props & role detection</i>"]
S5["settingsStore<br/><i>User configuration</i>"]
S5["settingsStore<br/><i>User configuration incl. MCP</i>"]
S6["mcpStore<br/><i>MCP servers, tools, prompts</i>"]
S7["mcpResourceStore<br/><i>MCP resources & attachments</i>"]
end
subgraph Services["⚙️ Services"]
@@ -36,11 +43,12 @@ flowchart TB
SV3["PropsService"]
SV4["DatabaseService"]
SV5["ParameterSyncService"]
SV6["MCPService<br/><i>protocol operations</i>"]
end
subgraph Storage["💾 Storage"]
ST1["IndexedDB<br/><i>conversations, messages</i>"]
ST2["LocalStorage<br/><i>config, userOverrides</i>"]
ST2["LocalStorage<br/><i>config, userOverrides, mcpServers</i>"]
end
subgraph APIs["🌐 llama-server API"]
@@ -50,15 +58,27 @@ flowchart TB
API4["/v1/models"]
end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/><i>WebSocket/HTTP/SSE</i>"]
EXT2["MCP Server N"]
end
%% Routes → Components
R1 & R2 --> C_Screen
RL --> C_Sidebar
%% Layout runs MCP health checks
RL --> S6
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
C_Message --> C_ChatMessageAgenticContent
C_Message --> C_MessageEditForm
C_Form & C_MessageEditForm --> C_ModelsSelector
C_Form --> C_McpServersSelector
C_Settings --> C_McpSettings
C_McpSettings --> C_McpResourceBrowser
%% Components → Hooks → Stores
C_Form & C_Messages --> H1 & H2
@@ -70,6 +90,15 @@ flowchart TB
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
C_McpSettings --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
C_Form --> S6
%% chatStore → agenticStore → mcpStore (agentic loop)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores → Services
S1 --> SV1 & SV4
@@ -77,6 +106,8 @@ flowchart TB
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services → Storage
SV4 --> ST1
@@ -87,6 +118,9 @@ flowchart TB
SV2 --> API3 & API4
SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
@@ -95,12 +129,17 @@ flowchart TB
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef mcpStyle fill:#e0f2f1,stroke:#00695c,stroke-width:2px
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
class R1,R2,RL routeStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_ChatMessageAgenticContent,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
class C_McpSettings,C_McpResourceBrowser,C_McpServersSelector componentStyle
class H1,H2 hookStyle
class S1,S2,S3,S4,S5 storeStyle
class SV1,SV2,SV3,SV4,SV5 serviceStyle
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class ST1,ST2 storageStyle
class API1,API2,API3,API4 apiStyle
class EXT1,EXT2 externalStyle
```
@@ -22,6 +22,13 @@ end
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
end
subgraph MCPComponents["MCP UI"]
C_McpSettings["McpServersSettings"]
C_McpServerCard["McpServerCard"]
C_McpResourceBrowser["McpResourceBrowser"]
C_McpResourcePreview["McpResourcePreview"]
C_McpServersSelector["McpServersSelector"]
end
end
subgraph Hooks["🪝 Hooks"]
@@ -43,14 +50,20 @@ end
S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
end
subgraph SA["agenticStore"]
SAState["<b>State:</b><br/>sessions (Map)<br/>isAnyRunning"]
SASession["<b>Session Management:</b><br/>getSession()<br/>updateSession()<br/>clearSession()<br/>getActiveSessions()<br/>isRunning()<br/>currentTurn()<br/>totalToolCalls()<br/>lastError()<br/>streamingToolCall()"]
SAConfig["<b>Configuration:</b><br/>getConfig()<br/>maxTurns, maxToolPreviewLines"]
SAFlow["<b>Agentic Loop:</b><br/>runAgenticFlow()<br/>executeAgenticLoop()<br/>normalizeToolCalls()<br/>emitToolCallResult()<br/>extractBase64Attachments()"]
end
subgraph S2["conversationsStore"]
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>usedModalities<br/>isInitialized<br/>titleUpdateConfirmationCallback"]
S2Modal["<b>Modalities:</b><br/>getModalitiesUpToMessage()<br/>calculateModalitiesFromMessages()"]
S2State["<b>State:</b><br/>conversations<br/>activeConversation<br/>activeMessages<br/>isInitialized<br/>pendingMcpServerOverrides<br/>titleUpdateConfirmationCallback"]
S2Lifecycle["<b>Lifecycle:</b><br/>initialize()<br/>loadConversations()<br/>clearActiveConversation()"]
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
S2ConvCRUD["<b>Conversation CRUD:</b><br/>createConversation()<br/>loadConversation()<br/>deleteConversation()<br/>deleteAll()<br/>updateConversationName()<br/>updateConversationTitleWithConfirmation()"]
S2MsgMgmt["<b>Message Management:</b><br/>refreshActiveMessages()<br/>addMessageToActive()<br/>updateMessageAtIndex()<br/>findMessageIndex()<br/>sliceActiveMessages()<br/>removeMessageAtIndex()<br/>getConversationMessages()"]
S2Nav["<b>Navigation:</b><br/>navigateToSibling()<br/>updateCurrentNode()<br/>updateConversationTimestamp()"]
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>triggerDownload()"]
S2McpOverrides["<b>MCP Per-Chat Overrides:</b><br/>getMcpServerOverride()<br/>getAllMcpServerOverrides()<br/>setMcpServerOverride()<br/>toggleMcpServerForChat()<br/>removeMcpServerOverride()<br/>isMcpServerEnabledForChat()<br/>clearPendingMcpServerOverrides()"]
S2Export["<b>Import/Export:</b><br/>downloadConversation()<br/>exportAllConversations()<br/>importConversations()<br/>importConversationsData()<br/>triggerDownload()"]
S2Utils["<b>Utilities:</b><br/>setTitleUpdateConfirmationCallback()"]
end
subgraph S3["modelsStore"]
@@ -77,6 +90,21 @@ end
S5Sync["<b>Server Sync:</b><br/>syncWithServerDefaults()<br/>forceSyncWithServerDefaults()"]
S5Utils["<b>Utilities:</b><br/>getConfig()<br/>getAllConfig()<br/>getParameterInfo()<br/>getParameterDiff()<br/>getServerDefaults()<br/>clearAllUserOverrides()"]
end
subgraph S6["mcpStore"]
S6State["<b>State:</b><br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)"]
S6Lifecycle["<b>Lifecycle:</b><br/>ensureInitialized()<br/>initialize()<br/>shutdown()<br/>acquireConnection()<br/>releaseConnection()"]
S6Health["<b>Health Checks:</b><br/>runHealthCheck()<br/>runHealthChecksForServers()<br/>updateHealthCheck()<br/>getHealthCheckState()<br/>clearHealthCheck()"]
S6Servers["<b>Server Management:</b><br/>getServers()<br/>addServer()<br/>updateServer()<br/>removeServer()<br/>getServerById()<br/>getServerDisplayName()"]
S6Tools["<b>Tool Operations:</b><br/>getToolDefinitionsForLLM()<br/>getToolNames()<br/>hasTool()<br/>getToolServer()<br/>executeTool()<br/>executeToolByName()"]
S6Prompts["<b>Prompt Operations:</b><br/>getAllPrompts()<br/>getPrompt()<br/>hasPromptsCapability()<br/>getPromptCompletions()"]
end
subgraph S7["mcpResourceStore"]
S7State["<b>State:</b><br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]<br/>isLoading"]
S7Resources["<b>Resource Discovery:</b><br/>setServerResources()<br/>getServerResources()<br/>getAllResourceInfos()<br/>getAllTemplateInfos()<br/>clearServerResources()"]
S7Cache["<b>Caching:</b><br/>cacheResourceContent()<br/>getCachedContent()<br/>invalidateCache()<br/>clearCache()"]
S7Subs["<b>Subscriptions:</b><br/>addSubscription()<br/>removeSubscription()<br/>isSubscribed()<br/>handleResourceUpdate()"]
S7Attach["<b>Attachments:</b><br/>addAttachment()<br/>updateAttachmentContent()<br/>removeAttachment()<br/>clearAttachments()<br/>toMessageExtras()"]
end
subgraph ReactiveExports["⚡ Reactive Exports"]
direction LR
@@ -95,12 +123,19 @@ end
RE9c["setEditModeActive()"]
RE9d["clearEditMode()"]
end
subgraph AgenticExports["agenticStore"]
REA1["agenticIsRunning()"]
REA2["agenticCurrentTurn()"]
REA3["agenticTotalToolCalls()"]
REA4["agenticLastError()"]
REA5["agenticStreamingToolCall()"]
REA6["agenticIsAnyRunning()"]
end
subgraph ConvExports["conversationsStore"]
RE10["conversations()"]
RE11["activeConversation()"]
RE12["activeMessages()"]
RE13["isConversationsInitialized()"]
RE14["usedModalities()"]
end
subgraph ModelsExports["modelsStore"]
RE15["modelOptions()"]
@@ -131,6 +166,13 @@ end
RE36["theme()"]
RE37["isInitialized()"]
end
subgraph MCPExports["mcpStore / mcpResourceStore"]
RE38["mcpResources()"]
RE39["mcpResourceAttachments()"]
RE40["mcpHasResourceAttachments()"]
RE41["mcpTotalResourceCount()"]
RE42["mcpResourcesLoading()"]
end
end
end
@@ -138,9 +180,9 @@ end
direction TB
subgraph SV1["ChatService"]
SV1Msg["<b>Messaging:</b><br/>sendMessage()"]
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>parseSSEChunk()"]
SV1Convert["<b>Conversion:</b><br/>convertMessageToChatData()<br/>convertExtraToApiFormat()"]
SV1Utils["<b>Utilities:</b><br/>extractReasoningContent()<br/>getServerProps()<br/>getModels()"]
SV1Stream["<b>Streaming:</b><br/>handleStreamResponse()<br/>handleNonStreamResponse()"]
SV1Convert["<b>Conversion:</b><br/>convertDbMessageToApiChatMessageData()<br/>mergeToolCallDeltas()"]
SV1Utils["<b>Utilities:</b><br/>stripReasoningContent()<br/>extractModelName()<br/>parseErrorResponse()"]
end
subgraph SV2["ModelsService"]
SV2List["<b>Listing:</b><br/>list()<br/>listRouter()"]
@@ -152,7 +194,7 @@ end
end
subgraph SV4["DatabaseService"]
SV4Conv["<b>Conversations:</b><br/>createConversation()<br/>getConversation()<br/>getAllConversations()<br/>updateConversation()<br/>deleteConversation()"]
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
SV4Msg["<b>Messages:</b><br/>createMessageBranch()<br/>createRootMessage()<br/>createSystemMessage()<br/>getConversationMessages()<br/>updateMessage()<br/>deleteMessage()<br/>deleteMessageCascading()"]
SV4Node["<b>Navigation:</b><br/>updateCurrentNode()"]
SV4Import["<b>Import:</b><br/>importConversations()"]
end
@@ -162,6 +204,19 @@ end
SV5Info["<b>Info:</b><br/>getParameterInfo()<br/>canSyncParameter()<br/>getSyncableParameterKeys()<br/>validateServerParameter()"]
SV5Diff["<b>Diff:</b><br/>createParameterDiff()"]
end
subgraph SV6["MCPService"]
SV6Transport["<b>Transport:</b><br/>createTransport()<br/>WebSocket / StreamableHTTP / SSE"]
SV6Conn["<b>Connection:</b><br/>connect()<br/>disconnect()"]
SV6Tools["<b>Tools:</b><br/>listTools()<br/>callTool()"]
SV6Prompts["<b>Prompts:</b><br/>listPrompts()<br/>getPrompt()"]
SV6Resources["<b>Resources:</b><br/>listResources()<br/>listResourceTemplates()<br/>readResource()<br/>subscribeResource()<br/>unsubscribeResource()"]
SV6Complete["<b>Completions:</b><br/>complete()"]
end
end
subgraph ExternalMCP["🔌 External MCP Servers"]
EXT1["MCP Server 1<br/>(WebSocket/StreamableHTTP/SSE)"]
EXT2["MCP Server N"]
end
subgraph Storage["💾 Storage"]
@@ -171,6 +226,7 @@ end
ST5["LocalStorage"]
ST6["config"]
ST7["userOverrides"]
ST8["mcpServers"]
end
subgraph APIs["🌐 llama-server API"]
@@ -185,6 +241,9 @@ end
R2 --> C_Screen
RL --> C_Sidebar
%% Layout runs MCP health checks on startup
RL --> S6
%% Component hierarchy
C_Screen --> C_Form & C_Messages & C_Settings
C_Messages --> C_Message
@@ -194,8 +253,15 @@ end
C_MessageEditForm --> C_Attach
C_Form --> C_ModelsSelector
C_Form --> C_Attach
C_Form --> C_McpServersSelector
C_Message --> C_Attach
%% MCP Components hierarchy
C_Settings --> C_McpSettings
C_McpSettings --> C_McpServerCard
C_McpServerCard --> C_McpResourceBrowser
C_McpResourceBrowser --> C_McpResourcePreview
%% Components use Hooks
C_Form --> H1
C_Message --> H1 & H2
@@ -210,17 +276,29 @@ end
C_Screen --> S1 & S2
C_Messages --> S2
C_Message --> S1 & S2 & S3
C_Form --> S1 & S3
C_Form --> S1 & S3 & S6
C_Sidebar --> S2
C_ModelsSelector --> S3 & S4
C_Settings --> S5
C_McpSettings --> S6
C_McpServerCard --> S6
C_McpResourceBrowser --> S6 & S7
C_McpServersSelector --> S6
%% Stores export Reactive State
S1 -. exports .-> ChatExports
SA -. exports .-> AgenticExports
S2 -. exports .-> ConvExports
S3 -. exports .-> ModelsExports
S4 -. exports .-> ServerExports
S5 -. exports .-> SettingsExports
S6 -. exports .-> MCPExports
S7 -. exports .-> MCPExports
%% chatStore → agenticStore (agentic loop orchestration)
S1 --> SA
SA --> SV1
SA --> S6
%% Stores use Services
S1 --> SV1 & SV4
@@ -228,28 +306,35 @@ end
S3 --> SV2 & SV3
S4 --> SV3
S5 --> SV5
S6 --> SV6
S7 --> SV6
%% Services to Storage
SV4 --> ST1
ST1 --> ST2 & ST3
SV5 --> ST5
ST5 --> ST6 & ST7
ST5 --> ST6 & ST7 & ST8
%% Services to APIs
SV1 --> API1
SV2 --> API3 & API4
SV3 --> API2
%% MCP → External Servers
SV6 --> EXT1 & EXT2
%% Styling
classDef routeStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef componentStyle fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef componentGroupStyle fill:#e1bee7,stroke:#7b1fa2,stroke-width:1px
classDef hookStyle fill:#fff8e1,stroke:#ff8f00,stroke-width:2px
classDef storeStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef stateStyle fill:#ffe0b2,stroke:#e65100,stroke-width:1px
classDef methodStyle fill:#ffecb3,stroke:#e65100,stroke-width:1px
classDef reactiveStyle fill:#fffde7,stroke:#f9a825,stroke-width:1px
classDef serviceStyle fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef serviceMStyle fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
classDef externalStyle fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,stroke-dasharray: 5 5
classDef storageStyle fill:#fce4ec,stroke:#c2185b,stroke-width:2px
classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
@@ -257,23 +342,32 @@ end
class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
class C_ModelsSelector,C_Settings componentStyle
class C_Attach componentStyle
class H1,H2,H3 methodStyle
class LayoutComponents,ChatUIComponents componentGroupStyle
class Hooks storeStyle
class S1,S2,S3,S4,S5 storeStyle
class S1State,S2State,S3State,S4State,S5State stateStyle
class C_McpSettings,C_McpServerCard,C_McpResourceBrowser,C_McpResourcePreview,C_McpServersSelector componentStyle
class H1,H2,H3 hookStyle
class LayoutComponents,ChatUIComponents,MCPComponents componentGroupStyle
class Hooks hookStyle
classDef agenticStyle fill:#e8eaf6,stroke:#283593,stroke-width:2px
classDef agenticMethodStyle fill:#c5cae9,stroke:#283593,stroke-width:1px
class S1,S2,S3,S4,S5,SA,S6,S7 storeStyle
class S1State,S2State,S3State,S4State,S5State,SAState,S6State,S7State stateStyle
class S1Msg,S1Regen,S1Edit,S1Stream,S1LoadState,S1ProcState,S1Error,S1Utils methodStyle
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2Modal,S2Export,S2Utils methodStyle
class SASession,SAConfig,SAFlow methodStyle
class S2Lifecycle,S2ConvCRUD,S2MsgMgmt,S2Nav,S2McpOverrides,S2Export,S2Utils methodStyle
class S3Getters,S3Modal,S3Status,S3Fetch,S3Select,S3LoadUnload,S3Utils methodStyle
class S4Getters,S4Data,S4Utils methodStyle
class S5Lifecycle,S5Update,S5Reset,S5Sync,S5Utils methodStyle
class ChatExports,ConvExports,ModelsExports,ServerExports,SettingsExports reactiveStyle
class SV1,SV2,SV3,SV4,SV5 serviceStyle
class S6Lifecycle,S6Health,S6Servers,S6Tools,S6Prompts methodStyle
class S7Resources,S7Cache,S7Subs,S7Attach methodStyle
class ChatExports,AgenticExports,ConvExports,ModelsExports,ServerExports,SettingsExports,MCPExports reactiveStyle
class SV1,SV2,SV3,SV4,SV5,SV6 serviceStyle
class SV6Transport,SV6Conn,SV6Tools,SV6Prompts,SV6Resources,SV6Complete serviceMStyle
class EXT1,EXT2 externalStyle
class SV1Msg,SV1Stream,SV1Convert,SV1Utils serviceMStyle
class SV2List,SV2LoadUnload,SV2Status serviceMStyle
class SV3Fetch serviceMStyle
class SV4Conv,SV4Msg,SV4Node,SV4Import serviceMStyle
class SV5Extract,SV5Merge,SV5Info,SV5Diff serviceMStyle
class ST1,ST2,ST3,ST5,ST6,ST7 storageStyle
class ST1,ST2,ST3,ST5,ST6,ST7,ST8 storageStyle
class API1,API2,API3,API4 apiStyle
```
+59 -5
View File
@@ -2,8 +2,10 @@
sequenceDiagram
participant UI as 🧩 ChatForm / ChatMessage
participant chatStore as 🗄️ chatStore
participant agenticStore as 🗄️ agenticStore
participant convStore as 🗄️ conversationsStore
participant settingsStore as 🗄️ settingsStore
participant mcpStore as 🗄️ mcpStore
participant ChatSvc as ⚙️ ChatService
participant DbSvc as ⚙️ DatabaseService
participant API as 🌐 /v1/chat/completions
@@ -25,6 +27,9 @@ sequenceDiagram
Note over convStore: → see conversations-flow.mmd
end
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
Note right of mcpStore: Converts pending MCP resource<br/>attachments into message extras
chatStore->>chatStore: addMessage("user", content, extras)
chatStore->>DbSvc: createMessageBranch(userMsg, parentId)
chatStore->>convStore: addMessageToActive(userMsg)
@@ -38,7 +43,7 @@ sequenceDiagram
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🌊 STREAMING
Note over UI,API: 🌊 STREAMING (with agentic flow detection)
%% ═══════════════════════════════════════════════════════════════════════════
activate chatStore
@@ -52,10 +57,17 @@ sequenceDiagram
chatStore->>chatStore: getApiOptions()
Note right of chatStore: Merge from settingsStore.config:<br/>temperature, max_tokens, top_p, etc.
chatStore->>ChatSvc: sendMessage(messages, options, signal)
alt agenticConfig.enabled && mcpStore has connected servers
chatStore->>agenticStore: runAgenticFlow(convId, messages, assistantMsg, options, signal)
Note over agenticStore: Multi-turn agentic loop:<br/>1. Call ChatService.sendMessage()<br/>2. If response has tool_calls → execute via mcpStore<br/>3. Append tool results as messages<br/>4. Loop until no more tool_calls or maxTurns<br/>→ see agentic flow details below
agenticStore-->>chatStore: final response with timings
else standard (non-agentic) flow
chatStore->>ChatSvc: sendMessage(messages, options, signal)
end
activate ChatSvc
ChatSvc->>ChatSvc: convertMessageToChatData(messages)
ChatSvc->>ChatSvc: convertDbMessageToApiChatMessageData(messages)
Note right of ChatSvc: DatabaseMessage[] → ApiChatMessageData[]<br/>Process attachments (images, PDFs, audio)
ChatSvc->>API: POST /v1/chat/completions
@@ -63,7 +75,7 @@ sequenceDiagram
loop SSE chunks
API-->>ChatSvc: data: {"choices":[{"delta":{...}}]}
ChatSvc->>ChatSvc: parseSSEChunk(line)
ChatSvc->>ChatSvc: handleStreamResponse(response)
alt content chunk
ChatSvc-->>chatStore: onChunk(content)
@@ -154,12 +166,15 @@ sequenceDiagram
Note over UI,API: ✏️ EDIT USER MESSAGE
%% ═══════════════════════════════════════════════════════════════════════════
UI->>chatStore: editUserMessagePreserveResponses(msgId, newContent)
UI->>chatStore: editMessageWithBranching(msgId, newContent, extras)
activate chatStore
chatStore->>chatStore: Get parent of target message
chatStore->>DbSvc: createMessageBranch(editedMsg, parentId)
chatStore->>convStore: refreshActiveMessages()
Note right of chatStore: Creates new branch, original preserved
chatStore->>chatStore: createAssistantMessage(editedMsg.id)
chatStore->>chatStore: streamChatCompletion(...)
Note right of chatStore: Automatically regenerates response
deactivate chatStore
%% ═══════════════════════════════════════════════════════════════════════════
@@ -171,4 +186,43 @@ sequenceDiagram
Note right of chatStore: errorDialogState = {type: 'timeout'|'server', message}
chatStore->>convStore: removeMessageAtIndex(failedMsgIdx)
chatStore->>DbSvc: deleteMessage(failedMsgId)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,API: 🤖 AGENTIC LOOP (when agenticConfig.enabled)
%% ═══════════════════════════════════════════════════════════════════════════
Note over agenticStore: agenticStore.runAgenticFlow(convId, messages, assistantMsg, options, signal)
activate agenticStore
agenticStore->>agenticStore: getSession(convId) or create new
agenticStore->>agenticStore: updateSession(turn: 0, running: true)
loop executeAgenticLoop (until no tool_calls or maxTurns)
agenticStore->>agenticStore: turn++
agenticStore->>ChatSvc: sendMessage(messages, options, signal)
ChatSvc->>API: POST /v1/chat/completions
API-->>ChatSvc: response with potential tool_calls
ChatSvc-->>agenticStore: onComplete(content, reasoning, timings, toolCalls)
alt response has tool_calls
agenticStore->>agenticStore: normalizeToolCalls(toolCalls)
loop for each tool_call
agenticStore->>agenticStore: updateSession(streamingToolCall)
agenticStore->>mcpStore: executeTool(mcpCall, signal)
mcpStore-->>agenticStore: tool result
agenticStore->>agenticStore: extractBase64Attachments(result)
agenticStore->>agenticStore: emitToolCallResult(convId, ...)
agenticStore->>convStore: addMessageToActive(toolResultMsg)
agenticStore->>DbSvc: createMessageBranch(toolResultMsg)
end
agenticStore->>agenticStore: Create new assistantMsg for next turn
Note right of agenticStore: Continue loop with updated messages
else no tool_calls (final response)
agenticStore->>agenticStore: buildFinalTimings(allTurns)
Note right of agenticStore: Break loop, return final response
end
end
agenticStore->>agenticStore: updateSession(running: false)
agenticStore-->>chatStore: final content, timings, model
deactivate agenticStore
```
@@ -6,7 +6,7 @@ sequenceDiagram
participant DbSvc as ⚙️ DatabaseService
participant IDB as 💾 IndexedDB
Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>usedModalities: $derived({vision, audio})
Note over convStore: State:<br/>conversations: DatabaseConversation[]<br/>activeConversation: DatabaseConversation | null<br/>activeMessages: DatabaseMessage[]<br/>isInitialized: boolean<br/>pendingMcpServerOverrides: Map&lt;string, McpServerOverride&gt;
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 🚀 INITIALIZATION
@@ -37,6 +37,13 @@ sequenceDiagram
convStore->>convStore: conversations.unshift(conversation)
convStore->>convStore: activeConversation = $state(conversation)
convStore->>convStore: activeMessages = $state([])
alt pendingMcpServerOverrides has entries
loop each pending override
convStore->>DbSvc: Store MCP server override for new conversation
end
convStore->>convStore: clearPendingMcpServerOverrides()
end
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
@@ -58,8 +65,7 @@ sequenceDiagram
Note right of convStore: Filter to show only current branch path
convStore->>convStore: activeMessages = $state(filtered)
convStore->>chatStore: syncLoadingStateForChat(convId)
Note right of chatStore: Sync isLoading/currentResponse if streaming
Note right of convStore: Route (+page.svelte) then calls:<br/>chatStore.syncLoadingStateForChat(convId)
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
@@ -121,16 +127,36 @@ sequenceDiagram
end
deactivate convStore
UI->>convStore: deleteAll()
activate convStore
convStore->>DbSvc: Delete all conversations and messages
convStore->>convStore: conversations = []
convStore->>convStore: clearActiveConversation()
deactivate convStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📊 MODALITY TRACKING
Note over UI,IDB: � MCP SERVER PER-CHAT OVERRIDES
%% ═══════════════════════════════════════════════════════════════════════════
Note over convStore: usedModalities = $derived.by(() => {<br/> calculateModalitiesFromMessages(activeMessages)<br/>})
Note over convStore: Conversations can override which MCP servers are enabled.
Note over convStore: Uses pendingMcpServerOverrides before conversation<br/>is created, then persists to conversation metadata.
Note over convStore: Scans activeMessages for attachments:<br/>- IMAGE → vision: true<br/>- PDF (processedAsImages) → vision: true<br/>- AUDIO → audio: true
UI->>convStore: setMcpServerOverride(convId, serverName, override)
Note right of convStore: override = {enabled: boolean}
UI->>convStore: getModalitiesUpToMessage(msgId)
Note right of convStore: Used for regeneration validation<br/>Only checks messages BEFORE target
UI->>convStore: toggleMcpServerForChat(convId, serverName, enabled)
activate convStore
convStore->>convStore: setMcpServerOverride(convId, serverName, {enabled})
deactivate convStore
UI->>convStore: isMcpServerEnabledForChat(convId, serverName)
Note right of convStore: Check override → fall back to global MCP config
UI->>convStore: getAllMcpServerOverrides(convId)
Note right of convStore: Returns all overrides for a conversation
UI->>convStore: removeMcpServerOverride(convId, serverName)
UI->>convStore: getMcpServerOverride(convId, serverName)
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,IDB: 📤 EXPORT / 📥 IMPORT
@@ -148,8 +174,10 @@ sequenceDiagram
UI->>convStore: importConversations(file)
activate convStore
convStore->>convStore: Parse JSON file
convStore->>convStore: importConversationsData(parsed)
convStore->>DbSvc: importConversations(parsed)
DbSvc->>IDB: Bulk INSERT conversations + messages
Note right of DbSvc: Skips duplicate conversations<br/>(checks existing by ID)
DbSvc->>IDB: INSERT conversations + messages (skip existing)
convStore->>convStore: loadConversations()
deactivate convStore
```
+25 -6
View File
@@ -66,6 +66,14 @@ sequenceDiagram
DbSvc-->>Store: rootMessageId
deactivate DbSvc
Store->>DbSvc: createSystemMessage(convId, content, parentId)
activate DbSvc
DbSvc->>DbSvc: Create message {role: "system", parent: parentId}
DbSvc->>Dexie: db.messages.add(systemMsg)
Dexie->>IDB: INSERT
DbSvc-->>Store: DatabaseMessage
deactivate DbSvc
Store->>DbSvc: createMessageBranch(message, parentId)
activate DbSvc
DbSvc->>DbSvc: Generate UUID for new message
@@ -116,6 +124,13 @@ sequenceDiagram
end
DbSvc->>Dexie: db.messages.delete(msgId)
Dexie->>IDB: DELETE target message
alt target message has a parent
DbSvc->>Dexie: db.messages.get(parentId)
DbSvc->>DbSvc: parent.children.filter(id !== msgId)
DbSvc->>Dexie: db.messages.update(parentId, {children})
Note right of DbSvc: Remove deleted message from parent's children[]
end
deactivate DbSvc
%% ═══════════════════════════════════════════════════════════════════════════
@@ -125,12 +140,16 @@ sequenceDiagram
Store->>DbSvc: importConversations(data)
activate DbSvc
loop each conversation in data
DbSvc->>DbSvc: Generate new UUIDs (avoid conflicts)
DbSvc->>Dexie: db.conversations.add(conversation)
Dexie->>IDB: INSERT conversation
loop each message
DbSvc->>Dexie: db.messages.add(message)
Dexie->>IDB: INSERT message
DbSvc->>Dexie: db.conversations.get(conv.id)
alt conversation already exists
Note right of DbSvc: Skip duplicate (keep existing)
else conversation is new
DbSvc->>Dexie: db.conversations.add(conversation)
Dexie->>IDB: INSERT conversation
loop each message
DbSvc->>Dexie: db.messages.add(message)
Dexie->>IDB: INSERT message
end
end
end
deactivate DbSvc
+226
View File
@@ -0,0 +1,226 @@
```mermaid
sequenceDiagram
participant UI as 🧩 McpServersSettings / ChatForm
participant chatStore as 🗄️ chatStore
participant mcpStore as 🗄️ mcpStore
participant mcpResStore as 🗄️ mcpResourceStore
participant convStore as 🗄️ conversationsStore
participant MCPSvc as ⚙️ MCPService
participant LS as 💾 LocalStorage
participant ExtMCP as 🔌 External MCP Server
Note over mcpStore: State:<br/>isInitializing, error<br/>toolCount, connectedServers<br/>healthChecks (Map)<br/>connections (Map)<br/>toolsIndex (Map)<br/>serverConfigs (Map)
Note over mcpResStore: State:<br/>serverResources (Map)<br/>cachedResources (Map)<br/>subscriptions (Map)<br/>attachments[]
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🚀 INITIALIZATION (App Startup)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: ensureInitialized()
activate mcpStore
mcpStore->>LS: get(MCP_SERVERS_LOCALSTORAGE_KEY)
LS-->>mcpStore: MCPServerSettingsEntry[]
mcpStore->>mcpStore: parseServerSettings(servers)
Note right of mcpStore: Filter enabled servers<br/>Build MCPServerConfig objects<br/>Per-chat overrides checked via convStore
loop For each enabled server
mcpStore->>mcpStore: runHealthCheck(serverId)
mcpStore->>mcpStore: updateHealthCheck(id, CONNECTING)
mcpStore->>MCPSvc: connect(serverName, config, clientInfo, capabilities, onPhase)
activate MCPSvc
MCPSvc->>MCPSvc: createTransport(config)
Note right of MCPSvc: WebSocket / StreamableHTTP / SSE<br/>with optional CORS proxy
MCPSvc->>ExtMCP: Transport handshake
ExtMCP-->>MCPSvc: Connection established
MCPSvc->>ExtMCP: Initialize request
Note right of ExtMCP: Exchange capabilities<br/>Server info, protocol version
ExtMCP-->>MCPSvc: InitializeResult (serverInfo, capabilities)
MCPSvc->>ExtMCP: listTools()
ExtMCP-->>MCPSvc: Tool[]
MCPSvc-->>mcpStore: MCPConnection
deactivate MCPSvc
mcpStore->>mcpStore: connections.set(serverName, connection)
mcpStore->>mcpStore: indexTools(connection.tools, serverName)
Note right of mcpStore: toolsIndex.set(toolName, serverName)<br/>Handle name conflicts with prefixes
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
mcpStore->>mcpStore: _connectedServers.push(serverName)
alt Server supports resources
mcpStore->>MCPSvc: listAllResources(connection)
MCPSvc->>ExtMCP: listResources()
ExtMCP-->>MCPSvc: MCPResource[]
MCPSvc-->>mcpStore: resources
mcpStore->>MCPSvc: listAllResourceTemplates(connection)
MCPSvc->>ExtMCP: listResourceTemplates()
ExtMCP-->>MCPSvc: MCPResourceTemplate[]
MCPSvc-->>mcpStore: templates
mcpStore->>mcpResStore: setServerResources(serverName, resources, templates)
end
end
mcpStore->>mcpStore: _isInitializing = false
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🔧 TOOL EXECUTION (Chat with Tools)
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: executeTool(mcpCall: MCPToolCall, signal?)
activate mcpStore
mcpStore->>mcpStore: toolsIndex.get(mcpCall.function.name)
Note right of mcpStore: Resolve serverName from toolsIndex<br/>MCPToolCall = {id, type, function: {name, arguments}}
mcpStore->>mcpStore: acquireConnection()
Note right of mcpStore: activeFlowCount++<br/>Prevent shutdown during execution
mcpStore->>mcpStore: connection = connections.get(serverName)
mcpStore->>MCPSvc: callTool(connection, {name, arguments}, signal)
activate MCPSvc
MCPSvc->>MCPSvc: throwIfAborted(signal)
MCPSvc->>ExtMCP: callTool(name, arguments)
alt Tool execution success
ExtMCP-->>MCPSvc: ToolCallResult (content, isError)
MCPSvc->>MCPSvc: formatToolResult(result)
Note right of MCPSvc: Handle text, image (base64),<br/>embedded resource content
MCPSvc-->>mcpStore: ToolExecutionResult
else Tool execution error
ExtMCP-->>MCPSvc: Error
MCPSvc-->>mcpStore: throw Error
else Aborted
MCPSvc-->>mcpStore: throw AbortError
end
deactivate MCPSvc
mcpStore->>mcpStore: releaseConnection()
Note right of mcpStore: activeFlowCount--
mcpStore-->>UI: ToolExecutionResult
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: � RESOURCE ATTACHMENT CONSUMPTION
%% ═══════════════════════════════════════════════════════════════════════════
chatStore->>mcpStore: consumeResourceAttachmentsAsExtras()
activate mcpStore
mcpStore->>mcpResStore: getAttachments()
mcpResStore-->>mcpStore: MCPResourceAttachment[]
mcpStore->>mcpStore: Convert attachments to message extras
mcpStore->>mcpResStore: clearAttachments()
mcpStore-->>chatStore: MessageExtra[] (for user message)
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: �📝 PROMPT OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: getAllPrompts()
activate mcpStore
loop For each connected server with prompts capability
mcpStore->>MCPSvc: listPrompts(connection)
MCPSvc->>ExtMCP: listPrompts()
ExtMCP-->>MCPSvc: Prompt[]
MCPSvc-->>mcpStore: prompts
end
mcpStore-->>UI: MCPPromptInfo[] (with serverName)
deactivate mcpStore
UI->>mcpStore: getPrompt(serverName, promptName, args?)
activate mcpStore
mcpStore->>MCPSvc: getPrompt(connection, name, args)
MCPSvc->>ExtMCP: getPrompt({name, arguments})
ExtMCP-->>MCPSvc: GetPromptResult (messages)
MCPSvc-->>mcpStore: GetPromptResult
mcpStore-->>UI: GetPromptResult
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 📁 RESOURCE OPERATIONS
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpResStore: addAttachment(resourceInfo)
activate mcpResStore
mcpResStore->>mcpResStore: Create MCPResourceAttachment (loading: true)
mcpResStore-->>UI: attachment
UI->>mcpStore: readResource(serverName, uri)
activate mcpStore
mcpStore->>MCPSvc: readResource(connection, uri)
MCPSvc->>ExtMCP: readResource({uri})
ExtMCP-->>MCPSvc: MCPReadResourceResult (contents)
MCPSvc-->>mcpStore: contents
mcpStore-->>UI: MCPResourceContent[]
deactivate mcpStore
UI->>mcpResStore: updateAttachmentContent(attachmentId, content)
mcpResStore->>mcpResStore: cacheResourceContent(resource, content)
deactivate mcpResStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🔄 AUTO-RECONNECTION
%% ═══════════════════════════════════════════════════════════════════════════
Note over mcpStore: On WebSocket close or connection error:
mcpStore->>mcpStore: autoReconnect(serverName, attempt)
activate mcpStore
mcpStore->>mcpStore: Calculate backoff delay
Note right of mcpStore: delay = min(30s, 1s * 2^attempt)
mcpStore->>mcpStore: Wait for delay
mcpStore->>mcpStore: reconnectServer(serverName)
alt Reconnection success
mcpStore->>mcpStore: updateHealthCheck(id, SUCCESS)
else Max attempts reached
mcpStore->>mcpStore: updateHealthCheck(id, ERROR)
end
deactivate mcpStore
%% ═══════════════════════════════════════════════════════════════════════════
Note over UI,ExtMCP: 🛑 SHUTDOWN
%% ═══════════════════════════════════════════════════════════════════════════
UI->>mcpStore: shutdown()
activate mcpStore
mcpStore->>mcpStore: Wait for activeFlowCount == 0
loop For each connection
mcpStore->>MCPSvc: disconnect(connection)
MCPSvc->>MCPSvc: transport.onclose = undefined
MCPSvc->>ExtMCP: close()
end
mcpStore->>mcpStore: connections.clear()
mcpStore->>mcpStore: toolsIndex.clear()
mcpStore->>mcpStore: _connectedServers = []
mcpStore->>mcpResStore: clear()
deactivate mcpStore
```
+986 -75
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -79,6 +79,7 @@
"vitest-browser-svelte": "^0.1.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",
"highlight.js": "^11.11.1",
"mode-watcher": "^1.1.0",
"pdfjs-dist": "^5.4.54",
@@ -90,6 +91,7 @@
"remark-html": "^16.0.1",
"remark-rehype": "^11.1.2",
"svelte-sonner": "^1.0.5",
"unist-util-visit": "^5.0.0"
"unist-util-visit": "^5.0.0",
"zod": "^4.2.1"
}
}
@@ -8,6 +8,7 @@
tooltip: string;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
iconSize?: string;
class?: string;
disabled?: boolean;
onclick: () => void;
@@ -21,6 +22,7 @@
size = 'sm',
class: className = '',
disabled = false,
iconSize = 'h-3 w-3',
onclick,
'aria-label': ariaLabel
}: Props = $props();
@@ -38,7 +40,7 @@
>
{@const IconComponent = icon}
<IconComponent class="h-3 w-3" />
<IconComponent class={iconSize} />
</Button>
</Tooltip.Trigger>
@@ -6,21 +6,22 @@
id: string;
onRemove?: (id: string) => void;
class?: string;
iconSize?: number;
}
let { id, onRemove, class: className = '' }: Props = $props();
let { id, onRemove, class: className = '', iconSize = 3 }: Props = $props();
</script>
<Button
type="button"
variant="ghost"
size="sm"
class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
size="icon-sm"
class="bg-white/20 p-0 hover:bg-white/30 {className}"
onclick={(e: MouseEvent) => {
e.stopPropagation();
onRemove?.(id);
}}
aria-label="Remove file"
>
<X class="h-3 w-3" />
<X class="h-{iconSize} w-{iconSize}" />
</Button>
@@ -1,6 +1,6 @@
<script lang="ts">
import { ModelModality } from '$lib/enums';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants/icons';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { cn } from '$lib/components/ui/utils';
type DisplayableModality = ModelModality.VISION | ModelModality.AUDIO;
@@ -0,0 +1,40 @@
<script lang="ts">
import { ChatMessageMcpPromptContent, ActionIconRemove } from '$lib/components/app';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { McpPromptVariant } from '$lib/enums';
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
isLoading?: boolean;
loadError?: string;
onRemove?: () => void;
}
let {
class: className = '',
prompt,
readonly = false,
isLoading = false,
loadError,
onRemove
}: Props = $props();
</script>
<div class="group relative {className}">
<ChatMessageMcpPromptContent
{prompt}
variant={McpPromptVariant.ATTACHMENT}
{isLoading}
{loadError}
/>
{#if !readonly && onRemove}
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIconRemove id={prompt.name} onRemove={() => onRemove?.()} />
</div>
{/if}
</div>
@@ -0,0 +1,86 @@
<script lang="ts">
import { Loader2, AlertCircle } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceAttachment } from '$lib/types';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIconRemove } from '$lib/components/app';
import { getResourceIcon, getResourceDisplayName } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
onRemove?: (attachmentId: string) => void;
onClick?: () => void;
class?: string;
}
let { attachment, onRemove, onClick, class: className }: Props = $props();
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
const ResourceIcon = $derived(
getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
);
const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
</script>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class={cn(
'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
getStatusClass(attachment),
onClick && 'cursor-pointer hover:bg-muted/50',
className
)}
onclick={onClick}
disabled={!onClick}
>
{#if attachment.loading}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
{:else if attachment.error}
<AlertCircle class="h-3 w-3 text-red-500" />
{:else}
<ResourceIcon class="h-3 w-3 text-muted-foreground" />
{/if}
<span class="max-w-[150px] truncate text-xs">
{getResourceDisplayName(attachment.resource)}
</span>
{#if onRemove}
<ActionIconRemove
class="-my-2 -mr-1.5 bg-transparent"
iconSize={2}
id={attachment.id}
{onRemove}
/>
{/if}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<div class="flex items-center gap-1 text-xs">
{#if favicon}
<img
src={favicon}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">
{serverName}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,41 @@
<script lang="ts">
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
mcpResourceAttachments,
mcpHasResourceAttachments
} from '$lib/stores/mcp-resources.svelte';
import { ChatAttachmentMcpResource, HorizontalScrollCarousel } from '$lib/components/app';
interface Props {
class?: string;
onResourceClick?: (uri: string) => void;
}
let { class: className, onResourceClick }: Props = $props();
const attachments = $derived(mcpResourceAttachments());
const hasAttachments = $derived(mcpHasResourceAttachments());
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
}
function handleResourceClick(uri: string) {
onResourceClick?.(uri);
}
</script>
{#if hasAttachments}
<div class={className}>
<HorizontalScrollCarousel gapSize="2">
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentMcpResource
class={i === 0 ? 'ml-3' : ''}
{attachment}
onRemove={handleRemove}
onClick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</HorizontalScrollCarousel>
</div>
{/if}
@@ -1,12 +1,21 @@
<script lang="ts">
import {
ChatAttachmentMcpPrompt,
ChatAttachmentMcpResource,
ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile,
HorizontalScrollCarousel,
DialogChatAttachmentPreview,
DialogChatAttachmentsViewAll
DialogChatAttachmentsViewAll,
DialogMcpResourcePreview
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { AttachmentType } from '$lib/enums';
import type {
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
MCPResourceAttachment
} from '$lib/types';
import { getAttachmentDisplayItems } from '$lib/utils';
interface Props {
@@ -49,6 +58,8 @@
let isScrollable = $state(false);
let previewDialogOpen = $state(false);
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
let mcpResourcePreviewOpen = $state(false);
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
let viewAllDialogOpen = $state(false);
@@ -67,6 +78,26 @@
previewDialogOpen = true;
}
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource) {
mcpResourcePreviewExtra = extra;
mcpResourcePreviewOpen = true;
}
function toMcpResourceAttachment(
extra: DatabaseMessageExtraMcpResource,
id: string
): MCPResourceAttachment {
return {
id,
resource: {
uri: extra.uri,
name: extra.name,
title: extra.name,
serverName: extra.serverName
}
};
}
$effect(() => {
if (carouselRef && displayItems.length) {
carouselRef.resetScroll();
@@ -82,7 +113,41 @@
onScrollableChange={(scrollable) => (isScrollable = scrollable)}
>
{#each displayItems as item (item.id)}
{#if item.isImage && item.preview}
{#if item.isMcpPrompt}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
? 'first:ml-4 last:mr-4'
: ''}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentMcpResource
class="flex-shrink-0 {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
attachment={toMcpResourceAttachment(mcpResource, item.id)}
onClick={() => openMcpResourcePreview(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentThumbnailImage
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
id={item.id}
@@ -128,7 +193,39 @@
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{#if item.isImage && item.preview}
{#if item.isMcpPrompt}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px]"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
/>
{/if}
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentMcpResource
attachment={toMcpResourceAttachment(mcpResource, item.id)}
onClick={() => openMcpResourcePreview(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentThumbnailImage
class="cursor-pointer"
id={item.id}
@@ -184,3 +281,7 @@
{imageClass}
{activeModelId}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
{/if}
@@ -1,20 +1,39 @@
<script lang="ts">
import {
ChatAttachmentsList,
ChatAttachmentMcpResources,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormPromptPicker,
ChatFormResourcePicker,
ChatFormTextarea
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { CLIPBOARD_CONTENT_QUOTE_PREFIX } from '$lib/constants/chat-form';
import { KeyboardKey, MimeTypeText } from '$lib/enums';
import { DialogMcpResources } from '$lib/components/app/dialogs';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX,
RESOURCE_TRIGGER_PREFIX
} from '$lib/constants';
import {
ContentPartType,
FileExtensionText,
KeyboardKey,
MimeTypeText,
SpecialFileType
} from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
@@ -34,6 +53,7 @@
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
// Event Handlers
onAttachmentRemove?: (index: number) => void;
@@ -42,6 +62,7 @@
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
@@ -51,6 +72,7 @@
disabled = false,
isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
@@ -59,6 +81,7 @@
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange
}: Props = $props();
@@ -74,12 +97,26 @@
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
let resourcePickerRef: ChatFormResourcePicker | undefined = $state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Prompt Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
// Inline Resource Picker State (triggered by @)
let isInlineResourcePickerOpen = $state(false);
let resourceSearchQuery = $state('');
// Resource Dialog State
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
/**
*
*
@@ -209,7 +246,53 @@
*
*/
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
} else if (
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
hasServers &&
mcpStore.hasResourcesCapability(perChatOverrides)
) {
isInlineResourcePickerOpen = true;
resourceSearchQuery = value.slice(1);
isPromptPickerOpen = false;
promptSearchQuery = '';
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return;
}
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
return;
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
@@ -238,7 +321,7 @@
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
if (parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
@@ -254,6 +337,29 @@
onFilesAdd?.(attachmentFiles);
}
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
if (parsed.mcpPromptAttachments.length > 0) {
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
id: uuid(),
name: att.name,
size: att.content.length,
type: SpecialFileType.MCP_PROMPT,
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
}),
isLoading: false,
textContent: att.content,
mcpPrompt: {
serverName: att.serverName,
promptName: att.promptName,
arguments: att.arguments
}
}));
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
onUploadedFilesChange?.(uploadedFiles);
}
setTimeout(() => {
textareaRef?.focus();
}, 10);
@@ -277,6 +383,130 @@
}
}
/**
*
*
* EVENT HANDLERS - Prompt Picker
*
*
*/
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
// Only clear the value if the prompt was triggered by typing '/'
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isPromptPickerOpen = false;
promptSearchQuery = '';
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT,
file: new File([], 'loading'),
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg: PromptMessage) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === ContentPartType.TEXT) {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join(PROMPT_CONTENT_SEPARATOR);
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
})
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
}
/**
*
*
* EVENT HANDLERS - Inline Resource Picker
*
*
*/
function handleInlineResourcePickerClose() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleInlineResourceSelect() {
// Clear the @query from input after resource is attached
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleBrowseResources() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isResourceDialogOpen = true;
}
/**
*
*
@@ -324,6 +554,25 @@
onSubmit?.();
}}
>
<ChatFormPromptPicker
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={handlePromptPickerClose}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
/>
<ChatFormResourcePicker
bind:this={resourcePickerRef}
isOpen={isInlineResourcePickerOpen}
searchQuery={resourceSearchQuery}
onClose={handleInlineResourcePickerClose}
onResourceSelect={handleInlineResourceSelect}
onBrowse={handleBrowseResources}
/>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
@@ -350,12 +599,23 @@
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
{#if mcpHasResourceAttachments()}
<ChatAttachmentMcpResources
class="mb-3"
onResourceClick={(uri) => {
preSelectedResourceUri = uri;
isResourceDialogOpen = true;
}}
/>
{/if}
<ChatFormActions
class="px-3"
bind:this={chatFormActionsRef}
@@ -369,7 +629,22 @@
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
</div>
</div>
</form>
<DialogMcpResources
bind:open={isResourceDialogOpen}
preSelectedUri={preSelectedResourceUri}
onAttach={(resource: MCPResourceInfo) => {
mcpStore.attachResource(resource.uri);
}}
onOpenChange={(newOpen: boolean) => {
if (!newOpen) {
preSelectedResourceUri = undefined;
}
}}
/>
@@ -1,19 +1,30 @@
<script lang="ts">
import { page } from '$app/state';
import { Plus, MessageSquare } from '@lucide/svelte';
import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
import { Switch } from '$lib/components/ui/switch';
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
@@ -21,8 +32,13 @@
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let isNewChat = $derived(!page.params.id);
@@ -35,6 +51,53 @@
let dropdownOpen = $state(false);
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let mcpSearchQuery = $state('');
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(mcpServers);
}
}
function handleMcpPromptClick() {
dropdownOpen = false;
onMcpPromptClick?.();
}
function handleMcpSettingsClick() {
dropdownOpen = false;
onMcpSettingsClick?.();
}
function handleMcpResourcesClick() {
dropdownOpen = false;
onMcpResourcesClick?.();
}
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
</script>
@@ -168,6 +231,103 @@
<p>{systemMessageTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Separator />
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage={hasMcpServers ? 'No servers found' : 'No MCP servers configured'}
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if mcpStore.getServerFavicon(server.id)}
<img
src={mcpStore.getServerFavicon(server.id)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#if hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpPromptClick}
>
<Zap class="h-4 w-4" />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
{#if hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpResourcesClick}
>
<FolderOpen class="h-4 w-4" />
<span>MCP Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,170 @@
<script lang="ts">
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Sheet from '$lib/components/ui/sheet';
import { FILE_TYPE_ICONS } from '$lib/constants';
import { McpLogo } from '$lib/components/app';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let sheetOpen = $state(false);
function handleMcpPromptClick() {
sheetOpen = false;
onMcpPromptClick?.();
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
}
function handleMcpResourcesClick() {
sheetOpen = false;
onMcpResourcesClick?.();
}
function handleSheetFileUpload() {
sheetOpen = false;
onFileUpload?.();
}
function handleSheetSystemPromptClick() {
sheetOpen = false;
onSystemPromptClick?.();
}
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
onclick={() => (sheetOpen = true)}
>
<span class="sr-only">{fileUploadTooltipText}</span>
<Plus class="h-4 w-4" />
</Button>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
<Sheet.Description class="sr-only">
Add files, system prompt or configure MCP servers
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
<!-- Images -->
<button
type="button"
class={sheetItemClass}
disabled={!hasVisionModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
<span>Images</span>
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
{/if}
</button>
<!-- Audio -->
<button
type="button"
class={sheetItemClass}
disabled={!hasAudioModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<span>Audio Files</span>
{#if !hasAudioModality}
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
{/if}
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
<span>Text Files</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
<span>PDF Files</span>
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
{/if}
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
<McpLogo class="h-4 w-4 shrink-0" />
<span>MCP Servers</span>
</button>
{#if hasMcpPromptsSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>
<Zap class="h-4 w-4 shrink-0" />
<span>MCP Prompt</span>
</button>
{/if}
{#if hasMcpResourcesSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpResourcesClick}>
<FolderOpen class="h-4 w-4 shrink-0" />
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
@@ -3,17 +3,24 @@
import { Button } from '$lib/components/ui/button';
import {
ChatFormActionAttachmentsDropdown,
ChatFormActionAttachmentsSheet,
ChatFormActionRecord,
ChatFormActionSubmit,
ModelsSelector
McpServersSelector,
ModelsSelector,
ModelsSelectorSheet
} from '$lib/components/app';
import { DialogChatSettings } from '$lib/components/app/dialogs';
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
interface Props {
canSend?: boolean;
@@ -27,6 +34,8 @@
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
@@ -40,22 +49,26 @@
onFileUpload,
onMicClick,
onStop,
onSystemPromptClick
onSystemPromptClick,
onMcpPromptClick,
onMcpResourcesClick
}: Props = $props();
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let isOffline = $derived(!!serverError());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let previousConversationModel: string | null = null;
$effect(() => {
if (conversationModel && conversationModel !== previousConversationModel) {
previousConversationModel = conversationModel;
if (conversationModel) {
modelsStore.selectModelByName(conversationModel);
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
// auto-select the first loaded model only when nothing is selected yet
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
@@ -148,32 +161,83 @@
return '';
});
let selectorModelRef: ModelsSelector | undefined = $state(undefined);
let selectorModelRef: ModelsSelector | ModelsSelectorSheet | undefined = $state(undefined);
let isMobile = new IsMobile();
export function openModelSelector() {
selectorModelRef?.open();
}
let showChatSettingsDialogWithMcpSection = $state(false);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<div class="mr-auto flex items-center gap-2">
<ChatFormActionAttachmentsDropdown
{#if isMobile.current}
<ChatFormActionAttachmentsSheet
{disabled}
{hasAudioModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
/>
{:else}
<ChatFormActionAttachmentsDropdown
{disabled}
{hasAudioModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
/>
{/if}
<McpServersSelector
{disabled}
{hasAudioModality}
{hasVisionModality}
{onFileUpload}
{onSystemPromptClick}
onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
/>
</div>
<div class="ml-auto flex items-center gap-1.5">
<ModelsSelector
{disabled}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText={true}
useGlobalSelection={true}
/>
{#if isMobile.current}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText
useGlobalSelection
/>
{:else}
<ModelsSelector
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText
useGlobalSelection
/>
{/if}
</div>
{#if isLoading}
@@ -201,3 +265,9 @@
/>
{/if}
</div>
<DialogChatSettings
open={showChatSettingsDialogWithMcpSection}
onOpenChange={(open) => (showChatSettingsDialogWithMcpSection = open)}
initialSection={SETTINGS_SECTION_TITLES.MCP}
/>
@@ -8,7 +8,7 @@
</script>
{#if show}
<div class="mt-4 flex items-center justify-center {className}">
<div class="mt-6 items-center justify-center {className} hidden md:flex">
<p class="text-xs text-muted-foreground">
Press <kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Enter</kbd> to send,
<kbd class="rounded bg-muted px-1 py-0.5 font-mono text-xs">Shift + Enter</kbd> for new line
@@ -0,0 +1,55 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
interface Props {
server: MCPServerSettingsEntry | undefined;
serverLabel: string;
title: string;
description?: string;
titleExtra?: Snippet;
subtitle?: Snippet;
}
let { server, serverLabel, title, description, titleExtra, subtitle }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{serverLabel}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{title}
</span>
{#if titleExtra}
{@render titleExtra()}
{/if}
</div>
{#if description}
<p class="mt-0.5 truncate text-sm text-muted-foreground">
{description}
</p>
{/if}
{#if subtitle}
{@render subtitle()}
{/if}
</div>
@@ -0,0 +1,82 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
interface Props {
items: T[];
isLoading: boolean;
selectedIndex: number;
searchQuery: string;
showSearchInput: boolean;
searchPlaceholder?: string;
emptyMessage?: string;
itemKey: (item: T, index: number) => string;
item: Snippet<[T, number, boolean]>;
skeleton?: Snippet;
footer?: Snippet;
}
let {
items,
isLoading,
selectedIndex,
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage = 'No items available',
itemKey,
item,
skeleton,
footer
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
$effect(() => {
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
const selectedElement = listContainer.querySelector(
`[data-picker-index="${selectedIndex}"]`
) as HTMLElement;
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
});
</script>
<ScrollArea>
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
</div>
{/if}
<div
bind:this={listContainer}
class="{CHAT_FORM_POPOVER_MAX_HEIGHT} p-2"
class:pt-13={showSearchInput}
>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{/if}
{:else if items.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{:else}
{#each items as itemData, index (itemKey(itemData, index))}
{@render item(itemData, index, index === selectedIndex)}
{/each}
{/if}
</div>
{#if footer}
{@render footer()}
{/if}
</ScrollArea>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
isSelected?: boolean;
onClick: () => void;
dataIndex?: number;
children: Snippet;
}
let { isSelected = false, onClick, dataIndex, children }: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
onclick={onClick}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"
>
{@render children()}
</button>
@@ -0,0 +1,30 @@
<script lang="ts">
interface Props {
titleWidth?: string;
showBadge?: boolean;
}
let { titleWidth = 'w-48', showBadge = false }: Props = $props();
</script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
<div class="min-w-0 flex-1 space-y-2">
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
<!-- Title skeleton -->
<div class="flex items-center gap-2">
<div class="h-4 {titleWidth} animate-pulse rounded bg-muted"></div>
{#if showBadge}
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
{/if}
</div>
<!-- Description skeleton -->
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
</div>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Popover from '$lib/components/ui/popover';
interface Props {
class?: string;
isOpen?: boolean;
srLabel?: string;
onClose?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
children: Snippet;
}
let {
class: className = '',
isOpen = $bindable(false),
srLabel = 'Open picker',
onClose,
onKeydown,
children
}: Props = $props();
</script>
<Popover.Root
bind:open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose?.();
}
}}
>
<Popover.Trigger class="pointer-events-none absolute inset-0 opacity-0">
<span class="sr-only">{srLabel}</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
onkeydown={onKeydown}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{@render children()}
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,435 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { debounce, uuid } from '$lib/utils';
import { KeyboardKey } from '$lib/enums';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import Badge from '$lib/components/ui/badge/badge.svelte';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
let isLoading = $state(false);
let selectedPrompt = $state<MCPPromptInfo | null>(null);
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadPrompts();
selectedIndex = 0;
} else {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
});
$effect(() => {
if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
selectedIndex = 0;
}
});
async function loadPrompts() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
prompts = [];
return;
}
prompts = await mcpStore.getAllPrompts();
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
prompts = [];
} finally {
isLoading = false;
}
}
function handlePromptClick(prompt: MCPPromptInfo) {
const args = prompt.arguments ?? [];
if (args.length > 0) {
selectedIndexBeforeArgumentForm = selectedIndex;
selectedPrompt = prompt;
promptArgs = {};
promptError = null;
requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
});
} else {
executePrompt(prompt, {});
}
}
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
promptError = null;
const placeholderId = uuid();
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
onClose?.();
try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage);
}
}
function handleArgumentSubmit(event: SubmitEvent) {
event.preventDefault();
if (selectedPrompt) {
executePrompt(selectedPrompt, promptArgs);
}
}
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (!selectedPrompt || value.length < 1) {
suggestions[argName] = [];
return;
}
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName,
value
});
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getPromptCompletions(
selectedPrompt.serverName,
selectedPrompt.name,
argName,
value
);
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Autocomplete result:', {
argName,
value,
result,
suggestionsCount: result?.values.length ?? 0
});
}
if (result && result.values.length > 0) {
// Filter out empty strings from suggestions
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
promptArgs[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
promptArgs[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
// Handle Escape - return to prompt selection list
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
handleCancelArgumentForm();
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
// Delay to allow click on suggestion
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
if ((suggestions[argName]?.length ?? 0) > 0) {
activeAutocomplete = argName;
}
}
function handleCancelArgumentForm() {
// Restore the previously selected prompt index
if (selectedIndexBeforeArgumentForm !== null) {
selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null;
}
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm();
} else {
onClose?.();
}
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault();
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
return true;
}
return false;
}
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServersSorted();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts;
return sortedPrompts.filter(
(prompt) =>
prompt.name.toLowerCase().includes(query) ||
prompt.title?.toLowerCase().includes(query) ||
prompt.description?.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(prompts.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open prompt picker"
{onClose}
onKeydown={handleKeydown}
>
{#if selectedPrompt}
{@const prompt = selectedPrompt}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<div class="p-4">
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
<ChatFormPromptPickerArgumentForm
prompt={selectedPrompt}
{promptArgs}
{suggestions}
{loadingSuggestions}
{activeAutocomplete}
{autocompleteIndex}
{promptError}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
onCancel={handleCancelArgumentForm}
/>
</div>
{:else}
<ChatFormPickerList
items={filteredPrompts}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onClick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
{/snippet}
</ChatFormPickerList>
{/if}
</ChatFormPickerPopover>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
prompt: MCPPromptInfo;
promptArgs: Record<string, string>;
suggestions: Record<string, string[]>;
loadingSuggestions: Record<string, boolean>;
activeAutocomplete: string | null;
autocompleteIndex: number;
promptError: string | null;
onArgInput: (argName: string, value: string) => void;
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
onArgBlur: (argName: string) => void;
onArgFocus: (argName: string) => void;
onSelectSuggestion: (argName: string, value: string) => void;
onSubmit: (event: SubmitEvent) => void;
onCancel: () => void;
}
let {
prompt,
promptArgs,
suggestions,
loadingSuggestions,
activeAutocomplete,
autocompleteIndex,
promptError,
onArgInput,
onArgKeydown,
onArgBlur,
onArgFocus,
onSelectSuggestion,
onSubmit,
onCancel
}: Props = $props();
</script>
<form onsubmit={onSubmit} class="space-y-3 pt-4">
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
value={promptArgs[arg.name] ?? ''}
suggestions={suggestions[arg.name] ?? []}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
isAutocompleteActive={activeAutocomplete === arg.name}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
/>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0">⚠</span>
<span>{promptError}</span>
</div>
{/if}
<div class="mt-8 flex justify-end gap-2">
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
<Button size="sm" type="submit">Use Prompt</Button>
</div>
</form>
@@ -0,0 +1,84 @@
<script lang="ts">
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
interface Props {
argument: PromptArgument;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
argument,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
onBlur,
onFocus,
onSelectSuggestion
}: Props = $props();
</script>
<div class="relative grid gap-1">
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
<span>
{argument.name}
{#if argument.required}
<span class="text-destructive">*</span>
{/if}
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</Label>
<Input
id="arg-{argument.name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
placeholder={argument.description || argument.name}
required={argument.required}
autocomplete="off"
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,236 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { KeyboardKey } from '$lib/enums';
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onResourceSelect?: (resource: MCPResourceInfo) => void;
onBrowse?: () => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onResourceSelect,
onBrowse
}: Props = $props();
let resources = $state<MCPResourceInfo[]>([]);
let isLoading = $state(false);
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadResources();
selectedIndex = 0;
}
});
$effect(() => {
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
selectedIndex = 0;
}
});
async function loadResources() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
resources = [];
return;
}
await mcpStore.fetchAllResources();
resources = mcpResourceStore.getAllResourceInfos();
} catch (error) {
console.error('[ChatFormResourcePicker] Failed to load resources:', error);
resources = [];
} finally {
isLoading = false;
}
}
function handleResourceClick(resource: MCPResourceInfo) {
mcpStore.attachResource(resource.uri);
onResourceSelect?.(resource);
onClose?.();
}
function isResourceAttached(uri: string): boolean {
return mcpResourceStore.isAttached(uri);
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
onClose?.();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredResources.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (filteredResources[selectedIndex]) {
handleResourceClick(filteredResources[selectedIndex]);
}
return true;
}
return false;
}
let filteredResources = $derived.by(() => {
const sortedServers = mcpStore.getServersSorted();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedResources = [...resources].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedResources;
return sortedResources.filter(
(resource) =>
resource.name.toLowerCase().includes(query) ||
resource.title?.toLowerCase().includes(query) ||
resource.description?.toLowerCase().includes(query) ||
resource.uri.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(resources.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open resource picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredResources}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search resources..."
emptyMessage="No MCP resources available"
itemKey={(resource) => resource.serverName + ':' + resource.uri}
>
{#snippet item(resource, index, isSelected)}
{@const server = serverSettingsMap.get(resource.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onClick={() => handleResourceClick(resource)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={resource.title || resource.name}
description={resource.description}
>
{#snippet titleExtra()}
{#if isResourceAttached(resource.uri)}
<span
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>
attached
</span>
{/if}
{/snippet}
{#snippet subtitle()}
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
{resource.uri}
</p>
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton />
{/snippet}
{#snippet footer()}
{#if onBrowse && resources.length > 3}
<Button
class="fixed right-3 bottom-3"
type="button"
onclick={onBrowse}
variant="secondary"
size="sm"
>
<FolderOpen class="h-3 w-3" />
Browse all
</Button>
{/if}
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -5,14 +5,16 @@
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { MessageRole } from '$lib/enums';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole, AttachmentType } from '$lib/enums';
import {
ChatMessageAssistant,
ChatMessageUser,
ChatMessageSystem
ChatMessageSystem,
ChatMessageMcpPrompt
} from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
@@ -83,6 +85,20 @@
startEdit: handleEdit
});
let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null;
if (message.content.trim()) return null;
if (!message.extra || message.extra.length !== 1) return null;
const extra = message.extra[0];
if (extra.type === AttachmentType.MCP_PROMPT) {
return extra as DatabaseMessageExtraMcpPrompt;
}
return null;
});
$effect(() => {
const pendingId = pendingEditMessageId();
@@ -245,6 +261,21 @@
{showDeleteDialog}
{siblingInfo}
/>
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt
class={className}
{deletionInfo}
{message}
mcpPrompt={mcpPromptExtra}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if message.role === MessageRole.USER}
<ChatMessageUser
class={className}
@@ -0,0 +1,354 @@
<script lang="ts">
import {
ChatMessageStatistics,
CollapsibleContentBlock,
MarkdownContent,
SyntaxHighlightedCode
} from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums';
import { formatJsonPretty } from '$lib/utils';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import { parseAgenticContent, type AgenticSection } from '$lib/utils';
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database';
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
import { ChatMessageStatsView } from '$lib/enums';
interface Props {
message?: DatabaseMessage;
content: string;
isStreaming?: boolean;
highlightTurns?: boolean;
}
type ToolResultLine = {
text: string;
image?: DatabaseMessageExtraImageFile;
};
let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
const sections = $derived(parseAgenticContent(content));
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
// Parse toolResults with images only when sections or message.extra change
const sectionsParsed = $derived(
sections.map((section) => ({
...section,
parsedLines: section.toolResult
? parseToolResultWithImages(section.toolResult, message?.extra)
: []
}))
);
// Group flat sections into agentic turns
// A new turn starts when a non-tool section follows a tool section
const turnGroups = $derived.by(() => {
const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
let currentTurn: (typeof sectionsParsed)[number][] = [];
let currentIndices: number[] = [];
let prevWasTool = false;
for (let i = 0; i < sectionsParsed.length; i++) {
const section = sectionsParsed[i];
const isTool =
section.type === AgenticSectionType.TOOL_CALL ||
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
if (!isTool && prevWasTool && currentTurn.length > 0) {
turns.push({ sections: currentTurn, flatIndices: currentIndices });
currentTurn = [];
currentIndices = [];
}
currentTurn.push(section);
currentIndices.push(i);
prevWasTool = isTool;
}
if (currentTurn.length > 0) {
turns.push({ sections: currentTurn, flatIndices: currentIndices });
}
return turns;
});
function getDefaultExpanded(section: AgenticSection): boolean {
if (
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
section.type === AgenticSectionType.TOOL_CALL_STREAMING
) {
return showToolCallInProgress;
}
if (section.type === AgenticSectionType.REASONING_PENDING) {
return showThoughtInProgress;
}
return false;
}
function isExpanded(index: number, section: AgenticSection): boolean {
if (expandedStates[index] !== undefined) {
return expandedStates[index];
}
return getDefaultExpanded(section);
}
function toggleExpanded(index: number, section: AgenticSection) {
const currentState = isExpanded(index, section);
expandedStates[index] = !currentState;
}
function parseToolResultWithImages(
toolResult: string,
extras?: DatabaseMessage['extra']
): ToolResultLine[] {
const lines = toolResult.split(NEWLINE_SEPARATOR);
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
const attachmentName = match[1];
const image = extras.find(
(e): e is DatabaseMessageExtraImageFile =>
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
return { text: line, image };
});
}
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
return {
turns: 1,
toolCallsCount: stats.toolCalls.length,
toolsMs: stats.toolsMs,
toolCalls: stats.toolCalls,
llm: stats.llm
};
}
</script>
{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
{#if section.type === AgenticSectionType.TEXT}
<div class="agentic-text">
<MarkdownContent content={section.content} attachments={message?.extra} />
</div>
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
{@const streamingIcon = isStreaming ? Loader2 : AlertTriangle}
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'}
{@const streamingSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={streamingIcon}
iconClass={streamingIconClass}
title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
{#if isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
{:else if isStreaming}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{:else}
<div
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
>
Response was truncated
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const toolIcon = isPending ? Loader2 : Wrench}
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
title={section.toolName || ''}
subtitle={isPending ? 'executing...' : undefined}
isStreaming={isPending}
onToggle={() => toggleExpanded(index, section)}
>
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language={FileTypeText.JSON}
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Result:</span>
{#if isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
{#each section.parsedLines as line, i (i)}
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.image}
<img
src={line.image.base64Url}
alt={line.image.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
/>
{/if}
{/each}
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title="Reasoning"
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
icon={Brain}
title={reasoningTitle}
subtitle={reasoningSubtitle}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
</div>
</CollapsibleContentBlock>
{/if}
{/snippet}
<div class="agentic-content">
{#if highlightTurns && turnGroups.length > 1}
{#each turnGroups as turn, turnIndex (turnIndex)}
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
<div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
<span class="agentic-turn-label">Turn {turnIndex + 1}</span>
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
{@render renderSection(section, turn.flatIndices[sIdx])}
{/each}
{#if turnStats}
<div class="turn-stats">
<ChatMessageStatistics
promptTokens={turnStats.llm.prompt_n}
promptMs={turnStats.llm.prompt_ms}
predictedTokens={turnStats.llm.predicted_n}
predictedMs={turnStats.llm.predicted_ms}
agenticTimings={turnStats.toolCalls.length > 0
? buildTurnAgenticTimings(turnStats)
: undefined}
initialView={ChatMessageStatsView.GENERATION}
hideSummary
/>
</div>
{/if}
</div>
{/each}
{:else}
{#each sectionsParsed as section, index (index)}
{@render renderSection(section, index)}
{/each}
{/if}
</div>
<style>
.agentic-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 48rem;
}
.agentic-text {
width: 100%;
}
.agentic-turn {
position: relative;
border: 1.5px dashed var(--muted-foreground);
border-radius: 0.75rem;
padding: 1rem;
transition: background 0.1s;
}
.agentic-turn-label {
position: absolute;
top: -1rem;
left: 0.75rem;
padding: 0 0.375rem;
background: var(--background);
font-size: 0.7rem;
font-weight: 500;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.turn-stats {
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--muted) / 0.5);
}
</style>
@@ -1,29 +1,29 @@
<script lang="ts">
import {
ChatMessageAgenticContent,
ChatMessageActions,
ChatMessageStatistics,
MarkdownContent,
ModelBadge,
ModelsSelector
} from '$lib/components/app';
import ChatMessageThinkingBlock from './ChatMessageThinkingBlock.svelte';
import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
import { tick } from 'svelte';
import { fade } from 'svelte/transition';
import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { MessageRole, KeyboardKey } from '$lib/enums';
import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants';
import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
import { REASONING_TAGS } from '$lib/constants/agentic';
interface Props {
class?: string;
@@ -49,58 +49,6 @@
textareaElement?: HTMLTextAreaElement;
}
interface ParsedReasoningContent {
content: string;
reasoningContent: string | null;
hasReasoningMarkers: boolean;
}
function parseReasoningContent(content: string | undefined): ParsedReasoningContent {
if (!content) {
return {
content: '',
reasoningContent: null,
hasReasoningMarkers: false
};
}
const plainParts: string[] = [];
const reasoningParts: string[] = [];
const { START, END } = REASONING_TAGS;
let cursor = 0;
let hasReasoningMarkers = false;
while (cursor < content.length) {
const startIndex = content.indexOf(START, cursor);
if (startIndex === -1) {
plainParts.push(content.slice(cursor));
break;
}
hasReasoningMarkers = true;
plainParts.push(content.slice(cursor, startIndex));
const reasoningStart = startIndex + START.length;
const endIndex = content.indexOf(END, reasoningStart);
if (endIndex === -1) {
reasoningParts.push(content.slice(reasoningStart));
cursor = content.length;
break;
}
reasoningParts.push(content.slice(reasoningStart, endIndex));
cursor = endIndex + END.length;
}
return {
content: plainParts.join(''),
reasoningContent: reasoningParts.length > 0 ? reasoningParts.join('\n\n') : null,
hasReasoningMarkers
};
}
let {
class: className = '',
deletionInfo,
@@ -136,15 +84,22 @@
}
}
const parsedMessageContent = $derived.by(() => parseReasoningContent(messageContent));
const visibleMessageContent = $derived(parsedMessageContent.content);
const thinkingContent = $derived(parsedMessageContent.reasoningContent);
const hasReasoningMarkers = $derived(parsedMessageContent.hasReasoningMarkers);
const hasAgenticMarkers = $derived(
messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
);
const hasStreamingToolCall = $derived(
isChatStreaming() && agenticStreamingToolCall(message.convId) !== null
);
const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
const isStructuredContent = $derived(
hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall
);
const processingState = useProcessingState();
let currentConfig = $derived(config());
let isRouter = $derived(isRouterMode());
let showRawOutput = $state(false);
let activeStatsView = $state<ChatMessageStatsView>(ChatMessageStatsView.GENERATION);
let statsContainerEl: HTMLDivElement | undefined = $state();
function getScrollParent(el: HTMLElement): HTMLElement | null {
@@ -159,19 +114,25 @@
return null;
}
async function handleStatsViewChange() {
async function handleStatsViewChange(view: ChatMessageStatsView) {
const el = statsContainerEl;
if (!el) {
activeStatsView = view;
return;
}
const scrollParent = getScrollParent(el);
if (!scrollParent) {
activeStatsView = view;
return;
}
const yBefore = el.getBoundingClientRect().top;
activeStatsView = view;
await tick();
const delta = el.getBoundingClientRect().top - yBefore;
@@ -189,11 +150,16 @@
});
}
let highlightAgenticTurns = $derived(
hasAgenticMarkers &&
(currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
);
let displayedModel = $derived(message.model ?? null);
let isCurrentlyLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming());
let hasNoContent = $derived(!visibleMessageContent?.trim());
let hasNoContent = $derived(!message?.content?.trim());
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
let showProcessingInfoTop = $derived(
@@ -232,14 +198,6 @@
role="group"
aria-label="Assistant message with actions"
>
{#if !editCtx.isEditing && thinkingContent}
<ChatMessageThinkingBlock
reasoningContent={thinkingContent}
isStreaming={!message.timestamp}
hasRegularContent={!!visibleMessageContent?.trim()}
/>
{/if}
{#if showProcessingInfoTop}
<div class="mt-6 w-full max-w-[48rem]" in:fade>
<div class="processing-container">
@@ -298,8 +256,15 @@
{:else if message.role === MessageRole.ASSISTANT}
{#if showRawOutput}
<pre class="raw-output">{messageContent || ''}</pre>
{:else if isStructuredContent}
<ChatMessageAgenticContent
content={messageContent || ''}
isStreaming={isChatStreaming()}
highlightTurns={highlightAgenticTurns}
{message}
/>
{:else}
<MarkdownContent content={visibleMessageContent || ''} attachments={message.extra} />
<MarkdownContent content={messageContent || ''} attachments={message.extra} />
{/if}
{:else}
<div class="text-sm whitespace-pre-wrap">
@@ -345,11 +310,13 @@
{/if}
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
promptTokens={message.timings.prompt_n}
promptMs={message.timings.prompt_ms}
predictedTokens={message.timings.predicted_n}
predictedMs={message.timings.predicted_ms}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
agenticTimings={agentic}
onActiveViewChange={handleStatsViewChange}
/>
{:else if isLoading() && currentConfig.showMessageStats}
@@ -361,7 +328,7 @@
{#if liveStats || genStats}
<ChatMessageStatistics
isLive={true}
isLive
isProcessingPrompt={!!isStillProcessingPrompt}
promptTokens={liveStats?.tokensProcessed}
promptMs={liveStats?.timeMs}
@@ -77,6 +77,10 @@
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
}
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
editCtx.setUploadedFiles(files);
}
$effect(() => {
chatStore.setEditModeActive(handleFilesAdd);
@@ -95,9 +99,11 @@
attachments={editCtx.editedExtras}
uploadedFiles={editCtx.editedUploadedFiles}
placeholder="Edit your message..."
showMcpPromptButton
onValueChange={editCtx.setContent}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
onUploadedFilesChange={handleUploadedFilesChange}
onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit}
/>
@@ -0,0 +1,83 @@
<script lang="ts">
import {
ChatMessageActions,
ChatMessageEditForm,
ChatMessageMcpPromptContent
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { MessageRole, McpPromptVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
message: DatabaseMessage;
mcpPrompt: DatabaseMessageExtraMcpPrompt;
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
}
let {
class: className = '',
message,
mcpPrompt,
siblingInfo = null,
showDeleteDialog,
deletionInfo,
onCopy,
onEdit,
onDelete,
onConfirmDelete,
onNavigateToSibling,
onShowDeleteDialogChange
}: Props = $props();
// Get edit context
const editCtx = getMessageEditContext();
</script>
<div
aria-label="MCP Prompt message with actions"
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
<ChatMessageMcpPromptContent
prompt={mcpPrompt}
variant={McpPromptVariant.MESSAGE}
class="w-full max-w-[80%]"
/>
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role={MessageRole.USER}
/>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,197 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { McpPromptVariant } from '$lib/enums';
import { TruncatedText } from '$lib/components/app/misc';
import * as Tooltip from '$lib/components/ui/tooltip';
interface ContentPart {
text: string;
argKey: string | null;
}
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
variant?: McpPromptVariant;
isLoading?: boolean;
loadError?: string;
}
let {
class: className = '',
prompt,
variant = McpPromptVariant.MESSAGE,
isLoading = false,
loadError
}: Props = $props();
let hoveredArgKey = $state<string | null>(null);
let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) {
return [{ text: prompt.content || '', argKey: null }];
}
const parts: ContentPart[] = [];
let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>();
for (const [key, value] of argumentEntries) {
if (value && value.trim()) {
valueToKey.set(value, key);
}
}
const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
while (remaining.length > 0) {
let earliestMatch: { index: number; value: string; key: string } | null = null;
for (const value of sortedValues) {
const index = remaining.indexOf(value);
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
earliestMatch = { index, value, key: valueToKey.get(value)! };
}
}
if (earliestMatch) {
if (earliestMatch.index > 0) {
parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
}
parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else {
parts.push({ text: remaining, argKey: null });
break;
}
}
return parts;
});
let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
let maxHeightStyle = $derived(
isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
);
const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
</script>
<div class="flex flex-col gap-2 {className}">
<div class="flex items-center justify-between gap-2">
<div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
<Tooltip.Root>
<Tooltip.Trigger>
{#if serverFavicon}
<img
src={serverFavicon}
alt=""
class="h-3.5 w-3.5 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<span>{serverDisplayName}</span>
</Tooltip.Content>
</Tooltip.Root>
<TruncatedText text={prompt.name} />
</div>
{#if showArgBadges}
<div class="flex flex-wrap justify-end gap-1">
{#each argumentEntries as [key, value] (key)}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="rounded-sm bg-purple-200/60 px-1.5 py-0.5 text-[10px] leading-none text-purple-700 transition-opacity dark:bg-purple-800/40 dark:text-purple-300 {hoveredArgKey &&
hoveredArgKey !== key
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = key)}
onmouseleave={() => (hoveredArgKey = null)}
>
{key}
</span>
</Tooltip.Trigger>
<Tooltip.Content>
<span class="max-w-xs break-all">{value}</span>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{/if}
</div>
{#if loadError}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<span class="{textSizeClass} text-destructive">{loadError}</span>
</div>
</Card>
{:else if isLoading}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-1 py-2 backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<div class="space-y-2">
<div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
</div>
</div>
</Card>
{:else if hasContent}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 py-0 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<span class="{textSizeClass} whitespace-pre-wrap">
<!-- This formatting is needed to keep the text in proper shape -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each contentParts as part, i (i)}{#if part.argKey}<span
class="rounded-sm bg-purple-300/50 px-0.5 text-purple-900 transition-opacity dark:bg-purple-700/50 dark:text-purple-100 {hoveredArgKey &&
hoveredArgKey !== part.argKey
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = part.argKey)}
onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
>{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"
>{part.text}</span
>{/if}{/each}</span
>
</div>
</Card>
{/if}
</div>
@@ -1,10 +1,11 @@
<script lang="ts">
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles } from '@lucide/svelte';
import { Clock, Gauge, WholeWord, BookOpenText, Sparkles, Wrench, Layers } from '@lucide/svelte';
import { BadgeChatStatistic } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ChatMessageStatsView } from '$lib/enums';
import type { ChatMessageAgenticTimings } from '$lib/types/chat';
import { formatPerformanceTime } from '$lib/utils';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants/formatters';
import { MS_PER_SECOND, DEFAULT_PERFORMANCE_TIME } from '$lib/constants';
interface Props {
predictedTokens?: number;
@@ -14,7 +15,9 @@
isLive?: boolean;
isProcessingPrompt?: boolean;
initialView?: ChatMessageStatsView;
agenticTimings?: ChatMessageAgenticTimings;
onActiveViewChange?: (view: ChatMessageStatsView) => void;
hideSummary?: boolean;
}
let {
@@ -25,7 +28,9 @@
isLive = false,
isProcessingPrompt = false,
initialView = ChatMessageStatsView.GENERATION,
onActiveViewChange
agenticTimings,
onActiveViewChange,
hideSummary = false
}: Props = $props();
let activeView: ChatMessageStatsView = $derived(initialView);
@@ -87,6 +92,26 @@
// In live mode, generation tab is disabled until we have generation stats
let isGenerationDisabled = $derived(isLive && !hasGenerationStats);
let hasAgenticStats = $derived(agenticTimings !== undefined && agenticTimings.toolCallsCount > 0);
let agenticToolsPerSecond = $derived(
hasAgenticStats && agenticTimings!.toolsMs > 0
? (agenticTimings!.toolCallsCount / agenticTimings!.toolsMs) * MS_PER_SECOND
: 0
);
let formattedAgenticToolsTime = $derived(
hasAgenticStats ? formatPerformanceTime(agenticTimings!.toolsMs) : DEFAULT_PERFORMANCE_TIME
);
let agenticTotalTimeMs = $derived(
hasAgenticStats
? agenticTimings!.toolsMs + agenticTimings!.llm.predicted_ms + agenticTimings!.llm.prompt_ms
: 0
);
let formattedAgenticTotalTime = $derived(formatPerformanceTime(agenticTotalTimeMs));
</script>
<div class="inline-flex items-center text-xs text-muted-foreground">
@@ -140,6 +165,52 @@
</p>
</Tooltip.Content>
</Tooltip.Root>
{#if hasAgenticStats}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.TOOLS
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.TOOLS)}
>
<Wrench class="h-3 w-3" />
<span class="sr-only">Tools</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool calls</p>
</Tooltip.Content>
</Tooltip.Root>
{#if !hideSummary}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
ChatMessageStatsView.SUMMARY
? 'bg-background text-foreground shadow-sm'
: 'hover:text-foreground'}"
onclick={() => (activeView = ChatMessageStatsView.SUMMARY)}
>
<Layers class="h-3 w-3" />
<span class="sr-only">Summary</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Agentic summary</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/if}
</div>
<div class="flex items-center gap-1 px-2">
@@ -164,6 +235,48 @@
value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed"
/>
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
<BadgeChatStatistic
class="bg-transparent"
icon={Wrench}
value="{agenticTimings!.toolCallsCount} calls"
tooltipLabel="Tool calls executed"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedAgenticToolsTime}
tooltipLabel="Tool execution time"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Gauge}
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
tooltipLabel="Tool execution rate"
/>
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
<BadgeChatStatistic
class="bg-transparent"
icon={Layers}
value="{agenticTimings!.turns} turns"
tooltipLabel="Agentic turns (LLM calls)"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={WholeWord}
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
tooltipLabel="Total tokens generated"
/>
<BadgeChatStatistic
class="bg-transparent"
icon={Clock}
value={formattedAgenticTotalTime}
tooltipLabel="Total time (LLM + tools)"
/>
{:else if hasPromptStats}
<BadgeChatStatistic
class="bg-transparent"
@@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button';
import { MarkdownContent } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { INPUT_CLASSES } from '$lib/constants';
import { config } from '$lib/stores/settings.svelte';
import { isIMEComposing } from '$lib/utils';
import ChatMessageActions from './ChatMessageActions.svelte';
@@ -146,7 +146,7 @@
<Card
class="overflow-y-auto rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined}
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height);"
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
>
<div
class="relative transition-all duration-300 {isExpanded
@@ -157,9 +157,9 @@
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent
class="markdown-system-content overflow-auto"
class="markdown-system-content -my-4"
content={message.content}
/>
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import { Brain } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import { config } from '$lib/stores/settings.svelte';
interface Props {
class?: string;
hasRegularContent?: boolean;
isStreaming?: boolean;
reasoningContent: string | null;
}
let {
class: className = '',
hasRegularContent = false,
isStreaming = false,
reasoningContent
}: Props = $props();
const currentConfig = config();
let isExpanded = $state(currentConfig.showThoughtInProgress);
$effect(() => {
if (hasRegularContent && reasoningContent && currentConfig.showThoughtInProgress) {
isExpanded = false;
}
});
</script>
<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
<div class="flex items-center gap-2 text-muted-foreground">
<Brain class="h-4 w-4" />
<span class="text-sm font-medium">
{isStreaming ? 'Reasoning...' : 'Reasoning'}
</span>
</div>
<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />
<span class="sr-only">Toggle reasoning content</span>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="border-t border-muted px-3 pb-3">
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{reasoningContent ?? ''}
</div>
</div>
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>
@@ -82,7 +82,7 @@
{:else}
{#if message.extra && message.extra.length > 0}
<div class="mb-2 max-w-[80%]">
<ChatAttachmentsList attachments={message.extra} readonly={true} imageHeight="h-80" />
<ChatAttachmentsList attachments={message.extra} readonly imageHeight="h-80" />
</div>
{/if}
@@ -12,7 +12,7 @@
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll';
import { INITIAL_SCROLL_DELAY } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import {
@@ -428,9 +428,9 @@
>
<div class="w-full max-w-[48rem] px-4">
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">llama.cpp</h1>
<p class="text-lg text-muted-foreground">
<p class="text-muted-foreground md:text-lg">
{serverStore.props?.modalities?.audio
? 'Record audio, type a message '
: 'Type a message'} or upload files to get started
@@ -441,8 +441,10 @@
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span>
<button
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
@@ -452,6 +454,7 @@
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</Alert.Title>
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
@@ -467,7 +470,7 @@
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
showHelperText={true}
showHelperText
bind:uploadedFiles
/>
</div>
@@ -110,6 +110,7 @@
class={className}
{disabled}
{isLoading}
showMcpPromptButton
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
@@ -14,14 +14,14 @@
</script>
<header
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-4 duration-200 ease-linear {sidebar.open
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-2 duration-200 ease-linear md:p-4 {sidebar.open
? 'md:left-[var(--sidebar-width)]'
: ''}"
>
<div class="pointer-events-auto flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
size="icon-lg"
onclick={toggleSettings}
class="rounded-full backdrop-blur-lg"
>
@@ -1,6 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants/processing-info';
import { PROCESSING_INFO_TIMEOUT } from '$lib/constants';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
@@ -12,21 +12,24 @@
import {
ChatSettingsFooter,
ChatSettingsImportExportTab,
ChatSettingsFields
ChatSettingsFields,
McpLogo,
McpServersSettings
} from '$lib/components/app';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
SETTINGS_SECTION_TITLES,
type SettingsSectionTitle
} from '$lib/constants/settings-sections';
type SettingsSectionTitle,
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_COLOR_MODES_CONFIG,
SETTINGS_KEYS
} from '$lib/constants';
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import type { Component } from 'svelte';
import { NUMERIC_FIELDS, POSITIVE_INTEGER_FIELDS } from '$lib/constants/settings-fields';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
interface Props {
onSave?: () => void;
@@ -114,6 +117,11 @@
label: 'Render user content as Markdown',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
@@ -128,6 +136,11 @@
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
}
]
},
@@ -248,6 +261,32 @@
icon: Database,
fields: []
},
{
title: SETTINGS_SECTION_TITLES.MCP,
icon: McpLogo,
fields: [
{
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic loop max turns',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.DEVELOPER,
icon: Code,
@@ -420,7 +459,7 @@
<!-- Mobile Header with Horizontal Scrollable Menu -->
<div class="flex flex-col pt-6 md:hidden">
<div class="border-b border-border/30 py-4">
<div class="border-b border-border/30 pt-4 md:py-4">
<!-- Horizontal Scrollable Category Menu with Navigation -->
<div class="relative flex items-center" style="scroll-padding: 1rem;">
<button
@@ -481,6 +520,19 @@
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
<ChatSettingsImportExportTab />
{:else if currentSection.title === SETTINGS_SECTION_TITLES.MCP}
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
<div class="border-t border-border/30 pt-6">
<McpServersSettings />
</div>
</div>
{:else}
<div class="space-y-6">
<ChatSettingsFields
@@ -5,8 +5,7 @@
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO, SETTINGS_KEYS } from '$lib/constants';
import { SettingsFieldType } from '$lib/enums/settings';
import { settingsStore } from '$lib/stores/settings.svelte';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
@@ -3,6 +3,7 @@
import { Button } from '$lib/components/ui/button';
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
import { createMessageCountMap } from '$lib/utils';
import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants';
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import { toast } from 'svelte-sonner';
@@ -55,18 +56,10 @@
})
);
const blob = new Blob([JSON.stringify(allData, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
conversationsStore.downloadConversationFile(
allData,
`${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`
);
exportedConversations = selectedConversations;
showExportSummary = true;
@@ -29,6 +29,7 @@
* - Horizontal scroll with smooth navigation arrows
* - Image thumbnails with lazy loading and error fallback
* - File type icons for non-image files (PDF, text, audio, etc.)
* - MCP prompt attachments with expandable content preview
* - Click-to-preview with full-size dialog and download option
* - "View All" button when `limitToSingleRow` is enabled and content overflows
* - Vision modality validation to warn about unsupported image uploads
@@ -50,6 +51,33 @@
*/
export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachmentsList.svelte';
/**
* Displays MCP Prompt attachment with expandable content preview.
* Shows server name, prompt name, and allows expanding to view full prompt arguments
* and content. Used when user selects a prompt from ChatFormPromptPicker.
*/
export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttachmentMcpPrompt.svelte';
/**
* Displays a single MCP Resource attachment with icon, name, and server info.
* Shows loading/error states and supports remove action.
* Used within ChatAttachmentMcpResources for individual resource display.
*/
export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
/**
* Full-size attachment preview component for dialog display. Handles different file types:
* images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),
* audio (placeholder with download), and generic files (download option).
*/
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
/**
* Displays MCP Resource attachments as a horizontal carousel.
* Shows resource name, URI, and allows clicking to view resource content.
*/
export { default as ChatAttachmentMcpResources } from './ChatAttachments/ChatAttachmentMcpResources.svelte';
/**
* Thumbnail for non-image file attachments. Displays file type icon based on extension,
* file name (truncated), and file size.
@@ -71,20 +99,15 @@ export { default as ChatAttachmentThumbnailImage } from './ChatAttachments/ChatA
*/
export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachmentsViewAll.svelte';
/**
* Full-size preview dialog for attachments. Opens when clicking on any attachment
* thumbnail. Shows the attachment in full size with options to download or close.
* Handles both image and non-image attachments with appropriate rendering.
*/
export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachmentPreview.svelte';
/**
*
* FORM
*
* Components for the chat input area. The form handles user input, file attachments,
* audio recording. It integrates with multiple stores:
* audio recording, and MCP prompts & resources selection. It integrates with multiple stores:
* - `chatStore` for message submission and generation control
* - `modelsStore` for model selection and validation
* - `mcpStore` for MCP prompt browsing and loading
*
* The form exposes a public API for programmatic control from parent components
* (focus, height reset, model selector, validation).
@@ -95,7 +118,7 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
* **ChatForm** - Main chat input component with rich features
*
* The primary input interface for composing and sending chat messages.
* Orchestrates text input, file attachments, audio recording.
* Orchestrates text input, file attachments, audio recording, and MCP prompts.
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
@@ -108,11 +131,14 @@ export { default as ChatAttachmentPreview } from './ChatAttachments/ChatAttachme
* - IME-safe Enter key handling (waits for composition end)
* - Shift+Enter for newline, Enter for submit
* - Paste handler for files and long text (> {pasteLongTextToFileLen} chars → file conversion)
* - Keyboard shortcut `/` triggers MCP prompt picker
*
* **Features:**
* - Auto-resizing textarea with placeholder
* - File upload via button dropdown (images/text/PDF), drag-drop, or paste
* - Audio recording with WAV conversion (when model supports audio)
* - MCP prompt picker with search and argument forms
* - MCP reource picker with component to list attached resources at the bottom of Chat Form
* - Model selector integration (router mode)
* - Loading state with stop button, disabled state for errors
*
@@ -144,6 +170,13 @@ export { default as ChatForm } from './ChatForm/ChatForm.svelte';
*/
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
/**
* Mobile sheet variant of the file attachment selector. Renders a bottom sheet
* with the same options as ChatFormActionAttachmentsDropdown, optimized for
* touch interaction on mobile devices.
*/
export { default as ChatFormActionAttachmentsSheet } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte';
/**
* Audio recording button with real-time recording indicator. Records audio
* and converts to WAV format for upload. Only visible when the active model
@@ -182,6 +215,118 @@ export { default as ChatFormHelperText } from './ChatForm/ChatFormHelperText.sve
*/
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
/**
* **ChatFormPromptPicker** - MCP prompt selection interface
*
* Floating picker for browsing and selecting MCP Server Prompts.
* Triggered by typing `/` in the chat input or choosing `MCP Prompt` option in ChatFormActionAttachmentsDropdown.
* Loads prompts from connected MCP servers and allows users to select and configure them.
*
* **Architecture:**
* - Fetches available prompts from mcpStore
* - Manages selection state and keyboard navigation internally
* - Delegates argument input to ChatFormPromptPickerArgumentForm
* - Communicates prompt loading lifecycle via callbacks
*
* **Prompt Loading Flow:**
* 1. User selects prompt → `onPromptLoadStart` called with placeholder ID
* 2. Prompt content fetched from MCP server asynchronously
* 3. On success → `onPromptLoadComplete` with full prompt data
* 4. On failure → `onPromptLoadError` with error details
*
* **Features:**
* - Search/filter prompts by name across all connected servers
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
* - Argument input forms for prompts with required parameters
* - Autocomplete suggestions for argument values
* - Loading states with skeleton placeholders
* - Server information header per prompt for visual identification
*
* **Exported API:**
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
*
* @example
* ```svelte
* <ChatFormPromptPicker
* bind:this={pickerRef}
* isOpen={showPicker}
* searchQuery={promptQuery}
* onClose={() => showPicker = false}
* onPromptLoadStart={(id, info) => addPlaceholder(id, info)}
* onPromptLoadComplete={(id, result) => replacePlaceholder(id, result)}
* onPromptLoadError={(id, error) => handleError(id, error)}
* />
* ```
*/
export { default as ChatFormPromptPicker } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte';
/**
* Form for entering MCP prompt arguments. Displays input fields for each
* required argument defined by the prompt. Validates input and submits
* when all required fields are filled. Shows argument descriptions as hints.
*/
export { default as ChatFormPromptPickerArgumentForm } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentForm.svelte';
/**
* Single argument input field with autocomplete suggestions. Fetches suggestions
* from MCP server based on argument type. Supports keyboard navigation through
* suggestions list. Used within ChatFormPromptPickerArgumentForm.
*/
export { default as ChatFormPromptPickerArgumentInput } from './ChatForm/ChatFormPromptPicker/ChatFormPromptPickerArgumentInput.svelte';
/**
* Shared popover wrapper for inline picker popovers (prompts, resources).
* Provides consistent positioning, styling, and open/close behavior.
*/
export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickerPopover.svelte';
/**
* Generic scrollable list for picker popovers. Provides search input,
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
* Shared by ChatFormPromptPicker and ChatFormResourcePicker.
*/
export { default as ChatFormPickerList } from './ChatForm/ChatFormPicker/ChatFormPickerList.svelte';
/**
* Generic button wrapper for picker list items. Provides consistent styling,
* hover/selected states, and data-picker-index attribute for scroll-into-view.
* Shared by ChatFormPromptPicker and ChatFormResourcePicker.
*/
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPicker/ChatFormPickerListItem.svelte';
/**
* Generic header for picker items displaying server favicon, label, item title,
* and optional description. Accepts `titleExtra` and `subtitle` snippets for
* custom content like badges or URIs. Shared by both pickers.
*/
export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPicker/ChatFormPickerItemHeader.svelte';
/**
* Generic skeleton loading placeholder for picker list items. Configurable
* title width and optional badge skeleton. Shared by both pickers.
*/
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
/**
* **ChatFormResourcePicker** - MCP resource selection interface
*
* Floating picker for browsing and attaching MCP Server Resources.
* Triggered by typing `@` in the chat input.
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
*
* **Features:**
* - Search/filter resources by name, title, description, or URI across all connected servers
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
* - Shows attached state for already-attached resources
* - Loading states with skeleton placeholders
* - Server information header per resource for visual identification
*
* **Exported API:**
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
*/
export { default as ChatFormResourcePicker } from './ChatForm/ChatFormResourcePicker/ChatFormResourcePicker.svelte';
/**
*
* MESSAGES
@@ -248,6 +393,7 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
*
* **User Messages:**
* - Shows attachments via ChatAttachmentsList
* - Displays MCP prompts if present
* - Edit creates new branch or preserves responses
*
* **Assistant Messages:**
@@ -276,6 +422,46 @@ export { default as ChatMessages } from './ChatMessages/ChatMessages.svelte';
*/
export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
/**
* **ChatMessageAgenticContent** - Agentic workflow output display
*
* Specialized renderer for assistant messages containing agentic workflow markers.
* Parses structured content and displays tool calls and reasoning blocks as
* interactive collapsible sections with real-time streaming support.
*
* **Architecture:**
* - Uses `parseAgenticContent()` from `$lib/utils` to parse markers
* - Renders sections as CollapsibleContentBlock components
* - Handles streaming state for progressive content display
* - Falls back to MarkdownContent for plain text sections
*
* **Marker Format:**
* - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
* - Reasoning: in constants/agentic.ts (REASONING_TAGS)
* - Partial markers handled gracefully during streaming
*
* **Execution States:**
* - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
* - **Pending**: Waiting indicator for queued tool calls
* - **Completed**: Static display, block collapsed by default
*
* **Features:**
* - JSON arguments syntax highlighting via SyntaxHighlightedCode
* - Tool results display with formatting
* - Plain text sections between markers rendered as markdown
* - Smart collapse defaults (expanded while streaming, collapsed when done)
*
* @example
* ```svelte
* <ChatMessageAgenticContent
* content={message.content}
* {message}
* isStreaming={isGenerating}
* />
* ```
*/
export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte';
/**
* Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
* buttons based on message role. Includes branching controls when message has siblings.
@@ -299,6 +485,20 @@ export { default as ChatMessageBranchingControls } from './ChatMessages/ChatMess
*/
export { default as ChatMessageStatistics } from './ChatMessages/ChatMessageStatistics.svelte';
/**
* MCP prompt display in user messages. Shows when user selected an MCP prompt
* via ChatFormPromptPicker. Displays server name, prompt name, and expandable
* content preview. Stored in message.extra as DatabaseMessageExtraMcpPrompt.
*/
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessageMcpPrompt.svelte';
/**
* Formatted content display for MCP prompt messages. Renders the full prompt
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
* for the expanded view.
*/
export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessageMcpPromptContent.svelte';
/**
* System message display component. Renders system messages with distinct styling.
* Visibility controlled by `showSystemMessage` config setting.
@@ -307,7 +507,7 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessageSystem.s
/**
* User message display component. Renders user messages with right-aligned bubble styling.
* Shows message content, attachments via ChatAttachmentsList.
* Shows message content, attachments via ChatAttachmentsList, and MCP prompts if present.
* Supports inline editing mode with ChatMessageEditForm integration.
*/
export { default as ChatMessageUser } from './ChatMessages/ChatMessageUser.svelte';
@@ -385,7 +585,7 @@ export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditFo
* @example
* ```svelte
* <!-- In chat route -->
* <ChatScreen showCenteredEmpty={true} />
* <ChatScreen showCenteredEmpty />
*
* <!-- In conversation route -->
* <ChatScreen showCenteredEmpty={false} />
@@ -460,6 +660,7 @@ export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProc
* - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
* - **Penalties**: Frequency penalty, presence penalty, repeat last N
* - **Import/Export**: Conversation backup and restore
* - **MCP**: MCP server management (opens DialogChatSettings with MCP tab)
* - **Developer**: Debug options, disable auto-scroll
*
* **Parameter Sync:**
@@ -22,15 +22,12 @@
IMAGE_NOT_ERROR_BOUND_SELECTOR,
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING
} from '$lib/constants/markdown';
import { UrlPrefix } from '$lib/enums';
BOOL_TRUE_STRING,
SETTINGS_KEYS
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files';
import {
highlightCode,
detectIncompleteCodeBlock,
type IncompleteCodeBlock
} from '$lib/utils/code';
import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
import '$styles/katex-custom.scss';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
@@ -38,6 +35,7 @@
import { ActionIconsCodeBlock, DialogCodePreview } from '$lib/components/app';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
@@ -503,7 +501,7 @@
// Don't handle data URLs or already-handled images
if (
img.src.startsWith(UrlPrefix.DATA) ||
img.src.startsWith(UrlProtocol.DATA) ||
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
)
return;
@@ -558,7 +556,7 @@
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === 'dark';
const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark);
});
@@ -593,7 +591,12 @@
});
</script>
<div bind:this={containerRef} class={className}>
<div
bind:this={containerRef}
class="{className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
? ' full-height-code-blocks'
: ''}"
>
{#each renderedBlocks as block (block.id)}
<div class="markdown-block" data-block-id={block.id}>
<!-- eslint-disable-next-line no-at-html-tags -->
@@ -615,7 +618,7 @@
<ActionIconsCodeBlock
code={incompleteCodeBlock.code}
language={incompleteCodeBlock.language || 'text'}
disabled={true}
disabled
onPreview={(code, lang) => {
previewCode = code;
previewLanguage = lang;
@@ -914,6 +917,16 @@
line-height: 1.3;
}
.full-height-code-blocks :global(.code-block-wrapper) {
max-height: none;
}
.full-height-code-blocks :global(.code-block-scroll-container),
.full-height-code-blocks .streaming-code-scroll-container {
max-height: none;
overflow-y: visible;
}
div :global(.code-block-header) {
display: flex;
justify-content: space-between;
@@ -5,6 +5,7 @@
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { ColorMode } from '$lib/enums';
interface Props {
code: string;
@@ -39,7 +40,7 @@
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === 'dark';
const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark);
});
@@ -70,7 +70,7 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
* bind:open
* icon={BrainIcon}
* title="Thinking..."
* isStreaming={true}
* isStreaming
* >
* {reasoningContent}
* </CollapsibleContentBlock>
@@ -1,7 +1,7 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ChatSettings } from '$lib/components/app';
import type { SettingsSectionTitle } from '$lib/constants/settings-sections';
import type { SettingsSectionTitle } from '$lib/constants';
interface Props {
onOpenChange?: (open: boolean) => void;
@@ -37,7 +37,7 @@
<iframe
bind:this={iframeRef}
title="Preview {language}"
sandbox="allow-scripts allow-same-origin"
sandbox="allow-scripts"
class="code-preview-iframe"
></iframe>
@@ -0,0 +1,119 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SyntaxHighlightedCode, ActionIconCopyToClipboard } from '$lib/components/app';
import {
getLanguageFromFilename,
isCodeResource,
isImageResource,
downloadResourceContent
} from '$lib/utils';
import { MimeTypeIncludes, MimeTypeText } from '$lib/enums';
import { DEFAULT_RESOURCE_FILENAME } from '$lib/constants';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
extra: DatabaseMessageExtraMcpResource;
}
let { open = $bindable(), onOpenChange, extra }: Props = $props();
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
function getLanguage(): string {
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return MimeTypeIncludes.JSON;
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return MimeTypeIncludes.JAVASCRIPT;
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return MimeTypeIncludes.TYPESCRIPT;
const name = extra.name || extra.uri || '';
return getLanguageFromFilename(name) || 'plaintext';
}
function handleDownload() {
if (!extra.content) return;
downloadResourceContent(
extra.content,
extra.mimeType || MimeTypeText.PLAIN,
extra.name || DEFAULT_RESOURCE_FILENAME
);
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
<Dialog.Header>
<Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
<Dialog.Description>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{extra.uri}</span>
{#if serverName}
<span class="flex items-center gap-1 text-xs text-muted-foreground">
·
{#if favicon}
<img
src={favicon}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
{serverName}
</span>
{/if}
{#if extra.mimeType}
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{extra.mimeType}</span>
{/if}
</div>
</Dialog.Description>
</Dialog.Header>
<div class="flex items-center justify-end gap-1">
<ActionIconCopyToClipboard
text={extra.content}
canCopy={!!extra.content}
ariaLabel="Copy content"
/>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleDownload}
disabled={!extra.content}
title="Download content"
>
<Download class="h-3.5 w-3.5" />
</Button>
</div>
<div class="overflow-auto">
{#if isImageResource(extra.mimeType, extra.uri) && extra.content}
<div class="flex items-center justify-center">
<img
src={extra.content.startsWith('data:')
? extra.content
: `data:${extra.mimeType || 'image/png'};base64,${extra.content}`}
alt={extra.name}
class="max-h-[70vh] max-w-full rounded object-contain"
/>
</div>
{:else if isCodeResource(extra.mimeType, extra.uri) && extra.content}
<SyntaxHighlightedCode code={extra.content} language={getLanguage()} maxHeight="70vh" />
{:else if extra.content}
<pre
class="max-h-[70vh] overflow-auto rounded-md border bg-muted/30 p-4 font-mono text-sm break-words whitespace-pre-wrap">{extra.content}</pre>
{:else}
<div class="py-8 text-center text-sm text-muted-foreground">No content available</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,394 @@
<script lang="ts">
import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import {
mcpResources,
mcpTotalResourceCount,
mcpResourceStore
} from '$lib/stores/mcp-resources.svelte';
import {
McpResourceBrowser,
McpResourcePreview,
McpResourceTemplateForm
} from '$lib/components/app';
import { getResourceDisplayName } from '$lib/utils';
import type { MCPResourceInfo, MCPResourceContent, MCPResourceTemplateInfo } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onAttach?: (resource: MCPResourceInfo) => void;
preSelectedUri?: string;
}
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
let selectedResources = new SvelteSet<string>();
let lastSelectedUri = $state<string | null>(null);
let isAttaching = $state(false);
let selectedTemplate = $state<MCPResourceTemplateInfo | null>(null);
let templatePreviewUri = $state<string | null>(null);
let templatePreviewContent = $state<MCPResourceContent[] | null>(null);
let templatePreviewLoading = $state(false);
let templatePreviewError = $state<string | null>(null);
const totalCount = $derived(mcpTotalResourceCount());
$effect(() => {
if (open) {
loadResources();
if (preSelectedUri) {
selectedResources.clear();
selectedResources.add(preSelectedUri);
lastSelectedUri = preSelectedUri;
}
}
});
async function loadResources() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (initialized) {
await mcpStore.fetchAllResources();
}
}
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange?.(newOpen);
if (!newOpen) {
selectedResources.clear();
lastSelectedUri = null;
clearTemplateState();
}
}
function clearTemplateState() {
selectedTemplate = null;
templatePreviewUri = null;
templatePreviewContent = null;
templatePreviewLoading = false;
templatePreviewError = null;
}
function handleTemplateSelect(template: MCPResourceTemplateInfo) {
selectedResources.clear();
lastSelectedUri = null;
if (
selectedTemplate?.uriTemplate === template.uriTemplate &&
selectedTemplate?.serverName === template.serverName
) {
clearTemplateState();
return;
}
selectedTemplate = template;
templatePreviewUri = null;
templatePreviewContent = null;
templatePreviewLoading = false;
templatePreviewError = null;
}
async function handleTemplateResolve(uri: string, serverName: string) {
templatePreviewUri = uri;
templatePreviewContent = null;
templatePreviewLoading = true;
templatePreviewError = null;
try {
const content = await mcpStore.readResourceByUri(serverName, uri);
if (content) {
templatePreviewContent = content;
} else {
templatePreviewError = 'Failed to read resource';
}
} catch (error) {
templatePreviewError = error instanceof Error ? error.message : 'Unknown error';
} finally {
templatePreviewLoading = false;
}
}
function handleTemplateCancelForm() {
clearTemplateState();
}
async function handleAttachTemplateResource() {
if (!templatePreviewUri || !selectedTemplate || !templatePreviewContent) return;
isAttaching = true;
try {
const knownResource = mcpResourceStore.findResourceByUri(templatePreviewUri);
if (knownResource) {
if (!mcpResourceStore.isAttached(knownResource.uri)) {
await mcpStore.attachResource(knownResource.uri);
}
toast.success(`Resource attached: ${knownResource.title || knownResource.name}`);
} else {
if (mcpResourceStore.isAttached(templatePreviewUri)) {
toast.info('Resource already attached');
handleOpenChange(false);
return;
}
const resourceInfo: MCPResourceInfo = {
uri: templatePreviewUri,
name: templatePreviewUri.split('/').pop() || templatePreviewUri,
serverName: selectedTemplate.serverName
};
const attachment = mcpResourceStore.addAttachment(resourceInfo);
mcpResourceStore.updateAttachmentContent(attachment.id, templatePreviewContent);
toast.success(`Resource attached: ${resourceInfo.name}`);
}
handleOpenChange(false);
} catch (error) {
console.error('Failed to attach template resource:', error);
} finally {
isAttaching = false;
}
}
function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
clearTemplateState();
if (shiftKey && lastSelectedUri) {
const allResources = getAllResourcesFlatInTreeOrder();
const lastIndex = allResources.findIndex((r) => r.uri === lastSelectedUri);
const currentIndex = allResources.findIndex((r) => r.uri === resource.uri);
if (lastIndex !== -1 && currentIndex !== -1) {
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
for (let i = start; i <= end; i++) {
selectedResources.add(allResources[i].uri);
}
}
} else {
selectedResources.clear();
selectedResources.add(resource.uri);
lastSelectedUri = resource.uri;
}
}
function handleResourceToggle(resource: MCPResourceInfo, checked: boolean) {
clearTemplateState();
if (checked) {
selectedResources.add(resource.uri);
} else {
selectedResources.delete(resource.uri);
}
lastSelectedUri = resource.uri;
}
function getAllResourcesFlatInTreeOrder(): MCPResourceInfo[] {
const allResources: MCPResourceInfo[] = [];
const resourcesMap = mcpResources();
for (const [serverName, serverRes] of resourcesMap.entries()) {
for (const resource of serverRes.resources) {
allResources.push({ ...resource, serverName });
}
}
return allResources.sort((a, b) => {
const aName = getResourceDisplayName(a);
const bName = getResourceDisplayName(b);
return aName.localeCompare(bName);
});
}
async function handleAttach() {
if (selectedResources.size === 0) return;
isAttaching = true;
try {
const allResources = getAllResourcesFlatInTreeOrder();
const resourcesToAttach = allResources.filter((r) => selectedResources.has(r.uri));
for (const resource of resourcesToAttach) {
await mcpStore.attachResource(resource.uri);
onAttach?.(resource);
}
const count = resourcesToAttach.length;
toast.success(
count === 1
? `Resource attached: ${resourcesToAttach[0].name}`
: `${count} resources attached`
);
handleOpenChange(false);
} catch (error) {
console.error('Failed to attach resources:', error);
} finally {
isAttaching = false;
}
}
const selectedTemplateUri = $derived(selectedTemplate?.uriTemplate ?? null);
const hasTemplateResult = $derived(
!!selectedTemplate && !!templatePreviewContent && !!templatePreviewUri
);
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
<Dialog.Header class="border-b border-border/30 px-6 py-4">
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="h-5 w-5" />
<span>MCP Resources</span>
{#if totalCount > 0}
<span class="text-sm font-normal text-muted-foreground">({totalCount})</span>
{/if}
</Dialog.Title>
<Dialog.Description>
Browse and attach resources from connected MCP servers to your chat context.
</Dialog.Description>
</Dialog.Header>
<div class="flex h-[500px] min-w-0">
<div class="w-72 shrink-0 overflow-y-auto border-r border-border/30 p-4">
<McpResourceBrowser
onSelect={handleResourceSelect}
onToggle={handleResourceToggle}
onTemplateSelect={handleTemplateSelect}
selectedUris={selectedResources}
{selectedTemplateUri}
expandToUri={preSelectedUri}
/>
</div>
<div class="min-w-0 flex-1 overflow-auto p-4">
{#if selectedTemplate && !templatePreviewContent}
<div class="flex h-full flex-col">
<div class="mb-3 flex items-center gap-2">
<Braces class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">
{selectedTemplate.title || selectedTemplate.name}
</span>
</div>
{#if selectedTemplate.description}
<p class="mb-4 text-xs text-muted-foreground">
{selectedTemplate.description}
</p>
{/if}
<div class="mb-4 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
<p class="font-mono text-xs break-all text-muted-foreground">
{selectedTemplate.uriTemplate}
</p>
</div>
{#if templatePreviewLoading}
<div class="flex flex-1 items-center justify-center">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if templatePreviewError}
<div class="flex flex-1 flex-col items-center justify-center gap-2 text-red-500">
<span class="text-sm">{templatePreviewError}</span>
<Button
size="sm"
variant="outline"
onclick={() => {
templatePreviewError = null;
}}
>
Try again
</Button>
</div>
{:else}
<McpResourceTemplateForm
template={selectedTemplate}
onResolve={handleTemplateResolve}
onCancel={handleTemplateCancelForm}
/>
{/if}
</div>
{:else if hasTemplateResult}
<!-- Template resolved: show preview -->
<McpResourcePreview
resource={{
uri: templatePreviewUri ?? '',
name: templatePreviewUri?.split('/').pop() || (templatePreviewUri ?? ''),
serverName: selectedTemplate?.serverName || ''
}}
preloadedContent={templatePreviewContent}
/>
{:else if selectedResources.size === 1}
{@const allResources = getAllResourcesFlatInTreeOrder()}
{@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
<McpResourcePreview resource={selectedResource ?? null} />
{:else if selectedResources.size > 1}
<div class="flex flex-col gap-10">
{#each getAllResourcesFlatInTreeOrder() as resource (resource.uri)}
{#if selectedResources.has(resource.uri)}
<McpResourcePreview {resource} />
{/if}
{/each}
</div>
{:else}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a resource to preview
</div>
{/if}
</div>
</div>
<Dialog.Footer class="border-t border-border/30 px-6 py-4">
<Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
{#if hasTemplateResult}
<Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
{#if isAttaching}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Attach Resource
</Button>
{:else}
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
{#if isAttaching}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,39 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { McpLogo, McpServersSettings } from '$lib/components/app';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleClose() {
onOpenChange?.(false);
}
</script>
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
style="max-width: 56rem;"
>
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
<Dialog.Title class="inline-flex items-center text-lg font-semibold">
<McpLogo class="mr-2 inline h-4 w-4" />
MCP Servers
</Dialog.Title>
<Dialog.Description class="text-sm text-muted-foreground">
Add and configure MCP servers to enable agentic tool execution capabilities.
</Dialog.Description>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6">
<McpServersSettings />
</div>
</Dialog.Content>
</Dialog.Root>
@@ -414,3 +414,57 @@ export { default as DialogConversationSelection } from './DialogConversationSele
* ```
*/
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
/**
* **DialogMcpResources** - MCP resources browser dialog
*
* Dialog for browsing and attaching MCP resources to chat context.
* Displays resources from connected MCP servers in a tree structure
* with preview panel and multi-select support.
*
* **Architecture:**
* - Uses ShadCN Dialog with two-panel layout
* - Left panel: McpResourceBrowser with tree navigation
* - Right panel: McpResourcePreview for selected resource
* - Integrates with mcpStore for resource fetching and attachment
*
* **Features:**
* - Tree-based resource navigation by server and path
* - Single and multi-select with shift+click
* - Resource preview with content display
* - Quick attach button per resource
* - Batch attach for multiple selections
*
* @example
* ```svelte
* <DialogMcpResources
* bind:open={showResources}
* onAttach={handleResourceAttach}
* />
* ```
*/
export { default as DialogMcpResources } from './DialogMcpResources.svelte';
/**
* **DialogMcpResourcePreview** - MCP resource content preview
*
* Dialog for previewing the content of a stored MCP resource attachment.
* Displays the resource content with syntax highlighting for code,
* image rendering for images, and plain text for other content.
*
* **Features:**
* - Syntax highlighted code preview
* - Image rendering for image resources
* - Copy to clipboard and download actions
* - Server name and favicon display
* - MIME type badge
*
* @example
* ```svelte
* <DialogMcpResourcePreview
* bind:open={previewOpen}
* extra={mcpResourceExtra}
* />
* ```
*/
export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
@@ -0,0 +1,78 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
interface Props {
name: string;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
name,
value = '',
suggestions = [],
isLoadingSuggestions = false,
isAutocompleteActive = false,
autocompleteIndex = 0,
onInput,
onKeydown,
onBlur,
onFocus,
onSelectSuggestion
}: Props = $props();
</script>
<div class="relative grid gap-1">
<Label for="tpl-arg-{name}" class="mb-1 text-muted-foreground">
<span>
{name}
<span class="text-destructive">*</span>
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</Label>
<Input
id="tpl-arg-{name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
placeholder="Enter {name}"
autocomplete="off"
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
@@ -1,7 +1,12 @@
<script lang="ts">
import { Plus, Trash2 } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { autoResizeTextarea } from '$lib/utils';
import {
autoResizeTextarea,
sanitizeKeyValuePairKey,
sanitizeKeyValuePairValue
} from '$lib/utils';
import { KEY_VALUE_PAIR_KEY_MAX_LENGTH, KEY_VALUE_PAIR_VALUE_MAX_LENGTH } from '$lib/constants';
import type { KeyValuePair } from '$lib/types';
interface Props {
@@ -36,17 +41,41 @@
onPairsChange(pairs.filter((_, i) => i !== index));
}
function updatePairKey(index: number, key: string) {
function updatePairKey(index: number, rawKey: string) {
const key = sanitizeKeyValuePairKey(rawKey);
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], key };
onPairsChange(newPairs);
}
function updatePairValue(index: number, value: string) {
function trimPairKey(index: number, key: string) {
const trimmed = key.trim();
if (trimmed === key) return;
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], key: trimmed };
onPairsChange(newPairs);
}
function updatePairValue(index: number, rawValue: string) {
const value = sanitizeKeyValuePairValue(rawValue);
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], value };
onPairsChange(newPairs);
}
function trimPairValue(index: number, value: string) {
const trimmed = value.trim();
if (trimmed === value) return;
const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], value: trimmed };
onPairsChange(newPairs);
}
</script>
<div class={className}>
@@ -77,7 +106,9 @@
type="text"
placeholder={keyPlaceholder}
value={pair.key}
maxlength={KEY_VALUE_PAIR_KEY_MAX_LENGTH}
oninput={(e) => updatePairKey(index, e.currentTarget.value)}
onblur={(e) => trimPairKey(index, e.currentTarget.value)}
class="flex-1"
/>
@@ -85,10 +116,12 @@
use:autoResizeTextarea
placeholder={valuePlaceholder}
value={pair.value}
maxlength={KEY_VALUE_PAIR_VALUE_MAX_LENGTH}
oninput={(e) => {
updatePairValue(index, e.currentTarget.value);
autoResizeTextarea(e.currentTarget);
}}
onblur={(e) => trimPairValue(index, e.currentTarget.value)}
class="flex-1 resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm leading-5 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
rows="1"
></textarea>
@@ -7,12 +7,18 @@
*/
/**
* **SearchInput** - Search field with clear button
* **InputWithSuggestions** - Input field with autocomplete suggestions
*
* Input field optimized for search with clear button and keyboard handling.
* Supports placeholder, autofocus, and change callbacks.
* Text input with dropdown suggestions and keyboard navigation.
* Supports autocomplete functionality with suggestion loading.
*
* **Features:**
* - Autocomplete dropdown with suggestions
* - Keyboard navigation (arrow keys, enter)
* - Loading state for suggestions
* - Focus and blur handling
*/
export { default as SearchInput } from './SearchInput.svelte';
export { default as InputWithSuggestions } from './InputWithSuggestions.svelte';
/**
* **KeyValuePairs** - Editable key-value list
@@ -28,3 +34,11 @@ export { default as SearchInput } from './SearchInput.svelte';
* - Auto-resize value textarea
*/
export { default as KeyValuePairs } from './KeyValuePairs.svelte';
/**
* **SearchInput** - Search field with clear button
*
* Input field optimized for search with clear button and keyboard handling.
* Supports placeholder, autofocus, and change callbacks.
*/
export { default as SearchInput } from './SearchInput.svelte';
@@ -4,6 +4,7 @@ export * from './chat';
export * from './content';
export * from './dialogs';
export * from './forms';
export * from './mcp';
export * from './misc';
export * from './models';
export * from './navigation';
@@ -0,0 +1,57 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== HealthCheckStatus.ERROR;
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let extraServersCount = $derived(
Math.max(0, healthyEnabledMcpServers.length - MAX_DISPLAYED_MCP_AVATARS)
);
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
.filter((f) => f.url !== null)
);
</script>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class={cn('inline-flex items-center gap-1.5', className)}>
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
{/each}
</div>
{#if extraServersCount > 0}
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
{/if}
</div>
{/if}
@@ -0,0 +1,61 @@
<script lang="ts">
import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
import type { MCPCapabilitiesInfo } from '$lib/types';
import { Badge } from '$lib/components/ui/badge';
interface Props {
capabilities?: MCPCapabilitiesInfo;
}
let { capabilities }: Props = $props();
</script>
{#if capabilities}
{#if capabilities.server.tools}
<Badge variant="outline" class="h-5 gap-1 bg-green-50 px-1.5 text-[10px] dark:bg-green-950">
<Wrench class="h-3 w-3 text-green-600 dark:text-green-400" />
Tools
</Badge>
{/if}
{#if capabilities.server.resources}
<Badge variant="outline" class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950">
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
Resources
</Badge>
{/if}
{#if capabilities.server.prompts}
<Badge variant="outline" class="h-5 gap-1 bg-purple-50 px-1.5 text-[10px] dark:bg-purple-950">
<MessageSquare class="h-3 w-3 text-purple-600 dark:text-purple-400" />
Prompts
</Badge>
{/if}
{#if capabilities.server.logging}
<Badge variant="outline" class="h-5 gap-1 bg-orange-50 px-1.5 text-[10px] dark:bg-orange-950">
<FileText class="h-3 w-3 text-orange-600 dark:text-orange-400" />
Logging
</Badge>
{/if}
{#if capabilities.server.completions}
<Badge variant="outline" class="h-5 gap-1 bg-cyan-50 px-1.5 text-[10px] dark:bg-cyan-950">
<Sparkles class="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
Completions
</Badge>
{/if}
{#if capabilities.server.tasks}
<Badge variant="outline" class="h-5 gap-1 bg-pink-50 px-1.5 text-[10px] dark:bg-pink-950">
<ListChecks class="h-3 w-3 text-pink-600 dark:text-pink-400" />
Tasks
</Badge>
{/if}
{/if}
@@ -0,0 +1,60 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { cn } from '$lib/components/ui/utils';
import type { MCPConnectionLog } from '$lib/types';
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
interface Props {
logs: MCPConnectionLog[];
connectionTimeMs?: number;
defaultExpanded?: boolean;
class?: string;
}
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
let isExpanded = $derived(defaultExpanded);
</script>
{#if logs.length > 0}
<Collapsible.Root bind:open={isExpanded} class={className}>
<div class="space-y-2">
<Collapsible.Trigger
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>Connection Log ({logs.length})</span>
{#if connectionTimeMs !== undefined}
<span class="ml-1">· Connected in {connectionTimeMs}ms</span>
{/if}
</Collapsible.Trigger>
</div>
<Collapsible.Content class="mt-2">
<div
class="max-h-64 space-y-0.5 overflow-y-auto rounded bg-muted/50 p-2 font-mono text-[10px]"
>
{#each logs as log (log.timestamp.getTime() + log.message)}
{@const Icon = getMcpLogLevelIcon(log.level)}
<div class={cn('flex items-start gap-1.5', getMcpLogLevelClass(log.level))}>
<span class="shrink-0 text-muted-foreground">
{formatTime(log.timestamp)}
</span>
<Icon class="mt-0.5 h-3 w-3 shrink-0" />
<span class="break-all">{log.message}</span>
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
@@ -0,0 +1,111 @@
<script>
let { class: className = '', style = '' } = $props();
</script>
<svg
class={className}
{style}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 174 174"
xmlns:xlink="http://www.w3.org/1999/xlink"
fill="none"
version="1.1"
><g id="shape-320b5b95-d08d-8089-8007-585a8e498184"
><defs
><clipPath
id="frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1"
class="frame-clip frame-clip-def"
><rect
rx="0"
ry="0"
x="0"
y="0"
width="174.00000000000045"
height="174"
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
/></clipPath
></defs
><g class="frame-container-wrapper"
><g class="frame-container-blur"
><g class="frame-container-shadows"
><g clip-path="url(#frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1)" fill="none"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a8e498184"
><rect
rx="0"
ry="0"
x="0"
y="0"
width="174.00000000000045"
height="174"
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
class="frame-background"
/></g
><g class="frame-children"
><g id="shape-320b5b95-d08d-8089-8007-585a974337b1"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b1"
><path
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd4382b6-320b5b95-d08d-8089-8007-585a974337b1"
class="strokes"
><g class="stroke-shape"
><path
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
><g id="shape-320b5b95-d08d-8089-8007-585a974337b2"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b2"
><path
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd447743-320b5b95-d08d-8089-8007-585a974337b2"
class="strokes"
><g class="stroke-shape"
><path
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
><g id="shape-320b5b95-d08d-8089-8007-585a974337b3"
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b3"
><path
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
fill="none"
stroke-linecap="round"
style="fill: none;"
/></g
><g
fill="none"
stroke-linecap="round"
id="strokes-b954dcef-3e3e-8015-8007-585acd44c5c9-320b5b95-d08d-8089-8007-585a974337b3"
class="strokes"
><g class="stroke-shape"
><path
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
/></g
></g
></g
></g
></g
></g
></g
></g
></g
></svg
>
@@ -0,0 +1,154 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { parseResourcePath } from '$lib/utils';
import McpResourceBrowserHeader from './McpResourceBrowserHeader.svelte';
import McpResourceBrowserEmptyState from './McpResourceBrowserEmptyState.svelte';
import McpResourceBrowserServerItem from './McpResourceBrowserServerItem.svelte';
interface Props {
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
selectedUris?: Set<string>;
selectedTemplateUri?: string | null;
expandToUri?: string;
class?: string;
}
let {
onSelect,
onToggle,
onTemplateSelect,
selectedUris = new Set(),
selectedTemplateUri,
expandToUri,
class: className
}: Props = $props();
let expandedServers = new SvelteSet<string>();
let expandedFolders = new SvelteSet<string>();
let searchQuery = $state('');
const resources = $derived(mcpResources());
const isLoading = $derived(mcpResourcesLoading());
const filteredResources = $derived.by(() => {
if (!searchQuery.trim()) {
return resources;
}
const query = searchQuery.toLowerCase();
const filtered = new SvelteMap();
for (const [serverName, serverRes] of resources.entries()) {
const filteredResources = serverRes.resources.filter((r) => {
return (
r.title?.toLowerCase().includes(query) ||
r.uri.toLowerCase().includes(query) ||
serverName.toLowerCase().includes(query)
);
});
const filteredTemplates = serverRes.templates.filter((t) => {
return (
t.name?.toLowerCase().includes(query) ||
t.title?.toLowerCase().includes(query) ||
t.uriTemplate.toLowerCase().includes(query) ||
serverName.toLowerCase().includes(query)
);
});
if (filteredResources.length > 0 || filteredTemplates.length > 0 || query.trim()) {
filtered.set(serverName, {
...serverRes,
resources: filteredResources,
templates: filteredTemplates
});
}
}
return filtered;
});
$effect(() => {
if (expandToUri && resources.size > 0) {
autoExpandToResource(expandToUri);
}
});
function autoExpandToResource(uri: string) {
for (const [serverName, serverRes] of resources.entries()) {
const resource = serverRes.resources.find((r) => r.uri === uri);
if (resource) {
expandedServers.add(serverName);
const pathParts = parseResourcePath(uri);
if (pathParts.length > 1) {
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
currentPath = `${currentPath}/${pathParts[i]}`;
const folderId = `${serverName}:${currentPath}`;
expandedFolders.add(folderId);
}
}
break;
}
}
}
function toggleServer(serverName: string) {
if (expandedServers.has(serverName)) {
expandedServers.delete(serverName);
} else {
expandedServers.add(serverName);
}
}
function toggleFolder(folderId: string) {
if (expandedFolders.has(folderId)) {
expandedFolders.delete(folderId);
} else {
expandedFolders.add(folderId);
}
}
function handleRefresh() {
mcpStore.fetchAllResources();
}
</script>
<div class={cn('flex flex-col gap-2', className)}>
<McpResourceBrowserHeader
{isLoading}
onRefresh={handleRefresh}
onSearch={(q) => (searchQuery = q)}
{searchQuery}
/>
<div class="flex flex-col gap-1">
{#if filteredResources.size === 0}
<McpResourceBrowserEmptyState {isLoading} />
{:else}
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
<McpResourceBrowserServerItem
serverName={serverName as string}
serverRes={serverRes as MCPServerResources}
isExpanded={expandedServers.has(serverName as string)}
{selectedUris}
{selectedTemplateUri}
{expandedFolders}
onToggleServer={() => toggleServer(serverName as string)}
onToggleFolder={toggleFolder}
{onSelect}
{onToggle}
{onTemplateSelect}
{searchQuery}
/>
{/each}
{/if}
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
interface Props {
isLoading: boolean;
}
let { isLoading }: Props = $props();
</script>
<div class="py-4 text-center text-sm text-muted-foreground">
{#if isLoading}
Loading resources...
{:else}
No resources available
{/if}
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import { RefreshCw, Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { SearchInput } from '$lib/components/app/forms';
interface Props {
isLoading: boolean;
onRefresh: () => void;
onSearch?: (query: string) => void;
searchQuery?: string;
}
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
</script>
<div class="flex flex-col gap-2">
<div class="mb-2 flex items-center gap-4">
<SearchInput
placeholder="Search resources..."
value={searchQuery}
onInput={(value) => onSearch?.(value)}
/>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onclick={onRefresh}
disabled={isLoading}
title="Refresh resources"
>
{#if isLoading}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<RefreshCw class="h-4 w-4" />
{/if}
</Button>
</div>
<h3 class="text-sm font-medium">Available resources</h3>
</div>
@@ -0,0 +1,235 @@
<script lang="ts">
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
import {
type ResourceTreeNode,
buildResourceTree,
countTreeResources,
sortTreeChildren
} from './mcp-resource-browser';
import { getDisplayName, getResourceIcon } from '$lib/utils';
interface Props {
serverName: string;
serverRes: MCPServerResources;
isExpanded: boolean;
selectedUris: Set<string>;
selectedTemplateUri?: string | null;
expandedFolders: SvelteSet<string>;
onToggleServer: () => void;
onToggleFolder: (folderId: string) => void;
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
searchQuery?: string;
}
let {
serverName,
serverRes,
isExpanded,
selectedUris,
selectedTemplateUri,
expandedFolders,
onToggleServer,
onToggleFolder,
onSelect,
onToggle,
onTemplateSelect,
searchQuery = ''
}: Props = $props();
const hasResources = $derived(serverRes.resources.length > 0);
const hasTemplates = $derived(serverRes.templates.length > 0);
const hasContent = $derived(hasResources || hasTemplates);
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
const favicon = $derived(mcpStore.getServerFavicon(serverName));
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
serverRes.templates.map((t) => ({
uriTemplate: t.uriTemplate,
name: t.name,
title: t.title,
description: t.description,
mimeType: t.mimeType,
serverName,
annotations: t.annotations,
icons: t.icons
}))
);
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
onSelect?.(resource, event.shiftKey);
}
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
onToggle?.(resource, checked);
}
function isResourceSelected(resource: MCPResourceInfo): boolean {
return selectedUris.has(resource.uri);
}
</script>
{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
{@const isFolder = !node.resource && node.children.size > 0}
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
{@const isFolderExpanded = expandedFolders.has(folderId)}
{#if isFolder}
{@const folderCount = countTreeResources(node)}
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
>
{#if isFolderExpanded}
<ChevronDown class="h-3 w-3" />
{:else}
<ChevronRight class="h-3 w-3" />
{/if}
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
<span class="font-medium">{node.name}</span>
<span class="text-xs text-muted-foreground">({folderCount})</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
{@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else if node.resource}
{@const resource = node.resource}
{@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)}
{@const isSelected = isResourceSelected(resource)}
{@const resourceDisplayName = resource.title || getDisplayName(node.name)}
<div class="group flex w-full items-center gap-2">
{#if onToggle}
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean | 'indeterminate') =>
handleCheckboxChange(resource, checked === true)}
class="h-4 w-4"
/>
{/if}
<button
class={cn(
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
'hover:bg-muted/50',
isSelected && 'bg-muted'
)}
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
title={resourceDisplayName}
>
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-left">
{resourceDisplayName}
</span>
</button>
</div>
{/if}
{/snippet}
<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span class="inline-flex flex-col items-start text-left">
<span class="inline-flex items-center justify-start gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
{displayName}
</span>
<span class="text-xs text-muted-foreground">
({serverRes.resources.length} resource{serverRes.resources.length !== 1
? 's'
: ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates
.length !== 1
? 's'
: ''}{/if})
</span>
</span>
{#if serverRes.loading}
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
{/if}
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#if serverRes.error}
<div class="py-1 text-xs text-red-500">
Error: {serverRes.error}
</div>
{:else if !hasContent}
<div class="py-1 text-xs text-muted-foreground">No resources</div>
{:else}
{#if hasResources}
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
{@render renderTreeNode(child, 1, '')}
{/each}
{/if}
{#if hasTemplates && onTemplateSelect}
{#if hasResources}
<div class="my-1 border-t border-border/30"></div>
{/if}
<div
class="py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase"
>
Templates
</div>
{#each templateInfos as template (template.uriTemplate)}
<button
class={cn(
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
'hover:bg-muted/50',
selectedTemplateUri === template.uriTemplate && 'bg-muted'
)}
onclick={() => onTemplateSelect(template)}
title={template.uriTemplate}
>
<Braces class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-left">
{template.title || template.name}
</span>
</button>
{/each}
{/if}
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
@@ -0,0 +1,118 @@
import type { MCPResource, MCPResourceInfo } from '$lib/types';
import { parseResourcePath } from '$lib/utils';
export interface ResourceTreeNode {
name: string;
resource?: MCPResourceInfo;
children: Map<string, ResourceTreeNode>;
isFiltered?: boolean;
}
function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
return (
resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query)
);
}
export function buildResourceTree(
resourceList: MCPResource[],
serverName: string,
searchQuery?: string
): ResourceTreeNode {
const root: ResourceTreeNode = { name: 'root', children: new Map() };
if (!searchQuery || !searchQuery.trim()) {
for (const resource of resourceList) {
const pathParts = parseResourcePath(resource.uri);
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map() });
}
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map()
});
}
return root;
}
const query = searchQuery.toLowerCase();
// Build tree with filtering
for (const resource of resourceList) {
if (!resourceMatchesSearch(resource, query)) continue;
const pathParts = parseResourcePath(resource.uri);
let current = root;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (!current.children.has(part)) {
current.children.set(part, { name: part, children: new Map(), isFiltered: true });
}
current = current.children.get(part)!;
}
const fileName = pathParts[pathParts.length - 1] || resource.name;
current.children.set(resource.uri, {
name: fileName,
resource: { ...resource, serverName },
children: new Map(),
isFiltered: true
});
}
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
if (node.resource) return true;
const toDelete: string[] = [];
for (const [name, child] of node.children.entries()) {
if (!cleanupEmptyFolders(child)) {
toDelete.push(name);
}
}
for (const name of toDelete) {
node.children.delete(name);
}
return node.children.size > 0;
}
cleanupEmptyFolders(root);
return root;
}
export function countTreeResources(node: ResourceTreeNode): number {
if (node.resource) return 1;
let count = 0;
for (const child of node.children.values()) {
count += countTreeResources(child);
}
return count;
}
export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
return children.sort((a, b) => {
const aIsFolder = !a.resource && a.children.size > 0;
const bIsFolder = !b.resource && b.children.size > 0;
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
return a.name.localeCompare(b.name);
});
}
@@ -0,0 +1,175 @@
<script lang="ts">
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import {
isImageMimeType,
createBase64DataUrl,
getResourceTextContent,
getResourceBlobContent,
downloadResourceContent
} from '$lib/utils';
import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
import { ActionIconCopyToClipboard } from '$lib/components/app';
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
interface Props {
resource: MCPResourceInfo | null;
/** Pre-loaded content (e.g., from template resolution). Skips store fetch when provided. */
preloadedContent?: MCPResourceContent[] | null;
class?: string;
}
let { resource, preloadedContent, class: className }: Props = $props();
let content = $state<MCPResourceContent[] | null>(null);
let isLoading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (resource) {
if (preloadedContent) {
content = preloadedContent;
isLoading = false;
error = null;
} else {
loadContent(resource.uri);
}
} else {
content = null;
error = null;
}
});
async function loadContent(uri: string) {
isLoading = true;
error = null;
try {
const result = await mcpStore.readResource(uri);
if (result) {
content = result;
} else {
error = 'Failed to load resource content';
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
isLoading = false;
}
}
function handleDownload() {
const text = getResourceTextContent(content);
if (!text || !resource) return;
downloadResourceContent(
text,
resource.mimeType || MimeTypeText.PLAIN,
resource.name || 'resource.txt'
);
}
</script>
<div class={cn('flex flex-col gap-3', className)}>
{#if !resource}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<FileText class="h-8 w-8 opacity-50" />
<span class="text-sm">Select a resource to preview</span>
</div>
{:else}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate font-medium">{resource.title || resource.name}</h3>
<p class="truncate text-xs text-muted-foreground">{resource.uri}</p>
{#if resource.description}
<p class="mt-1 text-sm text-muted-foreground">{resource.description}</p>
{/if}
</div>
<div class="flex items-center gap-1">
<ActionIconCopyToClipboard
text={getResourceTextContent(content)}
canCopy={!isLoading && !!getResourceTextContent(content)}
ariaLabel="Copy content"
/>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
onclick={handleDownload}
disabled={isLoading || !getResourceTextContent(content)}
title="Download content"
>
<Download class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
{:else if error}
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
<AlertCircle class="h-6 w-6" />
<span class="text-sm">{error}</span>
</div>
{:else if content}
{@const textContent = getResourceTextContent(content)}
{@const blobContent = getResourceBlobContent(content)}
{#if textContent}
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
{/if}
{#each blobContent as blob (blob.uri)}
{#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)}
<img
src={createBase64DataUrl(
blob.mimeType ?? MimeTypeApplication.OCTET_STREAM,
blob.blob
)}
alt="Resource content"
class="max-w-full rounded"
/>
{:else}
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
<FileText class="h-4 w-4" />
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
</div>
{/if}
{/each}
{#if !textContent && blobContent.length === 0}
<div class="py-4 text-center text-sm text-muted-foreground">No content available</div>
{/if}
{/if}
</div>
{#if resource.mimeType || resource.annotations}
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
{#if resource.mimeType}
<span class="rounded bg-muted px-1.5 py-0.5">{resource.mimeType}</span>
{/if}
{#if resource.annotations?.priority !== undefined}
<span class="rounded bg-muted px-1.5 py-0.5">
Priority: {resource.annotations.priority}
</span>
{/if}
<span class="rounded bg-muted px-1.5 py-0.5">
Server: {resource.serverName}
</span>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,171 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { InputWithSuggestions } from '$lib/components/app';
import { KeyboardKey } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
import type { MCPResourceTemplateInfo } from '$lib/types';
import {
debounce,
extractTemplateVariables,
expandTemplate,
isTemplateComplete
} from '$lib/utils';
interface Props {
template: MCPResourceTemplateInfo;
onResolve: (uri: string, serverName: string) => void;
onCancel: () => void;
}
let { template, onResolve, onCancel }: Props = $props();
const variables = $derived(extractTemplateVariables(template.uriTemplate));
let values = $state<Record<string, string>>({});
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
const expandedUri = $derived(expandTemplate(template.uriTemplate, values));
const isComplete = $derived(isTemplateComplete(template.uriTemplate, values));
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (value.length < 1) {
suggestions[argName] = [];
return;
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getResourceCompletions(
template.serverName,
template.uriTemplate,
argName,
value
);
if (result && result.values.length > 0) {
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[McpResourceTemplateForm] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
values[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
values[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
if (argSuggestions.length > 0 && activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
} else {
onCancel();
}
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
const value = values[argName] ?? '';
if (value.length >= MIN_AUTOCOMPLETE_INPUT_LENGTH) {
fetchCompletions(argName, value);
}
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (isComplete) {
onResolve(expandedUri, template.serverName);
}
}
</script>
<form onsubmit={handleSubmit} class="space-y-3">
{#each variables as variable (variable.name)}
<InputWithSuggestions
name={variable.name}
value={values[variable.name] ?? ''}
suggestions={suggestions[variable.name] ?? []}
isLoadingSuggestions={loadingSuggestions[variable.name] ?? false}
isAutocompleteActive={activeAutocomplete === variable.name}
autocompleteIndex={activeAutocomplete === variable.name ? autocompleteIndex : 0}
onInput={(value) => handleArgInput(variable.name, value)}
onKeydown={(e) => handleArgKeydown(e, variable.name)}
onBlur={() => handleArgBlur(variable.name)}
onFocus={() => handleArgFocus(variable.name)}
onSelectSuggestion={(value) => selectSuggestion(variable.name, value)}
/>
{/each}
{#if isComplete}
<div class="rounded-md bg-muted/50 px-3 py-2">
<p class="text-xs text-muted-foreground">Resolved URI:</p>
<p class="mt-0.5 font-mono text-xs break-all">{expandedUri}</p>
</div>
{/if}
<div class="flex justify-end gap-2 pt-1">
<Button type="button" size="sm" variant="secondary" onclick={onCancel}>Cancel</Button>
<Button size="sm" type="submit" disabled={!isComplete}>Read Resource</Button>
</div>
</form>

Some files were not shown because too many files have changed in this diff Show More