mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-27 21:46:57 +02:00
Merge branch 'master' into pr/18039
This commit is contained in:
+15
-8
@@ -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.
@@ -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)}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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<string, McpServerOverride>
|
||||
|
||||
%% ═══════════════════════════════════════════════════════════════════════════
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Generated
+986
-75
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
+40
@@ -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>
|
||||
+86
@@ -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>
|
||||
+41
@@ -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}
|
||||
+104
-3
@@ -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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
+164
-4
@@ -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>
|
||||
|
||||
+170
@@ -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>
|
||||
+91
-21
@@ -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
|
||||
|
||||
+55
@@ -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>
|
||||
+82
@@ -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>
|
||||
+23
@@ -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>
|
||||
+30
@@ -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>
|
||||
+435
@@ -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>
|
||||
+74
@@ -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>
|
||||
+84
@@ -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>
|
||||
+236
@@ -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}
|
||||
|
||||
+354
@@ -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>
|
||||
+43
-76
@@ -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}
|
||||
/>
|
||||
|
||||
+83
@@ -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>
|
||||
+197
@@ -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>
|
||||
+116
-3
@@ -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
-4
@@ -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>
|
||||
|
||||
-68
@@ -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
-1
@@ -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
|
||||
|
||||
+1
-2
@@ -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';
|
||||
|
||||
+5
-12
@@ -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
|
||||
>
|
||||
+154
@@ -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>
|
||||
+15
@@ -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>
|
||||
+41
@@ -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>
|
||||
+235
@@ -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>
|
||||
+118
@@ -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
Reference in New Issue
Block a user