mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-20 09:38:55 +02:00
Merge commit 'deae5ee133a3c4c56fbd46c17c8c2103af3bd643' into concedo_experimental
# Conflicts: # .devops/openvino.Dockerfile # .github/workflows/build-apple.yml # .github/workflows/build-cuda-ubuntu.yml # .github/workflows/docker.yml # .github/workflows/release.yml # .github/workflows/server-sanitize.yml # .github/workflows/ui-build-self-hosted.yml # .github/workflows/ui-build.yml # .github/workflows/ui-publish.yml # .github/workflows/ui-self-hosted.yml # .github/workflows/ui.yml # CMakeLists.txt # docs/backend/snapdragon/CMakeUserPresets.json # docs/backend/snapdragon/README.md # docs/backend/snapdragon/developer.md # docs/backend/snapdragon/linux.md # docs/backend/snapdragon/windows.md # docs/ops.md # docs/ops/Vulkan.csv # ggml/cmake/ggml-config.cmake.in # ggml/src/ggml-cpu/CMakeLists.txt # ggml/src/ggml-cpu/kleidiai/kernels.cpp # ggml/src/ggml-cpu/kleidiai/kernels.h # ggml/src/ggml-cpu/kleidiai/kleidiai.cpp # ggml/src/ggml-hexagon/ggml-hexagon.cpp # ggml/src/ggml-hexagon/htp-opnode.h # ggml/src/ggml-hexagon/htp/CMakeLists.txt # ggml/src/ggml-hexagon/htp/act-ops.c # ggml/src/ggml-hexagon/htp/cpy-ops.c # ggml/src/ggml-hexagon/htp/dma-queue.h # ggml/src/ggml-hexagon/htp/flash-attn-ops.c # ggml/src/ggml-hexagon/htp/get-rows-ops.c # ggml/src/ggml-hexagon/htp/hex-utils.h # ggml/src/ggml-hexagon/htp/htp-ctx.h # ggml/src/ggml-hexagon/htp/htp-ops.h # ggml/src/ggml-hexagon/htp/htp-tensor.c # ggml/src/ggml-hexagon/htp/htp-tensor.h # ggml/src/ggml-hexagon/htp/hvx-arith.h # ggml/src/ggml-hexagon/htp/main.c # ggml/src/ggml-hexagon/htp/matmul-ops.c # ggml/src/ggml-hexagon/htp/matmul-ops.h # ggml/src/ggml-hexagon/htp/set-rows-ops.c # ggml/src/ggml-rpc/CMakeLists.txt # scripts/snapdragon/ggml-hexagon-profile.py # scripts/snapdragon/ggml-hexagon-trace.py # tests/test-backend-ops.cpp # tools/cli/README.md # tools/llama-bench/llama-bench.cpp # tools/rpc/README.md # tools/server/README.md # tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
This commit is contained in:
+10
-1
@@ -87,6 +87,9 @@ struct mtmd_cli_context {
|
||||
mtmd::bitmaps bitmaps;
|
||||
std::vector<mtmd_helper::video_ptr> videos;
|
||||
|
||||
mtmd_helper_init_opt init_opt = mtmd_helper_init_opt_default();
|
||||
std::string video_ffmpeg_bin_dir;
|
||||
|
||||
mtmd::batch_ptr mbatch;
|
||||
|
||||
// chat template
|
||||
@@ -170,6 +173,12 @@ struct mtmd_cli_context {
|
||||
LOG_ERR("Failed to load vision model from %s\n", clip_path);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
video_ffmpeg_bin_dir = params.video_ffmpeg_bin_dir;
|
||||
init_opt.video_params.fps_target = params.video_fps;
|
||||
init_opt.video_params.timestamp_interval_ms = params.video_timestamp_interval_ms;
|
||||
init_opt.video_params.ffmpeg_bin_dir = video_ffmpeg_bin_dir.empty()
|
||||
? nullptr : video_ffmpeg_bin_dir.c_str();
|
||||
}
|
||||
|
||||
bool check_antiprompt(const llama_tokens & generated_tokens) {
|
||||
@@ -184,7 +193,7 @@ struct mtmd_cli_context {
|
||||
}
|
||||
|
||||
bool load_media(const std::string & fname) {
|
||||
auto res = mtmd_helper_bitmap_init_from_file(ctx_vision.get(), fname.c_str(), false);
|
||||
auto res = mtmd_helper_bitmap_init_from_file(ctx_vision.get(), fname.c_str(), false, init_opt);
|
||||
if (!res.bitmap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -370,14 +370,18 @@ static bool is_webp_file(const unsigned char * buf, size_t len) {
|
||||
}
|
||||
|
||||
#ifdef MTMD_VIDEO
|
||||
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder);
|
||||
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder,
|
||||
const mtmd_helper_video_init_params & params);
|
||||
#endif
|
||||
|
||||
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx, const unsigned char * buf, size_t len, bool placeholder) {
|
||||
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx, const unsigned char * buf, size_t len, bool placeholder,
|
||||
mtmd_helper_init_opt opt) {
|
||||
// calculate the hash if needed
|
||||
std::string id;
|
||||
mtmd_bitmap * result = nullptr;
|
||||
|
||||
GGML_UNUSED(opt); // only used by video code paths
|
||||
|
||||
if (!placeholder) {
|
||||
// use sha256 to prevent cache poisoning
|
||||
id = hash_sha256_hex(buf, len);
|
||||
@@ -415,7 +419,7 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx,
|
||||
#ifdef MTMD_VIDEO
|
||||
// stb_image does not support webp; decode it with ffmpeg as a single frame
|
||||
if (!result && is_webp_file(buf, len)) {
|
||||
result = decode_webp_with_ffmpeg(ctx, buf, len, placeholder);
|
||||
result = decode_webp_with_ffmpeg(ctx, buf, len, placeholder, opt.video_params);
|
||||
if (!result) {
|
||||
LOG_ERR("%s: failed to decode webp buffer\n", __func__);
|
||||
return {nullptr, nullptr};
|
||||
@@ -428,8 +432,7 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx,
|
||||
// last try: load as video
|
||||
#ifdef MTMD_VIDEO
|
||||
if (!result) {
|
||||
auto params = mtmd_helper_video_init_params_default();
|
||||
auto video_ctx = mtmd_helper_video_init_from_buf(ctx, buf, len, params);
|
||||
auto video_ctx = mtmd_helper_video_init_from_buf(ctx, buf, len, opt.video_params);
|
||||
if (!video_ctx) {
|
||||
LOG_ERR("%s: failed to decode buffer as either image/audio/video\n", __func__);
|
||||
return {nullptr, nullptr};
|
||||
@@ -457,7 +460,8 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx,
|
||||
return {nullptr, nullptr};
|
||||
}
|
||||
|
||||
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx, const char * fname, bool placeholder) {
|
||||
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx, const char * fname, bool placeholder,
|
||||
mtmd_helper_init_opt opt) {
|
||||
#ifdef _WIN32
|
||||
int wlen = MultiByteToWideChar(CP_UTF8, 0, fname, -1, NULL, 0);
|
||||
if (!wlen) {
|
||||
@@ -498,7 +502,7 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx,
|
||||
return {nullptr, nullptr};
|
||||
}
|
||||
|
||||
return mtmd_helper_bitmap_init_from_buf(ctx, buf.data(), buf.size(), placeholder);
|
||||
return mtmd_helper_bitmap_init_from_buf(ctx, buf.data(), buf.size(), placeholder, opt);
|
||||
}
|
||||
|
||||
bool mtmd_helper_support_video(mtmd_context * ctx) {
|
||||
@@ -856,6 +860,12 @@ mtmd_helper_video_init_params mtmd_helper_video_init_params_default() {
|
||||
};
|
||||
}
|
||||
|
||||
mtmd_helper_init_opt mtmd_helper_init_opt_default() {
|
||||
return {
|
||||
/* video_params */ mtmd_helper_video_init_params_default(),
|
||||
};
|
||||
}
|
||||
|
||||
static std::string video_resolve_bin(const char * bin_dir, const char * name) {
|
||||
if (!bin_dir || bin_dir[0] == '\0') {
|
||||
return name; // rely on PATH
|
||||
@@ -877,8 +887,8 @@ static std::string video_resolve_bin(const char * bin_dir, const char * name) {
|
||||
}
|
||||
|
||||
#ifdef MTMD_VIDEO
|
||||
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder) {
|
||||
auto params = mtmd_helper_video_init_params_default();
|
||||
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder,
|
||||
const mtmd_helper_video_init_params & params) {
|
||||
mtmd_helper_video vctx;
|
||||
vctx.mctx = mctx;
|
||||
vctx.input_buf.assign(buf, buf + len);
|
||||
|
||||
+28
-10
@@ -23,6 +23,23 @@ extern "C" {
|
||||
struct mtmd_helper_video;
|
||||
typedef struct mtmd_helper_video mtmd_helper_video;
|
||||
|
||||
struct mtmd_helper_video_init_params {
|
||||
float fps_target; // desired output fps; <= 0 means use the video's native fps, defaulted to 4.0f
|
||||
const char * ffmpeg_bin_dir; // directory containing ffmpeg/ffprobe binaries; NULL means search PATH
|
||||
int64_t timestamp_interval_ms; // interval for adding timestamp as text chunk (example: "[10m50.5s]"); <= 0 means no timestamp, defaulted to 5000ms
|
||||
// TODO @ngxson : allow "placeholder" bitmap output for counting tokens
|
||||
};
|
||||
|
||||
MTMD_API struct mtmd_helper_video_init_params mtmd_helper_video_init_params_default(void);
|
||||
|
||||
// opt for mtmd_helper_bitmap_init_from_*()
|
||||
struct mtmd_helper_init_opt {
|
||||
struct mtmd_helper_video_init_params video_params;
|
||||
};
|
||||
typedef struct mtmd_helper_init_opt mtmd_helper_init_opt;
|
||||
|
||||
MTMD_API struct mtmd_helper_init_opt mtmd_helper_init_opt_default(void);
|
||||
|
||||
// Set callback for all future logging events.
|
||||
// If this is not called, or NULL is supplied, everything is output on stderr.
|
||||
// Note: this also call mtmd_log_set() internally
|
||||
@@ -40,7 +57,11 @@ struct mtmd_helper_bitmap_wrapper {
|
||||
// it calls mtmd_helper_bitmap_init_from_buf() internally
|
||||
// returns nullptr on failure
|
||||
// this function is thread-safe
|
||||
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx, const char * fname, bool placeholder);
|
||||
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(
|
||||
mtmd_context * ctx,
|
||||
const char * fname,
|
||||
bool placeholder,
|
||||
struct mtmd_helper_init_opt opt);
|
||||
|
||||
// helper function to construct a mtmd_bitmap from a buffer containing a file
|
||||
// supported formats:
|
||||
@@ -53,7 +74,11 @@ MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtm
|
||||
// - output bitmap will have SHA-256 hash (hex string) as the ID
|
||||
// returns nullptr on failure
|
||||
// this function is thread-safe
|
||||
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx, const unsigned char * buf, size_t len, bool placeholder);
|
||||
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(
|
||||
mtmd_context * ctx,
|
||||
const unsigned char * buf, size_t len,
|
||||
bool placeholder,
|
||||
struct mtmd_helper_init_opt opt);
|
||||
|
||||
// helper to count the total number of tokens from a list of chunks, useful to keep track of KV cache
|
||||
MTMD_API size_t mtmd_helper_get_n_tokens(const mtmd_input_chunks * chunks);
|
||||
@@ -124,14 +149,7 @@ struct mtmd_helper_video_info {
|
||||
int32_t n_frames; // estimated total frames at effective fps (-1 if unknown)
|
||||
};
|
||||
|
||||
struct mtmd_helper_video_init_params {
|
||||
float fps_target; // desired output fps; <= 0 means use the video's native fps, defaulted to 4.0f
|
||||
const char * ffmpeg_bin_dir; // directory containing ffmpeg/ffprobe binaries; NULL means search PATH
|
||||
int64_t timestamp_interval_ms; // interval for adding timestamp as text chunk (example: "[10m50.5s]"); <= 0 means no timestamp, defaulted to 5000ms
|
||||
// TODO @ngxson : allow "placeholder" bitmap output for counting tokens
|
||||
};
|
||||
|
||||
MTMD_API struct mtmd_helper_video_init_params mtmd_helper_video_init_params_default(void);
|
||||
// note: mtmd_helper_video_init_params is defined at the top, as it is part of mtmd_helper_init_opt
|
||||
|
||||
// returns NULL on failure (ffprobe not found, file unreadable, etc.)
|
||||
MTMD_API mtmd_helper_video * mtmd_helper_video_init(
|
||||
|
||||
@@ -910,12 +910,17 @@ size_t validate_utf8(const std::string& text) {
|
||||
return len;
|
||||
}
|
||||
|
||||
server_tokens process_mtmd_prompt(mtmd_context * mctx, const std::string & prompt, const std::vector<raw_buffer> & files, bool is_placeholder) {
|
||||
server_tokens process_mtmd_prompt(
|
||||
mtmd_context * mctx,
|
||||
const std::string & prompt,
|
||||
const std::vector<raw_buffer> & files,
|
||||
const mtmd_helper_init_opt & init_opt,
|
||||
bool is_placeholder) {
|
||||
// these will be freed upon going out of scope
|
||||
mtmd::bitmaps bitmaps;
|
||||
std::vector<mtmd_helper::video_ptr> videos;
|
||||
for (auto & file : files) {
|
||||
auto out = mtmd_helper_bitmap_init_from_buf(mctx, file.data(), file.size(), is_placeholder);
|
||||
auto out = mtmd_helper_bitmap_init_from_buf(mctx, file.data(), file.size(), is_placeholder, init_opt);
|
||||
if (!out.bitmap) {
|
||||
throw std::runtime_error("Failed to load image or audio file");
|
||||
}
|
||||
@@ -956,7 +961,7 @@ server_tokens process_mtmd_prompt(mtmd_context * mctx, const std::string & promp
|
||||
* - "prompt": [12, 34, "string", 56, 78]
|
||||
* - "prompt": { "prompt_string": "string", "multimodal_data": [ "base64" ] }
|
||||
*/
|
||||
static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special) {
|
||||
static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special, const mtmd_helper_init_opt & init_opt) {
|
||||
constexpr char JSON_STRING_PROMPT_KEY[] = "prompt_string";
|
||||
constexpr char JSON_MTMD_DATA_KEY[] = "multimodal_data";
|
||||
const bool has_mtmd = mctx != nullptr;
|
||||
@@ -979,7 +984,7 @@ static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_co
|
||||
for (const auto & entry : json_prompt.at(JSON_MTMD_DATA_KEY)) {
|
||||
files.push_back(base64_decode(entry));
|
||||
}
|
||||
return process_mtmd_prompt(mctx, json_prompt.at(JSON_STRING_PROMPT_KEY), files);
|
||||
return process_mtmd_prompt(mctx, json_prompt.at(JSON_STRING_PROMPT_KEY), files, init_opt);
|
||||
} else {
|
||||
// Not multimodal, but contains a subobject.
|
||||
llama_tokens tmp = tokenize_mixed(vocab, json_prompt.at(JSON_STRING_PROMPT_KEY), add_special, parse_special);
|
||||
@@ -990,15 +995,15 @@ static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_co
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<server_tokens> tokenize_input_prompts(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special) {
|
||||
std::vector<server_tokens> tokenize_input_prompts(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special, const mtmd_helper_init_opt & init_opt) {
|
||||
std::vector<server_tokens> result;
|
||||
if (json_prompt.is_array() && !json_is_array_and_contains_numbers(json_prompt)) {
|
||||
result.reserve(json_prompt.size());
|
||||
for (const auto & p : json_prompt) {
|
||||
result.push_back(tokenize_input_subprompt(vocab, mctx, p,add_special, parse_special));
|
||||
result.push_back(tokenize_input_subprompt(vocab, mctx, p, add_special, parse_special, init_opt));
|
||||
}
|
||||
} else {
|
||||
result.push_back(tokenize_input_subprompt(vocab, mctx, json_prompt, add_special, parse_special));
|
||||
result.push_back(tokenize_input_subprompt(vocab, mctx, json_prompt, add_special, parse_special, init_opt));
|
||||
}
|
||||
if (result.empty()) {
|
||||
throw std::runtime_error("\"prompt\" must not be empty");
|
||||
@@ -1280,6 +1285,12 @@ json oaicompat_chat_params_parse(
|
||||
if (inputs.continue_final_message != COMMON_CHAT_CONTINUATION_NONE && inputs.add_generation_prompt) {
|
||||
throw std::invalid_argument("Cannot set both add_generation_prompt and continue_final_message to true.");
|
||||
}
|
||||
if (inputs.continue_final_message != COMMON_CHAT_CONTINUATION_NONE
|
||||
&& !inputs.messages.empty()
|
||||
&& inputs.messages.back().role == "assistant"
|
||||
&& !inputs.messages.back().tool_calls.empty()) {
|
||||
throw std::invalid_argument("Cannot continue an assistant message that contains tool calls.");
|
||||
}
|
||||
inputs.reasoning_format = opt.reasoning_format;
|
||||
if (body.contains("reasoning_format")) {
|
||||
inputs.reasoning_format = common_reasoning_format_from_name(body.at("reasoning_format").get<std::string>());
|
||||
@@ -1781,7 +1792,8 @@ server_tokens format_prompt_rerank(
|
||||
const struct llama_vocab * vocab,
|
||||
mtmd_context * mctx,
|
||||
const std::string & query,
|
||||
const std::string & doc) {
|
||||
const std::string & doc,
|
||||
const mtmd_helper_init_opt & init_opt) {
|
||||
server_tokens result = {};
|
||||
|
||||
const char * rerank_prompt = llama_model_chat_template(model, "rerank");
|
||||
@@ -1790,12 +1802,12 @@ server_tokens format_prompt_rerank(
|
||||
std::string prompt = rerank_prompt;
|
||||
string_replace_all(prompt, "{query}" , query);
|
||||
string_replace_all(prompt, "{document}", doc );
|
||||
server_tokens tokens = tokenize_input_subprompt(vocab, mctx, prompt, false, true);
|
||||
server_tokens tokens = tokenize_input_subprompt(vocab, mctx, prompt, false, true, init_opt);
|
||||
result.push_back(tokens);
|
||||
} else {
|
||||
// Get EOS token - use SEP token as fallback if EOS is not available
|
||||
server_tokens query_tokens = tokenize_input_subprompt(vocab, mctx, query, false, false);
|
||||
server_tokens doc_tokens = tokenize_input_subprompt(vocab, mctx, doc, false, false);
|
||||
server_tokens query_tokens = tokenize_input_subprompt(vocab, mctx, query, false, false, init_opt);
|
||||
server_tokens doc_tokens = tokenize_input_subprompt(vocab, mctx, doc, false, false, init_opt);
|
||||
llama_token eos_token = llama_vocab_eos(vocab);
|
||||
if (eos_token == LLAMA_TOKEN_NULL) {
|
||||
eos_token = llama_vocab_sep(vocab);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "llama.h"
|
||||
#include "chat.h"
|
||||
#include "mtmd.h"
|
||||
#include "mtmd-helper.h"
|
||||
|
||||
#include "json.h"
|
||||
|
||||
@@ -269,7 +270,12 @@ size_t validate_utf8(const std::string& text);
|
||||
|
||||
// process mtmd prompt, return the server_tokens containing both text tokens and media chunks
|
||||
// if is_placeholder is true, the media chunk will be treated as placeholder for counting tokens; the output tokens are not usable for actual inference (e.g. for submitting a task to server_queue)
|
||||
server_tokens process_mtmd_prompt(mtmd_context * mctx, const std::string & prompt, const std::vector<raw_buffer> & files, bool is_placeholder = false);
|
||||
server_tokens process_mtmd_prompt(
|
||||
mtmd_context * mctx,
|
||||
const std::string & prompt,
|
||||
const std::vector<raw_buffer> & files,
|
||||
const mtmd_helper_init_opt & init_opt,
|
||||
bool is_placeholder = false);
|
||||
|
||||
/**
|
||||
* break the input "prompt" object into multiple prompt if needed, then tokenize them
|
||||
@@ -289,7 +295,8 @@ std::vector<server_tokens> tokenize_input_prompts(
|
||||
mtmd_context * mctx,
|
||||
const json & json_prompt,
|
||||
bool add_special,
|
||||
bool parse_special);
|
||||
bool parse_special,
|
||||
const mtmd_helper_init_opt & init_opt);
|
||||
|
||||
//
|
||||
// OAI utils
|
||||
@@ -538,7 +545,8 @@ server_tokens format_prompt_rerank(
|
||||
const struct llama_vocab * vocab,
|
||||
mtmd_context * mctx,
|
||||
const std::string & query,
|
||||
const std::string & doc);
|
||||
const std::string & doc,
|
||||
const mtmd_helper_init_opt & init_opt);
|
||||
|
||||
// simple implementation of a pipe
|
||||
// used for streaming data between threads
|
||||
|
||||
@@ -794,6 +794,8 @@ public:
|
||||
llama_model * model_tgt = nullptr;
|
||||
|
||||
mtmd_context * mctx = nullptr;
|
||||
// note: video_params.ffmpeg_bin_dir points into params_base, which outlives this struct
|
||||
mtmd_helper_init_opt init_opt = mtmd_helper_init_opt_default();
|
||||
const llama_vocab * vocab = nullptr;
|
||||
|
||||
server_queue queue_tasks;
|
||||
@@ -1118,6 +1120,11 @@ private:
|
||||
}
|
||||
SRV_INF("loaded multimodal model, '%s'\n", mmproj_path.c_str());
|
||||
|
||||
init_opt.video_params.fps_target = params_base.video_fps;
|
||||
init_opt.video_params.timestamp_interval_ms = params_base.video_timestamp_interval_ms;
|
||||
init_opt.video_params.ffmpeg_bin_dir = params_base.video_ffmpeg_bin_dir.empty()
|
||||
? nullptr : params_base.video_ffmpeg_bin_dir.c_str();
|
||||
|
||||
if (params_base.ctx_shift) {
|
||||
params_base.ctx_shift = false;
|
||||
SRV_WRN("%s\n", "ctx_shift is not supported by multimodal, it will be disabled");
|
||||
@@ -2134,9 +2141,9 @@ private:
|
||||
try {
|
||||
auto & prompt = task.cli_prompt;
|
||||
if (mctx != nullptr) {
|
||||
task.tokens = process_mtmd_prompt(mctx, prompt, task.cli_files);
|
||||
task.tokens = process_mtmd_prompt(mctx, prompt, task.cli_files, init_opt);
|
||||
} else {
|
||||
task.tokens = std::move(tokenize_input_prompts(vocab, mctx, prompt, true, true)[0]);
|
||||
task.tokens = std::move(tokenize_input_prompts(vocab, mctx, prompt, true, true, init_opt)[0]);
|
||||
}
|
||||
task.cli_prompt.clear();
|
||||
task.cli_files.clear();
|
||||
@@ -4165,10 +4172,10 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
|
||||
|
||||
if (res_type != TASK_RESPONSE_TYPE_NONE && ctx_server.mctx != nullptr) {
|
||||
// This is the case used by OAI compatible chat path with MTMD. TODO It can be moved to the path below.
|
||||
inputs.push_back(process_mtmd_prompt(ctx_server.mctx, prompt.get<std::string>(), files));
|
||||
inputs.push_back(process_mtmd_prompt(ctx_server.mctx, prompt.get<std::string>(), files, ctx_server.init_opt));
|
||||
} else {
|
||||
// Everything else, including multimodal completions.
|
||||
inputs = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true);
|
||||
inputs = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true, ctx_server.init_opt);
|
||||
}
|
||||
|
||||
// tasks.reserve(inputs.size()); // TODO: this is inaccurate due to child tasks
|
||||
@@ -4752,7 +4759,7 @@ void server_routes::init_routes() {
|
||||
data["input_extra"] = input_extra; // default to empty array if it's not exist
|
||||
|
||||
std::string prompt = json_value(data, "prompt", std::string());
|
||||
std::vector<server_tokens> tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, false, true);
|
||||
std::vector<server_tokens> tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, false, true, ctx_server.init_opt);
|
||||
SRV_DBG("creating infill tasks, n_prompts = %d\n", (int) tokenized_prompts.size());
|
||||
data["prompt"] = format_prompt_infill(
|
||||
ctx_server.vocab,
|
||||
@@ -4816,7 +4823,7 @@ void server_routes::init_routes() {
|
||||
};
|
||||
|
||||
this->post_chat_completions_tok = [this](const server_http_req & req) {
|
||||
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, req, TASK_RESPONSE_TYPE_OAI_CHAT);
|
||||
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, ctx_server.init_opt, req, TASK_RESPONSE_TYPE_OAI_CHAT);
|
||||
};
|
||||
|
||||
this->post_control = [this](const server_http_req & req) {
|
||||
@@ -4875,7 +4882,7 @@ void server_routes::init_routes() {
|
||||
};
|
||||
|
||||
this->post_responses_tok_oai = [this](const server_http_req & req) {
|
||||
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, req, TASK_RESPONSE_TYPE_OAI_RESP);
|
||||
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, ctx_server.init_opt, req, TASK_RESPONSE_TYPE_OAI_RESP);
|
||||
};
|
||||
|
||||
this->post_transcriptions_oai = [this](const server_http_req & req) {
|
||||
@@ -4925,7 +4932,7 @@ void server_routes::init_routes() {
|
||||
};
|
||||
|
||||
this->post_anthropic_count_tokens = [this](const server_http_req & req) {
|
||||
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, req, TASK_RESPONSE_TYPE_ANTHROPIC);
|
||||
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, ctx_server.init_opt, req, TASK_RESPONSE_TYPE_ANTHROPIC);
|
||||
};
|
||||
|
||||
// same with handle_chat_completions, but without inference part
|
||||
@@ -5058,7 +5065,7 @@ void server_routes::init_routes() {
|
||||
std::vector<server_task> tasks;
|
||||
tasks.reserve(documents.size());
|
||||
for (size_t i = 0; i < documents.size(); i++) {
|
||||
auto tmp = format_prompt_rerank(ctx_server.model_tgt, ctx_server.vocab, ctx_server.mctx, query, documents[i]);
|
||||
auto tmp = format_prompt_rerank(ctx_server.model_tgt, ctx_server.vocab, ctx_server.mctx, query, documents[i], ctx_server.init_opt);
|
||||
server_task task = server_task(SERVER_TASK_TYPE_RERANK);
|
||||
task.id = rd.get_new_id();
|
||||
task.tokens = std::move(tmp);
|
||||
@@ -5296,7 +5303,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_embeddings_impl(cons
|
||||
}
|
||||
}
|
||||
|
||||
auto tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true);
|
||||
auto tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true, ctx_server.init_opt);
|
||||
for (const auto & tokens : tokenized_prompts) {
|
||||
// this check is necessary for models that do not add BOS token to the input
|
||||
if (tokens.empty()) {
|
||||
@@ -5357,7 +5364,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_embeddings_impl(cons
|
||||
return res;
|
||||
}
|
||||
|
||||
std::unique_ptr<server_res_generator> server_routes::handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const server_http_req & req, task_response_type res_type) {
|
||||
std::unique_ptr<server_res_generator> server_routes::handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const mtmd_helper_init_opt & init_opt, const server_http_req & req, task_response_type res_type) {
|
||||
auto res = create_response();
|
||||
std::vector<raw_buffer> files;
|
||||
json body = json::parse(req.body);
|
||||
@@ -5395,7 +5402,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_count_tokens(const l
|
||||
if (!prompt.is_string()) {
|
||||
throw std::runtime_error("for mtmd, input prompt must be a string.");
|
||||
}
|
||||
n_tokens = process_mtmd_prompt(mctx, prompt.get<std::string>(), files, true).size();
|
||||
n_tokens = process_mtmd_prompt(mctx, prompt.get<std::string>(), files, init_opt, true).size();
|
||||
} else {
|
||||
n_tokens = tokenize_mixed(vocab, prompt, true, true).size();
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ private:
|
||||
std::unique_ptr<server_res_generator> handle_slots_restore(const server_http_req & req, int id_slot);
|
||||
std::unique_ptr<server_res_generator> handle_slots_erase(const server_http_req &, int id_slot);
|
||||
std::unique_ptr<server_res_generator> handle_embeddings_impl(const server_http_req & req, task_response_type res_type);
|
||||
std::unique_ptr<server_res_generator> handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const server_http_req & req, task_response_type res_type);
|
||||
std::unique_ptr<server_res_generator> handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const mtmd_helper_init_opt & init_opt, const server_http_req & req, task_response_type res_type);
|
||||
|
||||
// using unique_ptr to allow late initialization of const
|
||||
std::unique_ptr<const server_context_meta> meta;
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ int main(int argc, char ** argv) {
|
||||
|
||||
mtmd::bitmap_ptr speaker_bitmap;
|
||||
if (!params.tts_speaker_file.empty()) {
|
||||
auto wrapper = mtmd_helper_bitmap_init_from_file(mctx.get(), params.tts_speaker_file.c_str(), false);
|
||||
auto wrapper = mtmd_helper_bitmap_init_from_file(mctx.get(), params.tts_speaker_file.c_str(), false, mtmd_helper_init_opt_default());
|
||||
if (!wrapper.bitmap) {
|
||||
LOG_ERR("failed to load speaker file %s\n", params.tts_speaker_file.c_str());
|
||||
return 1;
|
||||
|
||||
+151
-3
@@ -12,6 +12,107 @@ import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
// Require a blank line between sibling element-like nodes in a Svelte template
|
||||
// (elements, components, and the {#if} / {#each} / {#await} / {#snippet} /
|
||||
// {@render} blocks) that sit on separate lines at the same nesting level.
|
||||
// Whitespace between siblings is a whitespace-only SvelteText node; when it
|
||||
// holds a single newline (no blank line) the fix adds one, keeping the
|
||||
// indentation of the second sibling. Real text content (e.g. `foo\n\nbar`)
|
||||
// is left alone.
|
||||
const ELEMENT_LIKE_TYPES = new Set([
|
||||
'SvelteAwaitBlock',
|
||||
'SvelteComponent',
|
||||
'SvelteEachBlock',
|
||||
'SvelteElement',
|
||||
'SvelteIfBlock',
|
||||
'SvelteKeyBlock',
|
||||
'SvelteRenderTag',
|
||||
'SvelteSelf',
|
||||
'SvelteSnippetBlock'
|
||||
]);
|
||||
const paddingLineBetweenElements = {
|
||||
create(context) {
|
||||
// Check one list of template children. Each children array holds the
|
||||
// element-like nodes plus the whitespace/comment text between them.
|
||||
function checkChildren(children) {
|
||||
if (!Array.isArray(children)) return;
|
||||
|
||||
let lastElement = null;
|
||||
let lastWhitespace = null;
|
||||
|
||||
for (const child of children) {
|
||||
if (child.type === 'SvelteText' && /^\s*$/.test(child.value)) {
|
||||
lastWhitespace = child;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ELEMENT_LIKE_TYPES.has(child.type)) continue;
|
||||
|
||||
if (
|
||||
lastElement &&
|
||||
lastWhitespace &&
|
||||
child.loc.start.line - lastElement.loc.end.line === 1
|
||||
) {
|
||||
const textNode = lastWhitespace;
|
||||
|
||||
context.report({
|
||||
fix(fixer) {
|
||||
// Add a second newline so the two siblings are separated by a
|
||||
// blank line, keeping the trailing indentation.
|
||||
return fixer.replaceText(textNode, textNode.value.replace(/\n/, '\n\n'));
|
||||
},
|
||||
message: 'Expected a blank line between sibling elements.',
|
||||
node: child
|
||||
});
|
||||
}
|
||||
|
||||
lastElement = child;
|
||||
lastWhitespace = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SvelteAwaitBlock(node) {
|
||||
checkChildren(node.children);
|
||||
checkChildren(node.then?.children);
|
||||
checkChildren(node.else?.children);
|
||||
},
|
||||
SvelteComponent(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteEachBlock(node) {
|
||||
checkChildren(node.children);
|
||||
checkChildren(node.else?.children);
|
||||
},
|
||||
SvelteElement(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteFragment(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteIfBlock(node) {
|
||||
checkChildren(node.children);
|
||||
checkChildren(node.else?.children);
|
||||
},
|
||||
SvelteKeyBlock(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteProgram(node) {
|
||||
checkChildren(node.children);
|
||||
},
|
||||
SvelteSnippetBlock(node) {
|
||||
checkChildren(node.children);
|
||||
}
|
||||
};
|
||||
},
|
||||
meta: {
|
||||
docs: { description: 'Require a blank line between sibling elements in a Svelte template.' },
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
type: 'layout'
|
||||
}
|
||||
};
|
||||
// Require a blank line between consecutive class accessors (get/set). The core
|
||||
// `padding-line-between-statements` rule only handles statements, not class
|
||||
// members, so this is enforced with a small custom rule.
|
||||
@@ -66,7 +167,12 @@ export default ts.config(
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
plugins: {
|
||||
local: { rules: { 'blank-line-between-accessors': blankLineBetweenAccessors } },
|
||||
local: {
|
||||
rules: {
|
||||
'blank-line-between-accessors': blankLineBetweenAccessors,
|
||||
'padding-line-between-elements': paddingLineBetweenElements
|
||||
}
|
||||
},
|
||||
perfectionist,
|
||||
'simple-import-sort': simpleImportSort
|
||||
},
|
||||
@@ -82,6 +188,8 @@ export default ts.config(
|
||||
'eol-last': 'error',
|
||||
// Enforce a blank line between consecutive get/set accessors
|
||||
'local/blank-line-between-accessors': 'error',
|
||||
// Require a blank line between sibling elements in a Svelte template
|
||||
'local/padding-line-between-elements': 'error',
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off',
|
||||
@@ -156,9 +264,49 @@ export default ts.config(
|
||||
// grouping); Prettier normalizes comma spacing afterwards.
|
||||
'simple-import-sort/imports': ['error', { groups: [['.*']] }],
|
||||
'svelte/no-at-html-tags': 'off',
|
||||
|
||||
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
|
||||
// Sort HTML attributes alphabetically in the markup. The Svelte directives
|
||||
// (bind:/use:/animate:/style:/in:/out:/transition:/class:) sort first,
|
||||
// alphabetically among themselves, then all remaining attributes sort
|
||||
// alphabetically. The rule keeps spread attributes in place and does not cross
|
||||
// them. `this` stays first on <svelte:element> because Prettier forces it there
|
||||
// - reordering it alphabetically would fight the formatter.
|
||||
'svelte/sort-attributes': [
|
||||
'error',
|
||||
{
|
||||
order: [
|
||||
'this',
|
||||
{
|
||||
match: [
|
||||
'/^bind:/u',
|
||||
'/^use:/u',
|
||||
'/^animate:/u',
|
||||
'/^style:/u',
|
||||
'/^in:/u',
|
||||
'/^out:/u',
|
||||
'/^transition:/u',
|
||||
'/^class:/u'
|
||||
],
|
||||
sort: 'alphabetical'
|
||||
},
|
||||
{
|
||||
match: [
|
||||
'!/^bind:/u',
|
||||
'!/^use:/u',
|
||||
'!/^animate:/u',
|
||||
'!/^style:/u',
|
||||
'!/^in:/u',
|
||||
'!/^out:/u',
|
||||
'!/^transition:/u',
|
||||
'!/^class:/u'
|
||||
],
|
||||
sort: 'alphabetical'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -41,17 +41,17 @@
|
||||
{#snippet button(props = {})}
|
||||
<Button
|
||||
{...props}
|
||||
{href}
|
||||
{variant}
|
||||
{size}
|
||||
aria-label={ariaLabel || tooltip}
|
||||
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
|
||||
{disabled}
|
||||
{href}
|
||||
onclick={(e: MouseEvent) => {
|
||||
if (stopPropagationOnClick) e.stopPropagation();
|
||||
|
||||
onclick?.(e);
|
||||
}}
|
||||
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
|
||||
aria-label={ariaLabel || tooltip}
|
||||
{size}
|
||||
{variant}
|
||||
>
|
||||
{#if icon}
|
||||
{@const IconComponent = icon}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
</script>
|
||||
|
||||
<ActionIcon
|
||||
icon={Copy}
|
||||
tooltip={ariaLabel}
|
||||
iconSize={ICON_CLASS_DEFAULT}
|
||||
disabled={!canCopy}
|
||||
icon={Copy}
|
||||
iconSize={ICON_CLASS_DEFAULT}
|
||||
onclick={() => canCopy && copyToClipboard(text)}
|
||||
tooltip={ariaLabel}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -108,13 +108,13 @@
|
||||
{/if}
|
||||
|
||||
<DialogChatAttachmentsPreview
|
||||
bind:open={viewAllDialogOpen}
|
||||
{activeModelId}
|
||||
{attachments}
|
||||
bind:open={viewAllDialogOpen}
|
||||
{previewFocusIndex}
|
||||
{uploadedFiles}
|
||||
/>
|
||||
|
||||
{#if mcpResourcePreviewExtra}
|
||||
<DialogMcpResourcePreview extra={mcpResourcePreviewExtra} bind:open={mcpResourcePreviewOpen} />
|
||||
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
|
||||
{/if}
|
||||
|
||||
+17
-17
@@ -75,58 +75,58 @@
|
||||
{#if mcpPrompt}
|
||||
<ChatAttachmentsListItemMcpPrompt
|
||||
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
isLoading={item.isLoading}
|
||||
loadError={item.loadError}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
prompt={mcpPrompt}
|
||||
{readonly}
|
||||
/>
|
||||
{/if}
|
||||
{:else if isMcpResource(item)}
|
||||
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
|
||||
|
||||
<ChatAttachmentsListItemMcpResource
|
||||
class="flex-shrink-0 {className} {scrollClasses}"
|
||||
attachment={toMcpResourceAttachment(mcpResource, item.id)}
|
||||
class="flex-shrink-0 {className} {scrollClasses}"
|
||||
onclick={() => onMcpResourcePreview?.(mcpResource)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentsListItemThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
|
||||
height={imageHeight}
|
||||
id={item.id}
|
||||
{imageClass}
|
||||
name={item.name}
|
||||
onRemove={onFileRemove}
|
||||
onclick={() => onPreview?.(item)}
|
||||
preview={item.preview}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
height={imageHeight}
|
||||
width={imageWidth}
|
||||
{imageClass}
|
||||
onclick={() => onPreview?.(item)}
|
||||
/>
|
||||
{:else if isPdfFile(item.attachment, item.uploadedFile)}
|
||||
<ChatAttachmentsListItemThumbnailFile
|
||||
attachment={item.attachment}
|
||||
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
size={item.size}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
textContent={item.textContent}
|
||||
attachment={item.attachment}
|
||||
uploadedFile={item.uploadedFile}
|
||||
onclick={() => onPreview?.(item)}
|
||||
{readonly}
|
||||
size={item.size}
|
||||
textContent={item.textContent}
|
||||
uploadedFile={item.uploadedFile}
|
||||
/>
|
||||
{:else}
|
||||
<ChatAttachmentsListItemThumbnailFile
|
||||
attachment={item.attachment}
|
||||
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
size={item.size}
|
||||
{readonly}
|
||||
onRemove={onFileRemove}
|
||||
textContent={item.textContent}
|
||||
attachment={item.attachment}
|
||||
uploadedFile={item.uploadedFile}
|
||||
onclick={() => onPreview?.(item)}
|
||||
{readonly}
|
||||
size={item.size}
|
||||
textContent={item.textContent}
|
||||
uploadedFile={item.uploadedFile}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
<div
|
||||
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.()} />
|
||||
<ActionIcon icon={X} onclick={() => onRemove?.()} stopPropagationOnClick tooltip="Remove" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@
|
||||
<div
|
||||
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
|
||||
<ActionIcon icon={X} onclick={() => onRemove?.(id)} stopPropagationOnClick tooltip="Remove" />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet image()}
|
||||
<img src={preview} alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" />
|
||||
<img alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" src={preview} />
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
|
||||
+11
-11
@@ -185,30 +185,30 @@
|
||||
|
||||
<div class="{className} flex flex-col text-white">
|
||||
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
|
||||
<ChatAttachmentsPreviewNavButtons onPrev={prev} onNext={next} show={allItems.length > 1} />
|
||||
<ChatAttachmentsPreviewNavButtons onNext={next} onPrev={prev} show={allItems.length > 1} />
|
||||
|
||||
<div class="flex h-full w-full flex-col items-center justify-start overflow-auto py-4">
|
||||
{#if currentItem}
|
||||
<ChatAttachmentsPreviewFileInfo {displayName} {fileSize} />
|
||||
|
||||
<ChatAttachmentsPreviewCurrentItem
|
||||
{activeModelId}
|
||||
{audioSrc}
|
||||
{currentItem}
|
||||
{isImage}
|
||||
{isAudio}
|
||||
{isVideo}
|
||||
{isPdf}
|
||||
{isText}
|
||||
{displayPreview}
|
||||
{displayTextContent}
|
||||
{audioSrc}
|
||||
{videoSrc}
|
||||
{language}
|
||||
{hasVisionModality}
|
||||
{activeModelId}
|
||||
{isAudio}
|
||||
{isImage}
|
||||
{isPdf}
|
||||
{isText}
|
||||
{isVideo}
|
||||
{language}
|
||||
{videoSrc}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
|
||||
<ChatAttachmentsPreviewThumbnailStrip {currentIndex} items={allItems} {onNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -53,18 +53,18 @@
|
||||
{#key currentItem.id}
|
||||
{#if isPdf}
|
||||
<ChatAttachmentsPreviewCurrentItemPdf
|
||||
{activeModelId}
|
||||
{currentItem}
|
||||
displayName={currentItem.name}
|
||||
{displayTextContent}
|
||||
{hasVisionModality}
|
||||
{activeModelId}
|
||||
/>
|
||||
{:else if isImage}
|
||||
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
|
||||
{:else if isText && displayTextContent}
|
||||
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
|
||||
{:else if isAudio}
|
||||
<ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
|
||||
<ChatAttachmentsPreviewCurrentItemAudio {audioSrc} {currentItem} />
|
||||
{:else if isVideo}
|
||||
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
|
||||
{:else if isUnavailable}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
<Music class="mx-auto mb-4 h-16 w-16 text-white/50" />
|
||||
|
||||
{#if audioSrc}
|
||||
<audio controls class="mb-4 w-full" src={audioSrc}>
|
||||
<audio class="mb-4 w-full" controls src={audioSrc}>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
{:else}
|
||||
|
||||
+1
-1
@@ -10,9 +10,9 @@
|
||||
{#if displayPreview}
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<img
|
||||
src={displayPreview}
|
||||
alt={currentItem?.name || 'preview'}
|
||||
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
|
||||
src={displayPreview}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+15
-7
@@ -87,20 +87,20 @@
|
||||
|
||||
<div class="mb-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
|
||||
disabled={pdfImagesLoading}
|
||||
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
|
||||
size="sm"
|
||||
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
|
||||
>
|
||||
<FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
|
||||
Text
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
|
||||
disabled={pdfImagesLoading}
|
||||
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
|
||||
size="sm"
|
||||
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
|
||||
>
|
||||
{#if pdfImagesLoading}
|
||||
<div
|
||||
@@ -116,7 +116,9 @@
|
||||
{#if !hasVisionModality && activeModelId && currentItem}
|
||||
<Alert.Root class="mb-4 max-w-4xl">
|
||||
<Info class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<Alert.Title>Preview only</Alert.Title>
|
||||
|
||||
<Alert.Description>
|
||||
<span class="inline-flex">
|
||||
The selected model does not support vision. Only the extracted
|
||||
@@ -140,6 +142,7 @@
|
||||
<div
|
||||
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
|
||||
></div>
|
||||
|
||||
<p class="text-white/70">Converting PDF to images...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,20 +150,25 @@
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div class="text-center">
|
||||
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
|
||||
|
||||
<p class="mb-4 text-white/70">Failed to load PDF images</p>
|
||||
|
||||
<p class="text-sm text-white/50">{pdfImagesError}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if pdfImages.length > 0}
|
||||
{#each pdfImages as image, index (image)}
|
||||
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
|
||||
<img src={image} alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" />
|
||||
|
||||
<img alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" src={image} />
|
||||
|
||||
<div class="h-4"></div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div class="text-center">
|
||||
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
|
||||
|
||||
<p class="text-white/70">No PDF pages available</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
<Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
|
||||
|
||||
{#if videoSrc}
|
||||
<video controls class="mb-4 w-full" src={videoSrc}>
|
||||
<video class="mb-4 w-full" controls src={videoSrc}>
|
||||
<track kind="captions" src="" />
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
|
||||
+6
-6
@@ -13,21 +13,21 @@
|
||||
|
||||
{#if show}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Previous"
|
||||
class="absolute top-1/2 left-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
|
||||
onclick={onPrev}
|
||||
aria-label="Previous"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Next"
|
||||
class="absolute top-1/2 right-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
|
||||
onclick={onNext}
|
||||
aria-label="Next"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</Button>
|
||||
|
||||
+2
-2
@@ -38,16 +38,16 @@
|
||||
{#each items as item, index (item.id)}
|
||||
<button
|
||||
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
|
||||
aria-label={`Go to ${item.name}`}
|
||||
class={[
|
||||
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
|
||||
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
|
||||
'[&:not(:first-child)]:last:mr-4 [&:not(:last-child)]:first:ml-4'
|
||||
]}
|
||||
onclick={() => onNavigate(index)}
|
||||
aria-label={`Go to ${item.name}`}
|
||||
>
|
||||
{#if item.isImage && item.preview}
|
||||
<img src={item.preview} alt={item.name} class="h-12 w-12 object-cover" />
|
||||
<img alt={item.name} class="h-12 w-12 object-cover" src={item.preview} />
|
||||
{:else}
|
||||
<div
|
||||
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
ChatFormInputFileInputInvisible,
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
DialogMcpResourcesBrowser
|
||||
DialogMcpResourcesBrowser,
|
||||
DialogMcpServers
|
||||
} from '$lib/components/app';
|
||||
import {
|
||||
CLIPBOARD_CONTENT_QUOTE_PREFIX,
|
||||
@@ -183,6 +184,9 @@
|
||||
let isResourceDialogOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
|
||||
// MCP Servers Dialog State
|
||||
let isMcpServersDialogOpen = $state(false);
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
@@ -537,30 +541,30 @@
|
||||
>
|
||||
<ChatFormPickers
|
||||
bind:this={pickersRef}
|
||||
isCommandPickerOpen={pickers.isCommandPickerOpen}
|
||||
commandQuery={pickers.commandQuery}
|
||||
commands={pickers.availableCommands}
|
||||
isCommandPickerOpen={pickers.isCommandPickerOpen}
|
||||
isMentionPickerOpen={pickers.isMentionPickerOpen}
|
||||
isPromptPickerOpen={pickers.isPromptPickerOpen}
|
||||
{mentionAnchor}
|
||||
mentionQuery={pickers.mentionQuery}
|
||||
onCommandPickerClose={pickers.handleCommandPickerClose}
|
||||
onCommandSelect={pickers.handleCommandSelect}
|
||||
isPromptPickerOpen={pickers.isPromptPickerOpen}
|
||||
promptSearchQuery={pickers.promptSearchQuery}
|
||||
isMentionPickerOpen={pickers.isMentionPickerOpen}
|
||||
mentionQuery={pickers.mentionQuery}
|
||||
{mentionAnchor}
|
||||
scopePath={pickers.mentionScopePath}
|
||||
onPromptPickerClose={pickers.handlePromptPickerClose}
|
||||
onMentionPickerClose={pickers.handleMentionPickerClose}
|
||||
onMentionOpened={() => inputRef?.focus()}
|
||||
onMentionPickerClose={pickers.handleMentionPickerClose}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptPickerClose={pickers.handlePromptPickerClose}
|
||||
promptSearchQuery={pickers.promptSearchQuery}
|
||||
scopePath={pickers.mentionScopePath}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={mentionAnchor}
|
||||
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
|
||||
></div>
|
||||
|
||||
<div
|
||||
@@ -570,29 +574,29 @@
|
||||
data-slot="input-area"
|
||||
>
|
||||
<ChatAttachmentsList
|
||||
{attachments}
|
||||
bind:uploadedFiles
|
||||
onFileRemove={handleFileRemove}
|
||||
limitToSingleRow
|
||||
class="py-5"
|
||||
style="scroll-padding: 1rem;"
|
||||
activeModelId={activeModelId ?? undefined}
|
||||
{attachments}
|
||||
class="py-5"
|
||||
limitToSingleRow
|
||||
onFileRemove={handleFileRemove}
|
||||
style="scroll-padding: 1rem;"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
|
||||
>
|
||||
<ChatFormInput
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
{disabled}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onKeydown={handleKeydown}
|
||||
onPaste={handlePaste}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
{useRichInput}
|
||||
/>
|
||||
@@ -608,22 +612,23 @@
|
||||
{/if}
|
||||
|
||||
<ChatFormActions
|
||||
class="px-3"
|
||||
bind:this={chatFormActionsRef}
|
||||
canSend={canSubmit}
|
||||
class="px-3"
|
||||
{disabled}
|
||||
{isLoading}
|
||||
isReasoning={chatStore.isReasoning}
|
||||
{isRecording}
|
||||
{showAddButton}
|
||||
{showModelSelector}
|
||||
{uploadedFiles}
|
||||
onFileUpload={handleFileUpload}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
|
||||
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
|
||||
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
|
||||
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
|
||||
{showAddButton}
|
||||
{showModelSelector}
|
||||
{uploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -632,21 +637,20 @@
|
||||
|
||||
{#if toolsStore.hasEnabledCwdTools}
|
||||
<ChatFormCurrentWorkingDirectory
|
||||
directory={cwd}
|
||||
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
||||
bind:query={pickers.workingDirectoryQuery}
|
||||
customAnchor={mentionAnchor}
|
||||
directory={cwd}
|
||||
{disabled}
|
||||
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
||||
onChange={handleWorkingDirectoryChange}
|
||||
onClose={pickers.handleWorkingDirectoryClose}
|
||||
onOpen={pickers.handleWorkingDirectoryOpen}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<DialogMcpResourcesBrowser
|
||||
bind:open={isResourceDialogOpen}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
onAttach={(resource: MCPResourceInfo) => {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
}}
|
||||
@@ -655,4 +659,7 @@
|
||||
preSelectedResourceUri = undefined;
|
||||
}
|
||||
}}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
/>
|
||||
|
||||
<DialogMcpServers bind:open={isMcpServersDialogOpen} />
|
||||
|
||||
+1
-1
@@ -18,8 +18,8 @@
|
||||
class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
|
||||
{disabled}
|
||||
{onclick}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
|
||||
|
||||
|
||||
+9
-39
@@ -1,10 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddMcpServersSubmenu,
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
ChatFormActionAddToolsSubmenu
|
||||
} from '$lib/components/app';
|
||||
import { File, MessageSquare, Plus } from '@lucide/svelte';
|
||||
import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -31,11 +27,6 @@
|
||||
// must not restore focus to the trigger on close
|
||||
let suppressCloseAutoFocus = false;
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
dropdownOpen = false;
|
||||
chatFormActions.onMcpSettingsClick?.();
|
||||
}
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasAudioModality: chatFormActions.hasAudioModality,
|
||||
@@ -93,10 +84,6 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
@@ -156,31 +143,14 @@
|
||||
|
||||
<ChatFormActionAddToolsSubmenu />
|
||||
|
||||
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpSettingsClick}
|
||||
>
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpPromptClick}
|
||||
>
|
||||
<Zap class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP Prompt</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if chatFormActions.hasMcpResourcesSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpResourcesClick}
|
||||
>
|
||||
<FolderOpen class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP Resources</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Settings } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { conversationsStore, mcpStore } from '$lib/stores';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
onMcpSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let { onMcpSettingsClick }: Props = $props();
|
||||
|
||||
let mcpSearchQuery = $state('');
|
||||
// Every configured server is listed; `enabled` is an on/off state,
|
||||
// not a visibility filter, so a disabled server stays toggleable.
|
||||
let mcpServers = $derived(mcpStore.getServers());
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
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.preferences.isMcpServerEnabledForChat(serverId);
|
||||
}
|
||||
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.preferences.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
function handleMcpSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpSearchQuery = '';
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
onMcpSettingsClick?.();
|
||||
|
||||
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-72 pt-0">
|
||||
{#if hasMcpServers}
|
||||
<DropdownMenuSearchable
|
||||
placeholder="Search servers..."
|
||||
bind:searchValue={mcpSearchQuery}
|
||||
emptyMessage="No servers found"
|
||||
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)}
|
||||
{@const displayName = getServerLabel(server)}
|
||||
{@const faviconUrl = mcpStore.getServerFavicon(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">
|
||||
<div class="min-w-0 flex-1">
|
||||
<McpServerIdentity
|
||||
{displayName}
|
||||
{faviconUrl}
|
||||
iconClass={ICON_CLASS_DEFAULT}
|
||||
iconRounded="rounded-sm"
|
||||
showVersion={false}
|
||||
nameClass="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#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) => 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={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Manage MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
{:else}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpSettingsClick}
|
||||
>
|
||||
<Plus class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Add MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Root>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, Server, Zap } from '@lucide/svelte';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { getChatFormActionsContext } from '$lib/contexts';
|
||||
|
||||
const chatFormActions = getChatFormActionsContext();
|
||||
|
||||
function handleServersClick() {
|
||||
chatFormActions.onMcpSettingsClick?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-48">
|
||||
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={handleServersClick}>
|
||||
<Server class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpPromptClick}
|
||||
>
|
||||
<Zap class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Prompts</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if chatFormActions.hasMcpResourcesSupport}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpResourcesClick}
|
||||
>
|
||||
<FolderOpen class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Resources</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
+1
@@ -64,6 +64,7 @@
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
|
||||
+18
-18
@@ -78,7 +78,7 @@
|
||||
<Sheet.Root bind:open={sheetOpen}>
|
||||
{@render trigger({ disabled: chatFormActions.disabled, onclick: () => (sheetOpen = true) })}
|
||||
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
|
||||
<Sheet.Content class="max-h-[85vh] gap-0 overflow-y-auto" side="bottom">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Add to chat</Sheet.Title>
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<Collapsible.Root
|
||||
open={reasoningExpanded}
|
||||
onOpenChange={(open) => (reasoningExpanded = open)}
|
||||
open={reasoningExpanded}
|
||||
>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if reasoningExpanded}
|
||||
@@ -120,10 +120,10 @@
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemRowClass}
|
||||
class:bg-accent={reasoning.isSelected(level)}
|
||||
class={sheetItemRowClass}
|
||||
onclick={() => reasoning.select(level)}
|
||||
type="button"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
{#if reasoning.isSelected(level)}
|
||||
@@ -147,7 +147,7 @@
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
||||
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
|
||||
<Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if filesExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
@@ -166,9 +166,9 @@
|
||||
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
||||
{#if enabled}
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
||||
type="button"
|
||||
>
|
||||
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
{:else if item.disabledTooltip}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger>
|
||||
<button type="button" class={sheetItemClass} disabled>
|
||||
<button class={sheetItemClass} disabled type="button">
|
||||
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
<span>{item.label}</span>
|
||||
@@ -194,7 +194,7 @@
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
|
||||
<Collapsible.Root onOpenChange={(open) => (mcpExpanded = open)} open={mcpExpanded}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if mcpExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
@@ -223,21 +223,21 @@
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemRowClass}
|
||||
disabled={hasError}
|
||||
onclick={() =>
|
||||
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
|
||||
disabled={hasError}
|
||||
type="button"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={faviconUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
</Collapsible.Root>
|
||||
|
||||
{#if toolsPanel.totalToolCount > 0}
|
||||
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
|
||||
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if toolsExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
@@ -295,18 +295,18 @@
|
||||
{@const favicon = toolsPanel.getFavicon(group)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemRowClass}
|
||||
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
type="button"
|
||||
>
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={favicon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -319,8 +319,8 @@
|
||||
<Checkbox
|
||||
{checked}
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -330,9 +330,9 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
@@ -341,9 +341,9 @@
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
|
||||
type="button"
|
||||
>
|
||||
<Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
@@ -353,9 +353,9 @@
|
||||
|
||||
{#if chatFormActions.hasMcpResourcesSupport}
|
||||
<button
|
||||
type="button"
|
||||
class={sheetItemClass}
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
|
||||
|
||||
+5
-5
@@ -68,8 +68,8 @@
|
||||
{@const favicon = toolsPanel.getFavicon(group)}
|
||||
|
||||
<Collapsible.Root
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
|
||||
open={isExpanded}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Collapsible.Trigger
|
||||
@@ -84,12 +84,12 @@
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
src={favicon}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -107,8 +107,8 @@
|
||||
<Checkbox
|
||||
{...props}
|
||||
{checked}
|
||||
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
|
||||
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
@@ -127,14 +127,14 @@
|
||||
{#each group.tools as entry (entry.key)}
|
||||
{@const enabled = toolsStore.isToolEnabled(entry.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
|
||||
onclick={() => toolsStore.toggleTool(entry.key)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
data-slot="checkbox"
|
||||
data-state={enabled ? 'checked' : 'unchecked'}
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
{#if enabled}
|
||||
<Check class="size-3.5" />
|
||||
|
||||
+2
-2
@@ -139,17 +139,17 @@
|
||||
|
||||
{#if deviceStore.isMobile}
|
||||
<ModelsSelectorSheet
|
||||
disabled={disabled || isOffline}
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
{:else}
|
||||
<ModelsSelectorDropdown
|
||||
disabled={disabled || isOffline}
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
|
||||
+3
-2
@@ -17,16 +17,17 @@
|
||||
|
||||
{#snippet submitButton(props = {})}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
class={[
|
||||
'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
|
||||
showErrorState &&
|
||||
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
|
||||
]}
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
{...props}
|
||||
>
|
||||
<span class="sr-only">Send</span>
|
||||
|
||||
<ArrowUp class="h-12 w-12" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
+13
-12
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SkipForward, Square } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
ChatFormActionModels,
|
||||
@@ -10,7 +9,7 @@
|
||||
ChatFormContextGauge
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { setChatFormActionsContext } from '$lib/contexts';
|
||||
import { FileTypeCategory, MessageRole } from '$lib/enums';
|
||||
import { ChatService } from '$lib/services';
|
||||
@@ -34,6 +33,7 @@
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -47,6 +47,7 @@
|
||||
onFileUpload,
|
||||
onMcpPromptClick,
|
||||
onMcpResourcesClick,
|
||||
onMcpSettingsClick,
|
||||
onMicClick,
|
||||
onStop,
|
||||
onSystemPromptClick,
|
||||
@@ -163,7 +164,7 @@
|
||||
return onMcpResourcesClick;
|
||||
},
|
||||
get onMcpSettingsClick() {
|
||||
return () => goto(ROUTES.MCP_SERVERS);
|
||||
return onMcpSettingsClick;
|
||||
},
|
||||
get onSystemPromptClick() {
|
||||
return onSystemPromptClick;
|
||||
@@ -188,14 +189,14 @@
|
||||
|
||||
{#if showModelSelector}
|
||||
<ChatFormActionModels
|
||||
{disabled}
|
||||
bind:this={selectorModelRef}
|
||||
bind:hasAudioModality
|
||||
bind:hasModelSelected
|
||||
bind:hasVideoModality
|
||||
bind:hasVisionModality
|
||||
bind:hasModelSelected
|
||||
bind:isSelectedModelInCache
|
||||
bind:submitTooltip
|
||||
bind:this={selectorModelRef}
|
||||
{disabled}
|
||||
forceForegroundText
|
||||
useGlobalSelection
|
||||
/>
|
||||
@@ -204,12 +205,12 @@
|
||||
|
||||
{#if isReasoning}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="group h-8 w-8 rounded-full p-0"
|
||||
onclick={() =>
|
||||
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
|
||||
class="group h-8 w-8 rounded-full p-0"
|
||||
title="Skip reasoning"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<span class="sr-only">Skip reasoning</span>
|
||||
|
||||
@@ -221,10 +222,10 @@
|
||||
|
||||
{#if isLoading && !canSubmit}
|
||||
<Button
|
||||
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
|
||||
onclick={onStop}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onclick={onStop}
|
||||
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
|
||||
>
|
||||
<span class="sr-only">Stop</span>
|
||||
|
||||
@@ -238,8 +239,8 @@
|
||||
<ChatFormActionSubmit
|
||||
canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
|
||||
{disabled}
|
||||
tooltipLabel={submitTooltip}
|
||||
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
|
||||
tooltipLabel={submitTooltip}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -42,16 +42,16 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Context usage"
|
||||
data-context-gauge-trigger
|
||||
class="flex h-5 w-5 cursor-default items-center justify-center"
|
||||
data-context-gauge-trigger
|
||||
onclick={gaugeTriggerClick}
|
||||
onkeydown={gaugeTriggerKeydown}
|
||||
onpointerdown={gaugeTriggerPointerDown}
|
||||
onpointerenter={gaugeTriggerEnter}
|
||||
onpointerleave={gaugeTriggerLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
|
||||
<ContextGaugeDial level={gauge.colorLevel} percent={gauge.contextPercent} />
|
||||
</div>
|
||||
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<div class="grid gap-1.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-muted-foreground">{label}</span>
|
||||
|
||||
<span class="font-mono text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
|
||||
|
||||
+4
-2
@@ -57,12 +57,13 @@
|
||||
{#if cumulativeRead > 0}
|
||||
<ContextGaugeDetailRow
|
||||
label="Prompt tokens evaluated"
|
||||
value={`${cumulativeRead.toLocaleString()} tok`}
|
||||
subtitle={cumulativeCacheTotal > 0
|
||||
? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
|
||||
: undefined}
|
||||
value={`${cumulativeRead.toLocaleString()} tok`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if cumulativeOutput > 0}
|
||||
<ContextGaugeDetailRow
|
||||
label="Tokens generated"
|
||||
@@ -83,10 +84,10 @@
|
||||
{#if currentRead > 0}
|
||||
<ContextGaugeDetailRow
|
||||
label="Prompt"
|
||||
value={`${currentRead.toLocaleString()} tok`}
|
||||
subtitle={currentCache > 0
|
||||
? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
|
||||
: undefined}
|
||||
value={`${currentRead.toLocaleString()} tok`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -100,6 +101,7 @@
|
||||
<div class="pt-1 mt-0.5 border-t border-border/30">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">KV cache total</span>
|
||||
|
||||
<span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -18,7 +18,7 @@
|
||||
const strokeWidth = $derived(size === 'md' ? 4 : 3);
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 32 32" fill="none" class={dimensions}>
|
||||
<svg class={dimensions} fill="none" viewBox="0 0 32 32">
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
@@ -29,15 +29,15 @@
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="transition-colors duration-300 {strokeLevelClass}"
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={RADIUS}
|
||||
class="transition-colors duration-300 {strokeLevelClass}"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={CIRCUMFERENCE}
|
||||
stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
|
||||
stroke-linecap="round"
|
||||
stroke-width={strokeWidth}
|
||||
transform="rotate(-90 16 16)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
+3
-1
@@ -14,11 +14,13 @@
|
||||
{#if modelId !== null && !isLoading}
|
||||
<div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
||||
<span>Available context size is only visible once the model is loaded.</span>
|
||||
<Button size="sm" variant="secondary" class="self-start" onclick={onLoad}>Load model</Button>
|
||||
|
||||
<Button class="self-start" onclick={onLoad} size="sm" variant="secondary">Load model</Button>
|
||||
</div>
|
||||
{:else if isLoading}
|
||||
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
<span>Loading model...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+14
-11
@@ -54,17 +54,19 @@
|
||||
|
||||
{#if gaugePopup.open}
|
||||
<div
|
||||
role="status"
|
||||
bind:this={cardEl}
|
||||
class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
|
||||
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
|
||||
onpointerenter={gaugeCardEnter}
|
||||
onpointerleave={gaugeCardLeave}
|
||||
role="status"
|
||||
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Context</span>
|
||||
|
||||
<span class="text-muted-foreground">·</span>
|
||||
|
||||
<span class="font-mono text-muted-foreground">
|
||||
{formatParameters(gauge.contextUsed)}
|
||||
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
|
||||
@@ -73,8 +75,8 @@
|
||||
|
||||
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
|
||||
<ContextGaugeLoadModel
|
||||
modelId={gauge.activeModelId}
|
||||
isLoading={gauge.isActiveModelLoading}
|
||||
modelId={gauge.activeModelId}
|
||||
onLoad={gauge.loadModel}
|
||||
/>
|
||||
{:else if showProgressBar}
|
||||
@@ -91,6 +93,7 @@
|
||||
<span>
|
||||
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{formatParameters(gauge.contextAvailable ?? 0)} remaining
|
||||
</span>
|
||||
@@ -101,15 +104,15 @@
|
||||
|
||||
{#if gauge.hasAnyUsage}
|
||||
<ContextGaugeDetails
|
||||
currentRead={gauge.currentRead}
|
||||
currentFresh={gauge.currentFresh}
|
||||
currentCache={gauge.currentCache}
|
||||
currentOutput={gauge.currentOutput}
|
||||
kvTotal={gauge.kvTotal}
|
||||
cumulativeRead={gauge.cumulativeRead}
|
||||
cumulativeOutput={gauge.cumulativeOutput}
|
||||
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
|
||||
averageTokensPerSecond={gauge.averageTokensPerSecond}
|
||||
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
|
||||
cumulativeOutput={gauge.cumulativeOutput}
|
||||
cumulativeRead={gauge.cumulativeRead}
|
||||
currentCache={gauge.currentCache}
|
||||
currentFresh={gauge.currentFresh}
|
||||
currentOutput={gauge.currentOutput}
|
||||
currentRead={gauge.currentRead}
|
||||
kvTotal={gauge.kvTotal}
|
||||
transientDetails={gauge.transientDetails}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+22
-21
@@ -323,80 +323,81 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
|
||||
className
|
||||
]}
|
||||
onclick={onOpen}
|
||||
{disabled}
|
||||
onclick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<ChatFormCurrentWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
{homeBase}
|
||||
onClear={handleDismiss}
|
||||
{showTooltip}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
|
||||
<Popover.Trigger
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="sr-only">Open working directory picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
{customAnchor}
|
||||
preventScroll={false}
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
|
||||
{customAnchor}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onkeydown={handleKeydown}
|
||||
preventScroll={false}
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
>
|
||||
<div class="p-2 min-h-22 flex flex-col justify-between">
|
||||
<SearchInput
|
||||
bind:ref={searchInputRef}
|
||||
bind:value={query}
|
||||
placeholder="Choose working directory"
|
||||
onClose={closePicker}
|
||||
class="w-full"
|
||||
onClose={closePicker}
|
||||
placeholder="Choose working directory"
|
||||
/>
|
||||
|
||||
{#if !fileSearchEnabled}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
|
||||
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormCurrentWorkingDirectoryResultsList
|
||||
results={queryResults}
|
||||
bind:container={listContainer}
|
||||
error={searchError}
|
||||
hoveredIndex={nav.hoveredIndex}
|
||||
isSearching={search.isSearching}
|
||||
error={searchError}
|
||||
rawQuery={query}
|
||||
bind:container={listContainer}
|
||||
onCommit={commit}
|
||||
onHover={(index) => nav.setHover(index)}
|
||||
rawQuery={query}
|
||||
results={queryResults}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pickerSupported && fileSearchEnabled}
|
||||
<button
|
||||
type="button"
|
||||
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={browseNative}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if homeBase && fileSearchEnabled}
|
||||
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
|
||||
<div aria-hidden="true" class="-mx-2 my-2 h-px bg-border/20"></div>
|
||||
|
||||
<span class="px-2 py-1.5 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
+8
-7
@@ -29,8 +29,8 @@
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
|
||||
class:text-foreground={directory}
|
||||
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
|
||||
<Folder class="w-3.5 h-3.5" />
|
||||
@@ -42,6 +42,7 @@
|
||||
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{displayLabelTitle}</p>
|
||||
</Tooltip.Content>
|
||||
@@ -56,14 +57,14 @@
|
||||
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
tooltip="Reset working directory"
|
||||
ariaLabel="Reset working directory"
|
||||
{disabled}
|
||||
onclick={onClear}
|
||||
iconSize="h-3 w-3"
|
||||
stopPropagationOnClick
|
||||
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
{disabled}
|
||||
icon={X}
|
||||
iconSize="h-3 w-3"
|
||||
onclick={onClear}
|
||||
stopPropagationOnClick
|
||||
tooltip="Reset working directory"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+3
-2
@@ -34,8 +34,8 @@
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="max-h-48 overflow-y-auto py-2"
|
||||
transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
|
||||
class="max-h-48 overflow-y-auto py-2"
|
||||
>
|
||||
{#if isSearching && results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
|
||||
@@ -48,14 +48,15 @@
|
||||
<button
|
||||
type="button"
|
||||
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
|
||||
data-highlighted={index === hoveredIndex ? '' : undefined}
|
||||
class={cn(
|
||||
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
|
||||
)}
|
||||
data-highlighted={index === hoveredIndex ? '' : undefined}
|
||||
onclick={() => onCommit?.(path)}
|
||||
onmouseenter={() => onHover?.(index)}
|
||||
>
|
||||
<Folder class="size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left">
|
||||
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
|
||||
{#if seg.match}
|
||||
|
||||
@@ -56,23 +56,23 @@
|
||||
{#if useRichInput}
|
||||
<ChatFormInputRich
|
||||
bind:this={richRef}
|
||||
bind:value
|
||||
class={className}
|
||||
{disabled}
|
||||
{onInput}
|
||||
{onKeydown}
|
||||
{onPaste}
|
||||
{placeholder}
|
||||
bind:value
|
||||
/>
|
||||
{:else}
|
||||
<ChatFormInputBasic
|
||||
bind:this={basicRef}
|
||||
bind:value
|
||||
class={className}
|
||||
{disabled}
|
||||
{onInput}
|
||||
{onKeydown}
|
||||
{onPaste}
|
||||
{placeholder}
|
||||
bind:value
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+2
-2
@@ -69,14 +69,14 @@
|
||||
'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
disabled && 'cursor-not-allowed'
|
||||
]}
|
||||
style="max-height: var(--max-message-height);"
|
||||
{disabled}
|
||||
onkeydown={onKeydown}
|
||||
oninput={(event) => {
|
||||
autoResizeTextarea(event.currentTarget);
|
||||
onInput?.();
|
||||
}}
|
||||
onkeydown={onKeydown}
|
||||
onpaste={onPaste}
|
||||
{placeholder}
|
||||
style="max-height: var(--max-message-height);"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
|
||||
<input
|
||||
bind:this={fileInputElement}
|
||||
type="file"
|
||||
class="hidden {className}"
|
||||
{multiple}
|
||||
onchange={handleFileSelect}
|
||||
class="hidden {className}"
|
||||
type="file"
|
||||
/>
|
||||
|
||||
+9
-9
@@ -808,25 +808,25 @@
|
||||
<div class="flex-1 {className} mb-0.5">
|
||||
<div
|
||||
bind:this={rootElement}
|
||||
contenteditable={!disabled}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled}
|
||||
aria-multiline="true"
|
||||
aria-placeholder={placeholder}
|
||||
data-placeholder={placeholder}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
class={[
|
||||
'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
disabled && 'cursor-not-allowed'
|
||||
]}
|
||||
style="max-height: var(--max-message-height);"
|
||||
oncompositionstart={handleCompositionStart}
|
||||
contenteditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
oncompositionend={handleCompositionEnd}
|
||||
oncompositionstart={handleCompositionStart}
|
||||
oncopy={handleCopy}
|
||||
oncut={handleCut}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onpaste={handlePaste}
|
||||
oncopy={handleCopy}
|
||||
oncut={handleCut}
|
||||
role="textbox"
|
||||
style="max-height: var(--max-message-height);"
|
||||
tabindex={disabled ? -1 : 0}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
<ScrollCarousel gapSize="2" variant={ScrollCarouselVariant.CENTER}>
|
||||
{#each attachments as attachment, i (attachment.id)}
|
||||
<ChatAttachmentsListItemMcpResource
|
||||
class={i === 0 ? 'ml-3' : ''}
|
||||
{attachment}
|
||||
class={i === 0 ? 'ml-3' : ''}
|
||||
onRemove={handleRemove}
|
||||
onclick={() => handleResourceClick(attachment.resource.uri)}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -21,12 +21,12 @@
|
||||
<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';
|
||||
}}
|
||||
src={faviconUrl}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
+6
-4
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" generics="T">
|
||||
<script generics="T" lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
|
||||
@@ -67,11 +67,11 @@
|
||||
{#if showSearchInput}
|
||||
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
|
||||
<SearchInput
|
||||
{autofocus}
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchQuery}
|
||||
bind:ref={inputRef}
|
||||
bind:value={searchQuery}
|
||||
{autofocus}
|
||||
onClose={onSearchClose}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -85,8 +85,10 @@
|
||||
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
|
||||
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
|
||||
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
|
||||
|
||||
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -24,11 +24,11 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
type="button"
|
||||
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
|
||||
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'
|
||||
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
|
||||
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
<!-- 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>
|
||||
|
||||
|
||||
+5
-5
@@ -30,21 +30,21 @@
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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}"
|
||||
preventScroll={false}
|
||||
onkeydown={onKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onkeydown={onKeydown}
|
||||
preventScroll={false}
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
>
|
||||
{@render children()}
|
||||
</Popover.Content>
|
||||
|
||||
+9
-7
@@ -104,34 +104,36 @@
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open command picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
srLabel="Open command picker"
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredCommands}
|
||||
emptyMessage="No matching command"
|
||||
isLoading={false}
|
||||
itemKey={(command) => command.name}
|
||||
items={filteredCommands}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
searchQuery={query ?? ''}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
searchQuery={query ?? ''}
|
||||
emptyMessage="No matching command"
|
||||
itemKey={(command) => command.name}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
>
|
||||
{#snippet item(command, index, isSelected)}
|
||||
{@const Icon = commandIcon[command.action]}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
disabled={command.disabled}
|
||||
{isSelected}
|
||||
onclick={() => handleSelect(command)}
|
||||
onmouseenter={() => {
|
||||
if (!command.disabled) nav.setHover(index);
|
||||
}}
|
||||
>
|
||||
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="font-mono text-sm font-medium">/{command.name}</span>
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
|
||||
{command.description}
|
||||
</span>
|
||||
|
||||
+17
-17
@@ -359,9 +359,9 @@
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open prompt picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
srLabel="Open prompt picker"
|
||||
>
|
||||
{#if selectedPrompt}
|
||||
{@const prompt = selectedPrompt}
|
||||
@@ -370,10 +370,10 @@
|
||||
|
||||
<div class="p-4">
|
||||
<ChatFormPickerItemHeader
|
||||
description={prompt.description}
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={prompt.title || prompt.name}
|
||||
description={prompt.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if prompt.arguments?.length}
|
||||
@@ -385,33 +385,33 @@
|
||||
</ChatFormPickerItemHeader>
|
||||
|
||||
<ChatFormPromptPickerArgumentForm
|
||||
prompt={selectedPrompt}
|
||||
{promptArgs}
|
||||
{suggestions}
|
||||
{loadingSuggestions}
|
||||
{activeAutocomplete}
|
||||
{autocompleteIndex}
|
||||
{promptError}
|
||||
onArgInput={handleArgInput}
|
||||
onArgKeydown={handleArgKeydown}
|
||||
{loadingSuggestions}
|
||||
onArgBlur={handleArgBlur}
|
||||
onArgFocus={handleArgFocus}
|
||||
onArgInput={handleArgInput}
|
||||
onArgKeydown={handleArgKeydown}
|
||||
onCancel={handleCancelArgumentForm}
|
||||
onSelectSuggestion={selectSuggestion}
|
||||
onSubmit={handleArgumentSubmit}
|
||||
onCancel={handleCancelArgumentForm}
|
||||
prompt={selectedPrompt}
|
||||
{promptArgs}
|
||||
{promptError}
|
||||
{suggestions}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatFormPickerList
|
||||
items={filteredPrompts}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
searchPlaceholder="Search prompts..."
|
||||
emptyMessage="No MCP prompts available"
|
||||
{isLoading}
|
||||
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
|
||||
items={filteredPrompts}
|
||||
{scrollTrigger}
|
||||
searchPlaceholder="Search prompts..."
|
||||
{selectedIndex}
|
||||
{showSearchInput}
|
||||
>
|
||||
{#snippet item(prompt, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||
@@ -423,10 +423,10 @@
|
||||
onclick={() => handlePromptClick(prompt)}
|
||||
>
|
||||
<ChatFormPickerItemHeader
|
||||
description={prompt.description}
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={prompt.title || prompt.name}
|
||||
description={prompt.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if prompt.arguments?.length}
|
||||
@@ -440,7 +440,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet skeleton()}
|
||||
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
|
||||
<ChatFormPickerListItemSkeleton showBadge titleWidth="w-32" />
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
{/if}
|
||||
|
||||
+8
-8
@@ -38,20 +38,20 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<form onsubmit={onSubmit} class="space-y-3 pt-4">
|
||||
<form class="space-y-3 pt-4" onsubmit={onSubmit}>
|
||||
{#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)}
|
||||
isAutocompleteActive={activeAutocomplete === arg.name}
|
||||
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
|
||||
onBlur={() => onArgBlur(arg.name)}
|
||||
onFocus={() => onArgFocus(arg.name)}
|
||||
onInput={(value) => onArgInput(arg.name, value)}
|
||||
onKeydown={(e) => onArgKeydown(e, arg.name)}
|
||||
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
|
||||
suggestions={suggestions[arg.name] ?? []}
|
||||
value={promptArgs[arg.name] ?? ''}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 flex justify-end gap-2">
|
||||
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
|
||||
<Button onclick={onCancel} size="sm" type="button" variant="secondary">Cancel</Button>
|
||||
|
||||
<Button size="sm" type="submit">Use Prompt</Button>
|
||||
</div>
|
||||
|
||||
+9
-9
@@ -36,7 +36,7 @@
|
||||
</script>
|
||||
|
||||
<div class="relative grid gap-1">
|
||||
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
|
||||
<Label class="mb-1 text-muted-foreground" for="arg-{argument.name}">
|
||||
<span>
|
||||
{argument.name}
|
||||
|
||||
@@ -51,30 +51,30 @@
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
autocomplete="off"
|
||||
id="arg-{argument.name}"
|
||||
type="text"
|
||||
{value}
|
||||
oninput={(e) => onInput(e.currentTarget.value)}
|
||||
onkeydown={onKeydown}
|
||||
onblur={onBlur}
|
||||
onfocus={onFocus}
|
||||
oninput={(e) => onInput(e.currentTarget.value)}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={argument.description || argument.name}
|
||||
required={argument.required}
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
{value}
|
||||
/>
|
||||
|
||||
{#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={{ duration: 100, y: -5 }}
|
||||
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"
|
||||
>
|
||||
{#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'
|
||||
: ''}"
|
||||
onmousedown={() => onSelectSuggestion(suggestion)}
|
||||
type="button"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
|
||||
+19
-15
@@ -187,10 +187,10 @@
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
open={isOpen}
|
||||
>
|
||||
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
|
||||
from closing the picker when the user clicks inside the textarea.
|
||||
@@ -198,36 +198,36 @@
|
||||
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
|
||||
Positioning comes from `customAnchor` at the form's top edge. -->
|
||||
<Popover.Trigger
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="sr-only">Open file mention picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
{customAnchor}
|
||||
preventScroll={false}
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
class={[
|
||||
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
|
||||
className
|
||||
]}
|
||||
{customAnchor}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onkeydown={handleKeydown}
|
||||
preventScroll={false}
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={displayedItems}
|
||||
{emptyMessage}
|
||||
isLoading={search.isSearching}
|
||||
itemKey={(entry) => entry.type + ':' + entry.path}
|
||||
items={displayedItems}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
searchQuery={query ?? ''}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
searchQuery={query ?? ''}
|
||||
{emptyMessage}
|
||||
itemKey={(entry) => entry.type + ':' + entry.path}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
>
|
||||
{#snippet item(entry, index, isSelected)}
|
||||
<ChatFormPickerListItem
|
||||
@@ -245,6 +245,7 @@
|
||||
: 'text-muted-foreground'
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if showTooltip}
|
||||
@@ -254,6 +255,7 @@
|
||||
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{entry.path}</p>
|
||||
</Tooltip.Content>
|
||||
@@ -261,14 +263,16 @@
|
||||
{:else}
|
||||
<span class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
|
||||
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
|
||||
<HighlightedMatch query={trimmedQuery} text={abbreviateHome(entry.path, home)} />
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
|
||||
+7
-7
@@ -79,30 +79,30 @@
|
||||
|
||||
<ChatFormPickerCommand
|
||||
bind:this={commandPickerRef}
|
||||
isOpen={isCommandPickerOpen ?? false}
|
||||
query={commandQuery ?? ''}
|
||||
{commands}
|
||||
isOpen={isCommandPickerOpen ?? false}
|
||||
onClose={onCommandPickerClose ?? (() => {})}
|
||||
onSelect={onCommandSelect ?? (() => {})}
|
||||
query={commandQuery ?? ''}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpPrompts
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
searchQuery={promptSearchQuery}
|
||||
onClose={onPromptPickerClose}
|
||||
{onPromptLoadStart}
|
||||
{onPromptLoadComplete}
|
||||
{onPromptLoadError}
|
||||
{onPromptLoadStart}
|
||||
searchQuery={promptSearchQuery}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMention
|
||||
bind:this={mentionPickerRef}
|
||||
isOpen={isMentionPickerOpen ?? false}
|
||||
query={mentionQuery ?? ''}
|
||||
customAnchor={mentionAnchor}
|
||||
scopePath={scopePath ?? null}
|
||||
isOpen={isMentionPickerOpen ?? false}
|
||||
onClose={onMentionPickerClose ?? (() => {})}
|
||||
onOpened={onMentionOpened}
|
||||
onSelect={onMentionSelect ?? (() => {})}
|
||||
query={mentionQuery ?? ''}
|
||||
scopePath={scopePath ?? null}
|
||||
/>
|
||||
|
||||
@@ -381,13 +381,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
|
||||
<div class:chat-message--synthetic={isSynthetic} class="chat-message">
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem bind:textareaElement class={className} {message} />
|
||||
{:else if mcpPromptExtra}
|
||||
<ChatMessageMcpPrompt class={className} {message} mcpPrompt={mcpPromptExtra} />
|
||||
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
|
||||
{:else if isSynthetic}
|
||||
<ChatMessageSynthetic {message} class={className} />
|
||||
<ChatMessageSynthetic class={className} {message} />
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser class={className} {isLastUserMessage} {message} {nextAssistantMessage} />
|
||||
{:else}
|
||||
@@ -396,9 +396,9 @@
|
||||
class={className}
|
||||
{isLastAssistantMessage}
|
||||
{message}
|
||||
{toolMessages}
|
||||
onContinue={handleContinue}
|
||||
onRegenerate={handleRegenerate}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+13
-13
@@ -126,16 +126,16 @@
|
||||
|
||||
<div
|
||||
bind:this={assistantEl}
|
||||
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
|
||||
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
|
||||
style:--last-user-message-height={lastUserMessageHeight > 0
|
||||
? `${lastUserMessageHeight}px`
|
||||
: undefined}
|
||||
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
|
||||
role="group"
|
||||
aria-label="Assistant message with actions"
|
||||
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
|
||||
role="group"
|
||||
>
|
||||
{#if showProcessingInfoTop}
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="top" />
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="top" {processingState} />
|
||||
{/if}
|
||||
|
||||
{#if editCtx.isEditing}
|
||||
@@ -145,16 +145,16 @@
|
||||
<ChatMessageAssistantRawOutput {message} {toolMessages} />
|
||||
{:else}
|
||||
<ChatMessageAgenticContent
|
||||
{isLastAssistantMessage}
|
||||
isStreaming={chatStore.isStreaming()}
|
||||
{message}
|
||||
{toolMessages}
|
||||
isStreaming={chatStore.isStreaming()}
|
||||
{isLastAssistantMessage}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoBottom}
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="bottom" {processingState} />
|
||||
{/if}
|
||||
|
||||
{#if displayedModel}
|
||||
@@ -168,8 +168,8 @@
|
||||
/>
|
||||
|
||||
<ChatMessageAssistantStatistics
|
||||
{message}
|
||||
isLoading={chatStore.isLoading}
|
||||
{message}
|
||||
{processingState}
|
||||
showMessageStats={currentConfig.showMessageStats}
|
||||
/>
|
||||
@@ -179,14 +179,14 @@
|
||||
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
<ChatMessageActionIcons
|
||||
role={MessageRole.ASSISTANT}
|
||||
justify="start"
|
||||
actionsPosition="left"
|
||||
{onRegenerate}
|
||||
justify="start"
|
||||
onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
|
||||
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
|
||||
rawOutputEnabled={showRawOutput}
|
||||
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
|
||||
{onRegenerate}
|
||||
rawOutputEnabled={showRawOutput}
|
||||
role={MessageRole.ASSISTANT}
|
||||
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
const marginClass = $derived(position === 'top' ? 'mt-6' : 'mt-4');
|
||||
</script>
|
||||
|
||||
<div class="{marginClass} w-full max-w-3xl" in:fade>
|
||||
<div in:fade class="{marginClass} w-full max-w-3xl">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="shimmer-text text-sm">
|
||||
{modelLoadingText ??
|
||||
|
||||
+13
-13
@@ -24,22 +24,22 @@
|
||||
|
||||
{#if showMessageStats && isLiveFlowRoot && liveLlm}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
isLive
|
||||
promptTokens={liveLlm.prompt_n}
|
||||
promptMs={liveLlm.prompt_ms}
|
||||
predictedTokens={liveLlm.predicted_n}
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
predictedMs={liveLlm.predicted_ms}
|
||||
predictedTokens={liveLlm.predicted_n}
|
||||
promptMs={liveLlm.prompt_ms}
|
||||
promptTokens={liveLlm.prompt_n}
|
||||
/>
|
||||
{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const agentic = message.timings.agentic}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
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}
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
|
||||
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
|
||||
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
|
||||
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
|
||||
/>
|
||||
{:else if isLoading && showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
@@ -47,12 +47,12 @@
|
||||
|
||||
{#if genStats}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
isLive
|
||||
promptTokens={liveStats?.tokensProcessed}
|
||||
promptMs={liveStats?.timeMs}
|
||||
predictedTokens={genStats.tokensGenerated}
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
predictedMs={genStats.timeMs}
|
||||
predictedTokens={genStats.tokensGenerated}
|
||||
promptMs={liveStats?.timeMs}
|
||||
promptTokens={liveStats?.tokensProcessed}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
+3
@@ -19,10 +19,13 @@
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
|
||||
{#if info.path === null}
|
||||
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
|
||||
{:else}
|
||||
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
<span class="text-foreground/80 text-sm font-medium">Set working directory to </span>
|
||||
|
||||
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
|
||||
{info.display}
|
||||
</span>
|
||||
|
||||
+1
-1
@@ -29,9 +29,9 @@
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
<ChatMessageMcpPromptContent
|
||||
class="w-full max-w-[80%]"
|
||||
prompt={mcpPrompt}
|
||||
variant={McpPromptVariant.MESSAGE}
|
||||
class="w-full max-w-[80%]"
|
||||
/>
|
||||
|
||||
{#if message.timestamp}
|
||||
|
||||
+1
-1
@@ -99,12 +99,12 @@
|
||||
<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';
|
||||
}}
|
||||
src={serverFavicon}
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
{#if isCwdChange}
|
||||
<ChatMessageCwdChange {message} class={className} />
|
||||
<ChatMessageCwdChange class={className} {message} />
|
||||
{:else}
|
||||
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
|
||||
{/if}
|
||||
|
||||
+4
-4
@@ -83,16 +83,16 @@
|
||||
{#if editCtx.isEditing}
|
||||
<div class="w-full max-w-[80%]">
|
||||
<textarea
|
||||
style="max-height: var(--max-message-height);"
|
||||
bind:this={textareaElement}
|
||||
value={editCtx.editedContent}
|
||||
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
|
||||
onkeydown={handleEditKeydown}
|
||||
oninput={(e) => {
|
||||
autoResizeTextarea(e.currentTarget);
|
||||
editCtx.setContent(e.currentTarget.value);
|
||||
}}
|
||||
onkeydown={handleEditKeydown}
|
||||
placeholder="Edit system message..."
|
||||
style="max-height: var(--max-message-height);"
|
||||
value={editCtx.editedContent}
|
||||
></textarea>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
@@ -104,8 +104,8 @@
|
||||
|
||||
<Button
|
||||
class="h-8 px-3"
|
||||
onclick={editCtx.save}
|
||||
disabled={!editCtx.editedContent.trim()}
|
||||
onclick={editCtx.save}
|
||||
size="sm"
|
||||
>
|
||||
<Check class="mr-1 h-3 w-3" />
|
||||
|
||||
+15
-15
@@ -34,34 +34,34 @@
|
||||
</script>
|
||||
|
||||
{#if isSearchCall}
|
||||
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockSearchResults {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.BROWSER_GET_DATETIME}
|
||||
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
|
||||
<ChatMessageToolCallBlockGetDatetime {isStreaming} {section} />
|
||||
{:else if section.toolName === BuiltInTool.SERVER_GET_INFO}
|
||||
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
|
||||
<ChatMessageToolCallBlockGetInfo {isStreaming} {section} />
|
||||
{:else if section.toolName === BuiltInTool.SERVER_READ_FILE}
|
||||
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockReadFile {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.BROWSER_READ_MEDIA}
|
||||
<ChatMessageToolCallBlockReadMedia {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockReadMedia {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.SERVER_EDIT_FILE}
|
||||
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockEditFile {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.SERVER_WRITE_FILE}
|
||||
<ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockWriteFile {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND}
|
||||
<ChatMessageToolCallBlockExecShellCommand
|
||||
{section}
|
||||
{open}
|
||||
{isStreaming}
|
||||
{isExecuting}
|
||||
{attachments}
|
||||
{isExecuting}
|
||||
{isStreaming}
|
||||
{onToggle}
|
||||
{open}
|
||||
{section}
|
||||
/>
|
||||
{:else if section.toolName === BuiltInTool.SERVER_FILE_GLOB_SEARCH}
|
||||
<ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockFileGlobSearch {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.SERVER_GREP_SEARCH}
|
||||
<ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockGrepSearch {isStreaming} {onToggle} {open} {section} />
|
||||
{:else if section.toolName === BuiltInTool.BROWSER_RUN_JAVASCRIPT}
|
||||
<ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
|
||||
<ChatMessageToolCallBlockRunJavascript {isStreaming} {onToggle} {open} {section} />
|
||||
{:else}
|
||||
<ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
|
||||
<ChatMessageToolCallBlockDefault {attachments} {isStreaming} {onToggle} {open} {section} />
|
||||
{/if}
|
||||
|
||||
+11
-4
@@ -34,15 +34,17 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={null} {onToggle} {open} {section} {title}>
|
||||
{#snippet children(_meta, ctx)}
|
||||
{#if ctx.isStreamingCall}
|
||||
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<span>Input</span>
|
||||
|
||||
{#if ctx.isStreaming}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if section.toolArgs}
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs)}
|
||||
@@ -67,6 +69,7 @@
|
||||
<div class="mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<span>Input</span>
|
||||
</div>
|
||||
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs ?? '')}
|
||||
language={FileTypeText.JSON}
|
||||
@@ -74,16 +77,19 @@
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={showInput
|
||||
? 'mt-4 mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'
|
||||
: 'mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'}
|
||||
>
|
||||
<span>Output</span>
|
||||
|
||||
{#if ctx.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx.isPending}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||
Waiting for result...
|
||||
@@ -96,18 +102,19 @@
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
/>
|
||||
{:else if outputKind === ToolResultKind.MARKDOWN}
|
||||
<MarkdownContent content={section.toolResult} {attachments} />
|
||||
<MarkdownContent {attachments} content={section.toolResult} />
|
||||
{:else}
|
||||
<div class="overflow-auto">
|
||||
{#each parsedLines as line, i (i)}
|
||||
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
|
||||
{line.text}
|
||||
</div>
|
||||
|
||||
{#if line.media}
|
||||
{#if line.media.type === AttachmentType.AUDIO}
|
||||
{@const audioMimeType = line.media.mimeType ?? MimeTypeAudio.MP3_MPEG}
|
||||
<div class="mt-2 mb-2">
|
||||
<audio controls class="w-full rounded-lg">
|
||||
<audio class="w-full rounded-lg" controls>
|
||||
<source
|
||||
src={createBase64DataUrl(audioMimeType, line.media.base64Data)}
|
||||
type={audioMimeType}
|
||||
@@ -117,10 +124,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
src={line.media.base64Url}
|
||||
alt={line.media.name}
|
||||
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||
loading="lazy"
|
||||
src={line.media.base64Url}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
+11
-2
@@ -23,12 +23,14 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={editFileMeta} {onToggle} {open} {section}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Edit file </span>
|
||||
|
||||
<span class="font-mono" title={editFileMeta?.filePath}
|
||||
>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
|
||||
>
|
||||
|
||||
{#if editFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
@@ -40,6 +42,7 @@
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.edits.length > 0}
|
||||
@@ -48,13 +51,17 @@
|
||||
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Edit {ei + 1} of {meta.edits.length}
|
||||
</div>
|
||||
<div class="diff-block" style:max-height={MAX_HEIGHT_CODE_BLOCK}>
|
||||
|
||||
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-block">
|
||||
<div class="diff-pre">
|
||||
{#each diffLines as line, li (li)}
|
||||
<div class="diff-line diff-{line.kind}">
|
||||
<span class="diff-old-num">{line.oldLine ?? ''}</span>
|
||||
|
||||
<span class="diff-marker">{prefixFor(line.kind)}</span>
|
||||
|
||||
<span class="diff-new-num">{line.newLine ?? ''}</span>
|
||||
|
||||
<span class="diff-text">{line.text || ' '}</span>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -62,9 +69,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
{#if meta.resultMessage}
|
||||
{meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
|
||||
|
||||
{#if meta.editsApplied != null}
|
||||
<span class="font-mono">{meta.editsApplied}</span>
|
||||
{meta.editsApplied === 1 ? 'edit' : 'edits'} applied
|
||||
|
||||
+15
-7
@@ -176,6 +176,7 @@
|
||||
{#snippet execShellTitle()}
|
||||
{#if cwd}
|
||||
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
|
||||
|
||||
<span class="exec-prompt">$</span>
|
||||
{/if}
|
||||
|
||||
@@ -187,14 +188,14 @@
|
||||
{/snippet}
|
||||
|
||||
<ToolCallBlock
|
||||
{section}
|
||||
{open}
|
||||
extraLiveStreaming={isLive}
|
||||
{isStreaming}
|
||||
meta={execShellMeta ? { errorMessage: execShellError } : null}
|
||||
wrapper={CollapsibleTerminalBlock}
|
||||
extraLiveStreaming={isLive}
|
||||
spinIconWhenActive={true}
|
||||
{onToggle}
|
||||
{open}
|
||||
{section}
|
||||
spinIconWhenActive={true}
|
||||
wrapper={CollapsibleTerminalBlock}
|
||||
>
|
||||
{#snippet titleSnippet()}
|
||||
{@render execShellTitle()}
|
||||
@@ -209,23 +210,25 @@
|
||||
{:else if execShellError}
|
||||
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span>{execShellError}</span>
|
||||
</div>
|
||||
{:else if section.toolResult}
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="terminal-output"
|
||||
class:is-clamped={!useFullHeightCodeBlocks}
|
||||
class="terminal-output"
|
||||
onscroll={handleScrollEvent}
|
||||
>
|
||||
{#each outputLines as line, i (i)}
|
||||
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
|
||||
|
||||
{#if line.media?.type === AttachmentType.IMAGE}
|
||||
<img
|
||||
src={line.media.base64Url}
|
||||
alt={line.media.name}
|
||||
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||
loading="lazy"
|
||||
src={line.media.base64Url}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -234,14 +237,19 @@
|
||||
<div class={exitBadgeClass}>
|
||||
{#if execShellExitStatus.timedOut}
|
||||
<AlertTriangle class="h-3 w-3" />
|
||||
|
||||
<span>timed out</span>
|
||||
|
||||
<span class="exit-sep">·</span>
|
||||
|
||||
<span>exit {execShellExitStatus.code}</span>
|
||||
{:else if execShellExitStatus.code === 0}
|
||||
<Check class="h-3 w-3" />
|
||||
|
||||
<span>exit 0</span>
|
||||
{:else}
|
||||
<XCircle class="h-3 w-3" />
|
||||
|
||||
<span>exit {execShellExitStatus.code}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -19,16 +19,19 @@
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={fileGlobMeta} {onToggle} {open} {section}>
|
||||
{#snippet titleSnippet()}
|
||||
{#if fileGlobMeta}
|
||||
<span class="text-muted-foreground"
|
||||
>{fileGlobMeta.include === '**' ? 'List files' : 'Search files'} </span
|
||||
>
|
||||
|
||||
{#if fileGlobMeta.include !== '**'}
|
||||
<span class="font-mono">{fileGlobMeta.include}</span>
|
||||
{/if}
|
||||
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
|
||||
<span class="font-mono" title={fileGlobMeta.path}
|
||||
>{abbreviateHome(fileGlobMeta.path, home)}</span
|
||||
>
|
||||
@@ -45,6 +48,7 @@
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.matches.length > 0}
|
||||
@@ -53,11 +57,13 @@
|
||||
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{match}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
|
||||
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
|
||||
</div>
|
||||
|
||||
+4
@@ -44,15 +44,19 @@
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
|
||||
<Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if dateMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time </span>
|
||||
|
||||
<span class="text-red-600 text-xs italic dark:text-red-400">- {dateMeta.errorMessage}</span
|
||||
>
|
||||
{:else if dateMeta.dateString}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time is </span>
|
||||
|
||||
<span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
|
||||
{:else}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||
|
||||
+5
@@ -52,18 +52,23 @@
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
|
||||
<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
|
||||
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if infoMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
|
||||
<span class="text-red-600 text-xs italic dark:text-red-400">- {infoMeta.errorMessage}</span
|
||||
>
|
||||
{:else if infoMeta.os || infoMeta.cwd}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
|
||||
{#if infoMeta.os}
|
||||
<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
|
||||
{/if}
|
||||
|
||||
{#if infoMeta.cwd}
|
||||
<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
|
||||
{/if}
|
||||
|
||||
+11
-1
@@ -19,12 +19,15 @@
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={grepMeta} {onToggle} {open} {section}>
|
||||
{#snippet titleSnippet()}
|
||||
{#if grepMeta}
|
||||
<span class="text-muted-foreground">Search for </span>
|
||||
|
||||
<span class="font-mono">{grepMeta.pattern}</span>
|
||||
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
|
||||
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -39,6 +42,7 @@
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.matches.length > 0}
|
||||
@@ -46,22 +50,28 @@
|
||||
{#each meta.matches as match, mi (mi)}
|
||||
<div class="font-mono text-[11px] leading-relaxed">
|
||||
<span class="text-muted-foreground/70">{match.file}</span>
|
||||
|
||||
{#if meta.showLineNumbers && match.line != null}
|
||||
<span class="text-muted-foreground/70">:{match.line}</span>
|
||||
{/if}
|
||||
|
||||
<span class="text-muted-foreground/70">:</span>
|
||||
|
||||
<span>{match.content}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
|
||||
|
||||
{#if meta.showLineNumbers}
|
||||
<span class="italic">(with line numbers)</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
|
||||
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -17,10 +17,12 @@
|
||||
const readFileMeta = $derived(parseReadFileMeta(section));
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={readFileMeta} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={readFileMeta} {onToggle} {open} {section}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Read file </span>
|
||||
|
||||
<span class="font-mono">{readFileMeta?.fileName}</span>
|
||||
|
||||
{#if readFileMeta?.lineRange}
|
||||
<span class="text-muted-foreground"
|
||||
> (lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
|
||||
|
||||
+5
-3
@@ -43,9 +43,10 @@
|
||||
const audioMimeType = $derived(readMediaMeta?.mimeType ?? MimeTypeAudio.MP3_MPEG);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={readMediaMeta} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={readMediaMeta} {onToggle} {open} {section}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Read media </span>
|
||||
|
||||
<span class="font-mono">{readMediaMeta?.fileName}</span>
|
||||
{/snippet}
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
</div>
|
||||
{:else if mediaAttachment.type === AttachmentType.AUDIO}
|
||||
<div class="mt-2">
|
||||
<audio controls class="w-full rounded-lg">
|
||||
<audio class="w-full rounded-lg" controls>
|
||||
<source
|
||||
src={createBase64DataUrl(audioMimeType, mediaAttachment.base64Data)}
|
||||
type={audioMimeType}
|
||||
@@ -68,10 +69,10 @@
|
||||
{:else}
|
||||
<div class="mt-2">
|
||||
<img
|
||||
src={mediaAttachment.base64Url}
|
||||
alt={readMediaMeta?.fileName ?? 'media'}
|
||||
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
|
||||
loading="lazy"
|
||||
src={mediaAttachment.base64Url}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -81,6 +82,7 @@
|
||||
{#if readMediaMeta?.sizeBytes}
|
||||
<span>Size: {readMediaMeta.sizeBytes} bytes</span>
|
||||
{/if}
|
||||
|
||||
{#if readMediaMeta?.mimeType}
|
||||
<span>MIME: {readMediaMeta.mimeType}</span>
|
||||
{/if}
|
||||
|
||||
+7
-1
@@ -21,7 +21,7 @@
|
||||
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={runJsMeta} {onToggle} {open} {section} {title}>
|
||||
{#snippet children(meta, ctx)}
|
||||
{#if ctx.isPending}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
|
||||
@@ -30,8 +30,10 @@
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<SyntaxHighlightedCode
|
||||
code={meta.code}
|
||||
@@ -47,13 +49,17 @@
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
|
||||
<div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<Terminal class="h-3 w-3" />
|
||||
|
||||
<span>Console</span>
|
||||
|
||||
{#if meta.timeoutMs != null}
|
||||
<span class="font-mono">· timeout {meta.timeoutMs} ms</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if section.toolResult}
|
||||
<div class="mt-1">
|
||||
<SyntaxHighlightedCode
|
||||
|
||||
+19
-12
@@ -86,55 +86,60 @@
|
||||
{@const safeUrl = sanitizeExternalUrl(result.url)}
|
||||
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
|
||||
{#if safeUrl}
|
||||
<HoverCard.Root openDelay={150} closeDelay={100}>
|
||||
<HoverCard.Root closeDelay={100} openDelay={150}>
|
||||
<HoverCard.Trigger
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
|
||||
href={safeUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={hideBrokenIcon}
|
||||
src={faviconUrl}
|
||||
/>
|
||||
{:else}
|
||||
<Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
|
||||
{/if}
|
||||
|
||||
<span class="truncate font-medium text-foreground/80">{result.title}</span>
|
||||
</HoverCard.Trigger>
|
||||
|
||||
{#if showHoverCard}
|
||||
{@const publishDate = formatPublishDate(result.published)}
|
||||
{@const host = hostFor(safeUrl)}
|
||||
<HoverCard.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-3">
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
|
||||
>{result.title}</a
|
||||
href={safeUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank">{result.title}</a
|
||||
>
|
||||
|
||||
{#if publishDate || result.author}
|
||||
<div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
|
||||
{#if publishDate}
|
||||
<span>{publishDate}</span>
|
||||
{/if}
|
||||
|
||||
{#if publishDate && result.author}
|
||||
<span class="opacity-50">·</span>
|
||||
{/if}
|
||||
|
||||
{#if result.author}
|
||||
<span class="truncate">{result.author}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if result.highlights}
|
||||
<p
|
||||
class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
|
||||
@@ -142,6 +147,7 @@
|
||||
{result.highlights}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if host}
|
||||
<div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
|
||||
{/if}
|
||||
@@ -152,7 +158,7 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
|
||||
<CollapsibleContentBlock class="my-2" {icon} {iconClass} {iconUrl} {onToggle} {open} {title}>
|
||||
{#if results.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-2 pb-1">
|
||||
{#each results as result (result.url)}
|
||||
@@ -162,6 +168,7 @@
|
||||
{:else if showSpinner}
|
||||
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
+6
-1
@@ -21,12 +21,14 @@
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
|
||||
<ToolCallBlock {isStreaming} meta={writeFileMeta} {onToggle} {open} {section}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Write file </span>
|
||||
|
||||
<span class="font-mono" title={writeFileMeta?.filePath}
|
||||
>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
|
||||
>
|
||||
|
||||
{#if writeFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
@@ -38,6 +40,7 @@
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta}
|
||||
@@ -47,9 +50,11 @@
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
{#if meta.resultMessage}
|
||||
{meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
|
||||
|
||||
{#if meta.bytesWritten != null}
|
||||
<span class="font-mono">{meta.bytesWritten}</span>
|
||||
bytes
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" generics="TMeta">
|
||||
<script generics="TMeta" lang="ts">
|
||||
// Generic chrome shell shared by every per-tool block under
|
||||
// `ChatMessageToolCall/`. Owns:
|
||||
// - the collapsible wrapper (defaults to CollapsibleContentBlock;
|
||||
@@ -114,15 +114,15 @@
|
||||
</script>
|
||||
|
||||
<Wrapper
|
||||
{open}
|
||||
class="my-2"
|
||||
icon={toolIcon}
|
||||
iconClass={toolIconClass}
|
||||
{iconUrl}
|
||||
{onToggle}
|
||||
{open}
|
||||
{subtitle}
|
||||
{title}
|
||||
{titleSnippet}
|
||||
{subtitle}
|
||||
{onToggle}
|
||||
>
|
||||
{@render children(meta, {
|
||||
isCodeStreaming,
|
||||
|
||||
+4
-4
@@ -69,8 +69,8 @@
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
<ChatMessageUserBubble
|
||||
content={message.content}
|
||||
attachments={message.extra}
|
||||
content={message.content}
|
||||
renderMarkdown={true}
|
||||
/>
|
||||
|
||||
@@ -82,8 +82,8 @@
|
||||
>
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.READING}
|
||||
promptTokens={storedReadingStats!.promptTokens}
|
||||
promptMs={storedReadingStats!.promptMs}
|
||||
promptTokens={storedReadingStats!.promptTokens}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,10 +95,10 @@
|
||||
class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.READING}
|
||||
isLive
|
||||
promptTokens={liveStats.tokensProcessed}
|
||||
mode={ChatMessageStatisticsMode.READING}
|
||||
promptMs={liveStats.timeMs}
|
||||
promptTokens={liveStats.tokensProcessed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@
|
||||
|
||||
{#if attachments && attachments.length > 0}
|
||||
<div class="mb-2 max-w-[80%]">
|
||||
<ChatAttachmentsList {attachments} readonly imageHeight="h-40" />
|
||||
<ChatAttachmentsList {attachments} imageHeight="h-40" readonly />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
+7
-5
@@ -37,11 +37,11 @@
|
||||
<ChatMessageEditForm />
|
||||
{:else}
|
||||
<ChatMessageUserBubble
|
||||
{content}
|
||||
attachments={extras}
|
||||
textColorClass="text-muted-foreground"
|
||||
cardBgClass="dark:bg-primary/8"
|
||||
{content}
|
||||
maxHeightStyle="overflow-wrap: anywhere; word-break: break-word;"
|
||||
textColorClass="text-muted-foreground"
|
||||
/>
|
||||
|
||||
<div class="max-w-[80%]">
|
||||
@@ -50,9 +50,11 @@
|
||||
<div
|
||||
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-0 transition-all duration-150 group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.handleEdit} />
|
||||
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
||||
<ActionIcon icon={ArrowUp} tooltip="Send immediately" onclick={onSendImmediately} />
|
||||
<ActionIcon icon={Edit} onclick={editCtx.handleEdit} tooltip="Edit" />
|
||||
|
||||
<ActionIcon icon={Trash2} onclick={onDelete} tooltip="Delete" />
|
||||
|
||||
<ActionIcon icon={ArrowUp} onclick={onSendImmediately} tooltip="Send immediately" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
@@ -14,10 +14,12 @@
|
||||
<div class="my-2 rounded-lg border border-border bg-card p-3">
|
||||
<div class="mb-3 flex items-center gap-2 text-sm">
|
||||
<IconComponent class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
|
||||
<span>
|
||||
{@render message()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{@render actions()}
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -16,13 +16,13 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
<Button size="sm" onclick={() => onDecision(true)}>Continue</Button>
|
||||
<Button onclick={() => onDecision(true)} size="sm">Continue</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
onclick={() => onDecision(false)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
+5
-4
@@ -28,10 +28,10 @@
|
||||
<DropdownMenu.Root>
|
||||
<ButtonGroup.Root class="overflow-hidden rounded-md shadow-sm">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="!rounded-r-none !shadow-none"
|
||||
onclick={() => onDecision(ToolPermissionDecision.ONCE)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
Allow once
|
||||
</Button>
|
||||
@@ -39,11 +39,11 @@
|
||||
<ButtonGroup.Separator />
|
||||
|
||||
<DropdownMenu.Trigger
|
||||
aria-label="More allow options"
|
||||
class={cn(
|
||||
buttonVariants({ size: 'sm', variant: 'secondary' }),
|
||||
'inline-flex cursor-pointer items-center !rounded-l-none !shadow-none !px-2'
|
||||
)}
|
||||
aria-label="More allow options"
|
||||
>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -54,6 +54,7 @@
|
||||
Always allow <pre>{toolName}</pre>
|
||||
tool
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{#if serverLabel}
|
||||
<DropdownMenu.Item onclick={() => onDecision(ToolPermissionDecision.ALWAYS_SERVER)}>
|
||||
Always allow all tools from {serverLabel}
|
||||
@@ -73,7 +74,7 @@
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<Button variant="destructive" size="sm" onclick={() => onDecision(ToolPermissionDecision.DENY)}>
|
||||
<Button onclick={() => onDecision(ToolPermissionDecision.DENY)} size="sm" variant="destructive">
|
||||
Deny
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
+23
-22
@@ -77,29 +77,30 @@
|
||||
<div
|
||||
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
|
||||
>
|
||||
<ActionIcon icon={Copy} tooltip="Copy" onclick={messageActions.copy} />
|
||||
<ActionIcon icon={Copy} onclick={messageActions.copy} tooltip="Copy" />
|
||||
|
||||
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.startEdit} />
|
||||
<ActionIcon icon={Edit} onclick={editCtx.startEdit} tooltip="Edit" />
|
||||
|
||||
{#if role === MessageRole.ASSISTANT && onRegenerate}
|
||||
<ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
|
||||
<ActionIcon icon={RefreshCw} onclick={() => onRegenerate()} tooltip="Regenerate" />
|
||||
{/if}
|
||||
|
||||
{#if role === MessageRole.ASSISTANT && onContinue}
|
||||
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
||||
<ActionIcon icon={ArrowRight} onclick={onContinue} tooltip="Continue" />
|
||||
{/if}
|
||||
|
||||
{#if messageActions.forkConversation}
|
||||
<ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} />
|
||||
<ActionIcon icon={GitBranch} onclick={handleOpenForkDialog} tooltip="Fork conversation" />
|
||||
{/if}
|
||||
|
||||
<ActionIcon icon={Trash2} tooltip="Delete" onclick={messageActions.requestDelete} />
|
||||
<ActionIcon icon={Trash2} onclick={messageActions.requestDelete} tooltip="Delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showRawOutputSwitch}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">Show raw output</span>
|
||||
|
||||
<Switch
|
||||
checked={rawOutputEnabled}
|
||||
onCheckedChange={(checked) => onRawOutputToggle?.(checked)}
|
||||
@@ -109,54 +110,54 @@
|
||||
</div>
|
||||
|
||||
<DialogConfirmation
|
||||
open={messageActions.showDeleteDialog}
|
||||
title="Delete Message"
|
||||
description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
|
||||
? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
|
||||
: 'Are you sure you want to delete this message? This action cannot be undone.'}
|
||||
cancelText="Cancel"
|
||||
confirmText={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
|
||||
? `Delete ${messageActions.deletionInfo.totalCount} Messages`
|
||||
: 'Delete'}
|
||||
cancelText="Cancel"
|
||||
variant="destructive"
|
||||
description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
|
||||
? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
|
||||
: 'Are you sure you want to delete this message? This action cannot be undone.'}
|
||||
icon={Trash2}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => messageActions.setShowDeleteDialog(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
open={messageActions.showDeleteDialog}
|
||||
title="Delete Message"
|
||||
variant="destructive"
|
||||
/>
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showForkDialog}
|
||||
title="Fork Conversation"
|
||||
description="Create a new conversation branching from this message."
|
||||
confirmText="Fork"
|
||||
cancelText="Cancel"
|
||||
confirmText="Fork"
|
||||
description="Create a new conversation branching from this message."
|
||||
icon={GitBranch}
|
||||
onConfirm={handleConfirmFork}
|
||||
onCancel={() => (showForkDialog = false)}
|
||||
onConfirm={handleConfirmFork}
|
||||
title="Fork Conversation"
|
||||
>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="fork-name">Title</Label>
|
||||
|
||||
<Input
|
||||
id="fork-name"
|
||||
bind:value={forkName}
|
||||
class="text-foreground"
|
||||
id="fork-name"
|
||||
placeholder="Enter fork name"
|
||||
type="text"
|
||||
bind:value={forkName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="fork-attachments"
|
||||
checked={forkIncludeAttachments}
|
||||
id="fork-attachments"
|
||||
onCheckedChange={(checked) => {
|
||||
forkIncludeAttachments = checked === true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label for="fork-attachments" class="cursor-pointer text-sm font-normal">
|
||||
<Label class="cursor-pointer text-sm font-normal" for="fork-attachments">
|
||||
Include all attachments
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
+6
-6
@@ -30,11 +30,11 @@
|
||||
role="navigation"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={ChevronLeft}
|
||||
tooltip="Previous version"
|
||||
disabled={!hasPrevious}
|
||||
class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}"
|
||||
disabled={!hasPrevious}
|
||||
icon={ChevronLeft}
|
||||
onclick={() => messageActions.navigateToSibling(previousSiblingId!)}
|
||||
tooltip="Previous version"
|
||||
/>
|
||||
|
||||
<span class="px-1 font-mono text-xs">
|
||||
@@ -42,11 +42,11 @@
|
||||
</span>
|
||||
|
||||
<ActionIcon
|
||||
icon={ChevronRight}
|
||||
tooltip="Next version"
|
||||
disabled={!hasNext}
|
||||
class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}"
|
||||
disabled={!hasNext}
|
||||
icon={ChevronRight}
|
||||
onclick={() => messageActions.navigateToSibling(nextSiblingId!)}
|
||||
tooltip="Next version"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+16
-16
@@ -181,26 +181,26 @@
|
||||
{#snippet renderSection(section: AgenticSection, index: number)}
|
||||
{#if section.type === AgenticSectionType.TEXT}
|
||||
<div class="agentic-text">
|
||||
<MarkdownContent content={section.content} attachments={message?.extra} />
|
||||
<MarkdownContent attachments={message?.extra} content={section.content} />
|
||||
</div>
|
||||
{:else if section.type === AgenticSectionType.REASONING || section.type === AgenticSectionType.REASONING_PENDING}
|
||||
<ChatMessageReasoningBlock
|
||||
{section}
|
||||
open={isExpanded(index, section)}
|
||||
{isStreaming}
|
||||
{hasReasoningError}
|
||||
attachments={message?.extra}
|
||||
{hasReasoningError}
|
||||
{isStreaming}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
open={isExpanded(index, section)}
|
||||
{section}
|
||||
/>
|
||||
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
||||
<ChatMessageToolCallBlock
|
||||
{section}
|
||||
open={isExpanded(index, section)}
|
||||
{isStreaming}
|
||||
attachments={message?.extra}
|
||||
isExecuting={section.toolCallId !== undefined &&
|
||||
section.toolCallId === currentlyExecutingToolCallId}
|
||||
attachments={message?.extra}
|
||||
{isStreaming}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
open={isExpanded(index, section)}
|
||||
{section}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -218,15 +218,15 @@
|
||||
{#if turnStats && showAgenticTurnStats}
|
||||
<div class="turn-stats transition-opacity duration-150 mt-1 mb-4">
|
||||
<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
|
||||
initialView={ChatMessageStatsView.GENERATION}
|
||||
predictedMs={turnStats.llm.predicted_ms}
|
||||
predictedTokens={turnStats.llm.predicted_n}
|
||||
promptMs={turnStats.llm.prompt_ms}
|
||||
promptTokens={turnStats.llm.prompt_n}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -240,9 +240,9 @@
|
||||
|
||||
{#if pendingPermission && !permissionDismissed}
|
||||
<ChatMessageActionCardPermissionRequest
|
||||
toolName={pendingPermission.toolName}
|
||||
serverLabel={pendingPermission.serverLabel}
|
||||
onDecision={handlePermission}
|
||||
serverLabel={pendingPermission.serverLabel}
|
||||
toolName={pendingPermission.toolName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -102,35 +102,35 @@
|
||||
|
||||
<div class="relative w-full max-w-[80%]">
|
||||
<ChatForm
|
||||
value={editCtx.editedContent}
|
||||
attachments={editCtx.editedExtras}
|
||||
bind:uploadedFiles={editCtx.editedUploadedFiles}
|
||||
placeholder="Edit your message..."
|
||||
showMcpPromptButton
|
||||
showAddButton={editCtx.messageRole === MessageRole.USER}
|
||||
showModelSelector={editCtx.messageRole === MessageRole.USER}
|
||||
onValueChange={editCtx.setContent}
|
||||
attachments={editCtx.editedExtras}
|
||||
onAttachmentRemove={handleAttachmentRemove}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onValueChange={editCtx.setContent}
|
||||
placeholder="Edit your message..."
|
||||
showAddButton={editCtx.messageRole === MessageRole.USER}
|
||||
showMcpPromptButton
|
||||
showModelSelector={editCtx.messageRole === MessageRole.USER}
|
||||
value={editCtx.editedContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
|
||||
{#if isUserMessage && editCtx.showSaveOnlyOption}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
|
||||
<Switch bind:checked={saveWithoutRegenerate} class="scale-75" id="save-only-switch" />
|
||||
|
||||
<label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
|
||||
<label class="cursor-pointer text-xs text-muted-foreground" for="save-only-switch">
|
||||
Update without re-sending
|
||||
</label>
|
||||
</div>
|
||||
{:else if isAssistantMessage}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="branch-after-edit" bind:checked={branchAfterEdit} class="scale-75" />
|
||||
<Switch bind:checked={branchAfterEdit} class="scale-75" id="branch-after-edit" />
|
||||
|
||||
<label for="branch-after-edit" class="cursor-pointer text-xs text-muted-foreground">
|
||||
<label class="cursor-pointer text-xs text-muted-foreground" for="branch-after-edit">
|
||||
Branch conversation after edit
|
||||
</label>
|
||||
</div>
|
||||
@@ -147,12 +147,12 @@
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDiscardDialog}
|
||||
title="Discard changes?"
|
||||
description="You have unsaved changes. Are you sure you want to discard them?"
|
||||
confirmText="Discard"
|
||||
cancelText="Keep editing"
|
||||
variant="destructive"
|
||||
confirmText="Discard"
|
||||
description="You have unsaved changes. Are you sure you want to discard them?"
|
||||
icon={AlertTriangle}
|
||||
onConfirm={editCtx.cancel}
|
||||
onCancel={() => (showDiscardDialog = false)}
|
||||
onConfirm={editCtx.cancel}
|
||||
title="Discard changes?"
|
||||
variant="destructive"
|
||||
/>
|
||||
|
||||
@@ -124,23 +124,23 @@
|
||||
</script>
|
||||
|
||||
<CollapsibleContentBlock
|
||||
{open}
|
||||
class="my-2"
|
||||
icon={Lightbulb}
|
||||
iconClass="h-3.5 w-3.5"
|
||||
{title}
|
||||
{subtitle}
|
||||
{shimmerTitle}
|
||||
{onToggle}
|
||||
{open}
|
||||
{shimmerTitle}
|
||||
{subtitle}
|
||||
{title}
|
||||
>
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="reasoning-content"
|
||||
class:is-streaming={isPending}
|
||||
class="reasoning-content"
|
||||
onscroll={handleScrollEvent}
|
||||
>
|
||||
{#if currentConfig.renderThinkingAsMarkdown}
|
||||
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
||||
<MarkdownContent {attachments} class="text-muted-foreground" content={section.content} />
|
||||
{:else}
|
||||
<div
|
||||
class="text-[13px] leading-relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
|
||||
+14
-14
@@ -140,15 +140,15 @@
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
|
||||
opts.view
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: opts.disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: 'hover:text-foreground'}"
|
||||
onclick={() => !opts.disabled && (activeView = opts.view)}
|
||||
disabled={opts.disabled}
|
||||
onclick={() => !opts.disabled && (activeView = opts.view)}
|
||||
type="button"
|
||||
>
|
||||
<IconComponent class="h-3 w-3" />
|
||||
|
||||
@@ -208,85 +208,85 @@
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{predictedTokens?.toLocaleString()} tokens"
|
||||
tooltipLabel="Generated tokens"
|
||||
value="{predictedTokens?.toLocaleString()} tokens"
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedTime}
|
||||
tooltipLabel="Generation time"
|
||||
value={formattedTime}
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||
tooltipLabel="Generation speed"
|
||||
value="{tokensPerSecond.toFixed(2)} t/s"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Wrench}
|
||||
value="{agenticTimings!.toolCallsCount} calls"
|
||||
tooltipLabel="Tool calls executed"
|
||||
value="{agenticTimings!.toolCallsCount} calls"
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedAgenticToolsTime}
|
||||
tooltipLabel="Tool execution time"
|
||||
value={formattedAgenticToolsTime}
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
|
||||
tooltipLabel="Tool execution rate"
|
||||
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
|
||||
/>
|
||||
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Layers}
|
||||
value="{agenticTimings!.turns} turns"
|
||||
tooltipLabel="Agentic turns (LLM calls)"
|
||||
value="{agenticTimings!.turns} turns"
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
|
||||
tooltipLabel="Total tokens generated"
|
||||
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedAgenticTotalTime}
|
||||
tooltipLabel="Total time (LLM + tools)"
|
||||
value={formattedAgenticTotalTime}
|
||||
/>
|
||||
{:else if hasPromptStats && (mode === ChatMessageStatisticsMode.READING || isSwitchable)}
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={WholeWord}
|
||||
value="{promptTokens} tokens"
|
||||
tooltipLabel="Prompt tokens"
|
||||
value="{promptTokens} tokens"
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Clock}
|
||||
value={formattedPromptTime ?? '0s'}
|
||||
tooltipLabel="Prompt processing time"
|
||||
value={formattedPromptTime ?? '0s'}
|
||||
/>
|
||||
|
||||
<ChatMessageStatisticsBadge
|
||||
class="bg-transparent"
|
||||
icon={Gauge}
|
||||
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
|
||||
tooltipLabel="Prompt processing speed"
|
||||
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+1
@@ -32,6 +32,7 @@
|
||||
</BadgeInfo>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{tooltipLabel}</p>
|
||||
</Tooltip.Content>
|
||||
|
||||
@@ -227,14 +227,14 @@
|
||||
<div>
|
||||
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
|
||||
<ChatMessage
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
{chatActions}
|
||||
{message}
|
||||
{toolMessages}
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -247,10 +247,10 @@
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
onDelete={() => agenticStore.clearSteeringMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
agenticStore.injectSteeringMessage(convId, newContent, extras)}
|
||||
onDelete={() => agenticStore.clearSteeringMessage(convId)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
|
||||
@@ -262,9 +262,9 @@
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={chatStore.getPendingMessageExtras(convId)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
|
||||
onDelete={() => chatStore.clearPendingMessage(convId)}
|
||||
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -294,8 +294,8 @@
|
||||
<ServerLoadingSplash />
|
||||
{:else}
|
||||
<div
|
||||
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
|
||||
style:--chat-form-bottom-position={chatFormBottomPosition}
|
||||
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
|
||||
ondragenter={dragAndDrop.dragHandlers.dragenter}
|
||||
ondragleave={dragAndDrop.dragHandlers.dragleave}
|
||||
ondragover={dragAndDrop.dragHandlers.dragover}
|
||||
@@ -313,6 +313,7 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
|
||||
class={[
|
||||
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
||||
deviceStore.isStandalone
|
||||
@@ -322,7 +323,6 @@
|
||||
: 'bottom-2 right-2 left-2',
|
||||
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
|
||||
]}
|
||||
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
|
||||
>
|
||||
<ChatScreenGreeting {isEmpty} />
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
</div>
|
||||
|
||||
<ChatScreenForm
|
||||
bind:uploadedFiles={fileUpload.uploadedFiles}
|
||||
class="pointer-events-auto conversation-chat-form"
|
||||
disabled={hasPropsError || chatStore.isEditing()}
|
||||
{initialMessage}
|
||||
@@ -356,18 +357,17 @@
|
||||
onSend={handleSendMessage}
|
||||
onStop={() => chatStore.stopGeneration()}
|
||||
onSystemPromptAdd={handleSystemPromptAdd}
|
||||
bind:uploadedFiles={fileUpload.uploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ChatScreenDialogsAndAlerts
|
||||
{showDeleteDialog}
|
||||
{handleDeleteConfirm}
|
||||
{showEmptyFileDialog}
|
||||
{emptyFileNames}
|
||||
{activeErrorDialog}
|
||||
{handleErrorDialogOpenChange}
|
||||
{emptyFileNames}
|
||||
{fileUpload}
|
||||
{handleDeleteConfirm}
|
||||
{handleErrorDialogOpenChange}
|
||||
{showDeleteDialog}
|
||||
{showEmptyFileDialog}
|
||||
/>
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
<div class="pointer-events-auto flex justify-center relative h-0">
|
||||
<ActionIcon
|
||||
icon={ArrowDown}
|
||||
{onclick}
|
||||
ariaLabel="Scroll to bottom"
|
||||
tooltip="Scroll to bottom"
|
||||
size="lg"
|
||||
iconSize={ICON_CLASS_DEFAULT}
|
||||
class="h-9 w-9 rounded-full bg-muted/60 border border-border/20 shadow-sm text-accent-foreground absolute bottom-4"
|
||||
icon={ArrowDown}
|
||||
iconSize={ICON_CLASS_DEFAULT}
|
||||
{onclick}
|
||||
size="lg"
|
||||
tooltip="Scroll to bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDeleteDialog}
|
||||
title="Delete Conversation"
|
||||
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="destructive"
|
||||
confirmText="Delete"
|
||||
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
|
||||
icon={Trash2}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => (showDeleteDialog = false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Delete Conversation"
|
||||
variant="destructive"
|
||||
/>
|
||||
|
||||
<DialogEmptyFileAlert
|
||||
@@ -47,8 +47,8 @@
|
||||
/>
|
||||
|
||||
<DialogChatError
|
||||
message={activeErrorDialog?.message ?? ''}
|
||||
contextInfo={activeErrorDialog?.contextInfo}
|
||||
message={activeErrorDialog?.message ?? ''}
|
||||
onOpenChange={handleErrorDialogOpenChange}
|
||||
open={Boolean(activeErrorDialog)}
|
||||
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user