mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-08 05:58:11 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d483b9b3aa | |||
| fdb0107f11 | |||
| 9bbb9d023e | |||
| 0b8c616dd0 | |||
| 327c7b9c81 | |||
| 3653e6d6d5 | |||
| fc6545d322 | |||
| 1621a3d388 | |||
| 6de1b63473 | |||
| f8e30266d2 | |||
| a194a75b7e | |||
| 23634783c5 | |||
| 4cb22cd537 | |||
| 4cf5cab65d | |||
| 933f46f3cb | |||
| 9ba73fd1f5 | |||
| f4f7758cae | |||
| 34e9ee57f5 | |||
| dff15d4ac9 | |||
| e1470ee6a2 | |||
| 217df17ac3 | |||
| cb26014d96 | |||
| 82bb48500a | |||
| 42e98813e4 | |||
| fc3f10b389 | |||
| 5114c2adbb | |||
| 74b1f5f5aa | |||
| 164f47706a | |||
| 7e565b10f7 | |||
| ea1abe6f43 | |||
| 2cc28a7179 |
@@ -3308,6 +3308,16 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
|
||||
params.server_tools = parse_csv_row(value);
|
||||
}
|
||||
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS"));
|
||||
add_opt(common_arg(
|
||||
{"--tools-runtime"}, "OPTION",
|
||||
"experimental: run tools in a separate runtime environment (default: none, use host environment)\n"
|
||||
"available options:\n"
|
||||
" 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit\n"
|
||||
" 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit\n",
|
||||
[](common_params & params, const std::string & value) {
|
||||
params.server_tools_runtime = value;
|
||||
}
|
||||
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS_RUNTIME"));
|
||||
add_opt(common_arg(
|
||||
{"--mcp-servers-config"}, "PATH",
|
||||
"experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)\n"
|
||||
|
||||
@@ -655,6 +655,7 @@ struct common_params {
|
||||
|
||||
// enable built-in tools
|
||||
std::vector<std::string> server_tools;
|
||||
std::string server_tools_runtime;
|
||||
|
||||
// MCP server configs (Cursor-compatible JSON)
|
||||
std::string mcp_servers_config; // path to JSON file with MCP server definitions
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ project("ggml" C CXX ASM)
|
||||
|
||||
### GGML Version
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 18)
|
||||
set(GGML_VERSION_PATCH 1)
|
||||
set(GGML_VERSION_MINOR 19)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION_BASE "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/")
|
||||
|
||||
@@ -8,6 +8,22 @@
|
||||
#include <sys/sysctl.h>
|
||||
#endif
|
||||
|
||||
#if !defined(HWCAP_FPHP)
|
||||
#define HWCAP_FPHP (1 << 9)
|
||||
#endif
|
||||
|
||||
#if !defined(HWCAP_ASIMDHP)
|
||||
#define HWCAP_ASIMDHP (1 << 10)
|
||||
#endif
|
||||
|
||||
#if !defined(HWCAP_ASIMDDP)
|
||||
#define HWCAP_ASIMDDP (1 << 20)
|
||||
#endif
|
||||
|
||||
#if !defined(HWCAP_SVE)
|
||||
#define HWCAP_SVE (1 << 22)
|
||||
#endif
|
||||
|
||||
#if !defined(HWCAP2_SVE2)
|
||||
#define HWCAP2_SVE2 (1 << 1)
|
||||
#endif
|
||||
@@ -23,7 +39,7 @@
|
||||
struct aarch64_features {
|
||||
// has_neon not needed, aarch64 has NEON guaranteed
|
||||
bool has_dotprod = false;
|
||||
bool has_fp16_va = false;
|
||||
bool has_fp16 = false;
|
||||
bool has_sve = false;
|
||||
bool has_sve2 = false;
|
||||
bool has_i8mm = false;
|
||||
@@ -36,7 +52,7 @@ struct aarch64_features {
|
||||
uint32_t hwcap2 = getauxval(AT_HWCAP2);
|
||||
|
||||
has_dotprod = !!(hwcap & HWCAP_ASIMDDP);
|
||||
has_fp16_va = !!(hwcap & HWCAP_FPHP);
|
||||
has_fp16 = !!(hwcap & HWCAP_FPHP) && !!(hwcap & HWCAP_ASIMDHP);
|
||||
has_sve = !!(hwcap & HWCAP_SVE);
|
||||
has_sve2 = !!(hwcap2 & HWCAP2_SVE2);
|
||||
has_i8mm = !!(hwcap2 & HWCAP2_I8MM);
|
||||
@@ -75,7 +91,7 @@ static int ggml_backend_cpu_aarch64_score() {
|
||||
score += 1<<1;
|
||||
#endif
|
||||
#ifdef GGML_USE_FP16_VECTOR_ARITHMETIC
|
||||
if (!af.has_fp16_va) { return 0; }
|
||||
if (!af.has_fp16) { return 0; }
|
||||
score += 1<<2;
|
||||
#endif
|
||||
#ifdef GGML_USE_SVE
|
||||
|
||||
@@ -3816,7 +3816,7 @@ int ggml_metal_op_norm(ggml_metal_op_t ctx, int idx) {
|
||||
}
|
||||
|
||||
nth = std::min(nth, ggml_metal_pipeline_max_theads_per_threadgroup(pipeline));
|
||||
nth = std::min(nth, args.ne00_t);
|
||||
nth = std::min(nth, (args.ne00_t + 31)/32*32);
|
||||
|
||||
const size_t smem = pipeline.smem;
|
||||
|
||||
|
||||
@@ -1022,9 +1022,20 @@ static T block_reduce(T val, T * shared_vals, int block_size_template) {
|
||||
}
|
||||
|
||||
static __dpct_inline__ float ggml_sycl_ue4m3_to_fp32(uint8_t x) {
|
||||
const uint32_t bits = x * (x != 0x7F && x != 0xFF);
|
||||
const __nv_fp8_e4m3 xf = *reinterpret_cast<const __nv_fp8_e4m3 *>(&bits);
|
||||
return static_cast<float>(xf) / 2;
|
||||
// UE4M3 is unsigned: 4 exp bits (bias 7), 3 mantissa bits, no sign, no NaN.
|
||||
// exp == 0xF is a valid exponent (256-448 range), not NaN.
|
||||
if (x == 0 || x == 0x7F) {
|
||||
return 0.0f;
|
||||
}
|
||||
const int exp = (x >> 3) & 0xF;
|
||||
const int man = x & 0x7;
|
||||
float raw;
|
||||
if (exp == 0) {
|
||||
raw = man * (1.0f / 8.0f) * sycl::pow(2.0f, -6.0f);
|
||||
} else {
|
||||
raw = (1.0f + man / 8.0f) * sycl::pow(2.0f, (float) exp - 7.0f);
|
||||
}
|
||||
return raw * 0.5f;
|
||||
}
|
||||
|
||||
#endif // GGML_SYCL_COMMON_HPP
|
||||
|
||||
@@ -36,9 +36,13 @@ static void kernel_ssm_conv(
|
||||
return;
|
||||
}
|
||||
|
||||
const int channel = static_cast<int>(idx % d_inner);
|
||||
const int token = static_cast<int>((idx / d_inner) % n_t);
|
||||
const int seq = static_cast<int>(idx / (static_cast<size_t>(d_inner) * static_cast<size_t>(n_t)));
|
||||
// src has the tokens of one channel contiguous, dst has the channels of one
|
||||
// token contiguous, so either the loads or the store must be strided. Indexing
|
||||
// token-fastest coalesces the d_conv loads, which measured faster except for
|
||||
// short, cache-resident rows.
|
||||
const int token = static_cast<int>(idx % n_t);
|
||||
const int channel = static_cast<int>((idx / n_t) % d_inner);
|
||||
const int seq = static_cast<int>(idx / (static_cast<size_t>(n_t) * static_cast<size_t>(d_inner)));
|
||||
|
||||
const float *s = src_data
|
||||
+ static_cast<size_t>(seq) * static_cast<size_t>(src_stride_seq)
|
||||
|
||||
@@ -1 +1 @@
|
||||
90951f99af1fbebef3fbdd58ff5b8715b0bb9c43
|
||||
30bf8685ed4eb0a47f2b06229543327749904150
|
||||
|
||||
@@ -8722,6 +8722,13 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
|
||||
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, true));
|
||||
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, false, true));
|
||||
}
|
||||
// row lengths that are not a multiple of 32, for the scalar (33) and float4 (132, 260) paths
|
||||
for (uint32_t n : { 33, 132, 260 }) {
|
||||
for (bool v : { false, true }) {
|
||||
test_cases.emplace_back(new test_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, v, eps));
|
||||
test_cases.emplace_back(new test_rms_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, v, eps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// in-place tests
|
||||
|
||||
@@ -195,7 +195,7 @@ static const std::vector<std::string> dspark_dflash = {
|
||||
|
||||
struct plan_case {
|
||||
const char * name;
|
||||
const std::vector<std::string> & files;
|
||||
const std::vector<std::string> files;
|
||||
const char * hf_repo;
|
||||
const char * hf_file;
|
||||
bool sidecars; // request mmproj + mtp + dflash + eagle3 + dspark
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
|
||||
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
|
||||
| `-np, --parallel N` | number of parallel sequences to decode (default: 1)<br/>(env: LLAMA_ARG_N_PARALLEL) |
|
||||
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
|
||||
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
|
||||
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
|
||||
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
|
||||
|
||||
@@ -137,7 +137,6 @@ llama-completion.exe -m models\gemma-1.1-7b-it.Q4_K_M.gguf --ignore-eos -n -1
|
||||
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
|
||||
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
|
||||
| `-np, --parallel N` | number of parallel sequences to decode (default: 1)<br/>(env: LLAMA_ARG_N_PARALLEL) |
|
||||
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
|
||||
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
|
||||
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
|
||||
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
|
||||
|
||||
@@ -170,6 +170,17 @@ struct clip_hparams {
|
||||
warmup_image_size = static_cast<int>(std::sqrt(image_max_pixels));
|
||||
}
|
||||
|
||||
// used by longest_edge preprocessor (no model-specific value for min/max tokens)
|
||||
void set_limit_image_tokens() {
|
||||
const int patch_area = patch_size * patch_size * n_merge * n_merge;
|
||||
if (custom_image_min_tokens > 0) {
|
||||
image_min_pixels = custom_image_min_tokens * patch_area;
|
||||
}
|
||||
if (custom_image_max_tokens > 0) {
|
||||
image_max_pixels = custom_image_max_tokens * patch_area;
|
||||
}
|
||||
}
|
||||
|
||||
void set_warmup_n_tokens(int n_tokens) {
|
||||
int n_tok_per_side = static_cast<int>(std::sqrt(n_tokens));
|
||||
GGML_ASSERT(n_tok_per_side * n_tok_per_side == n_tokens && "n_tokens must be n*n");
|
||||
|
||||
@@ -1434,6 +1434,7 @@ struct clip_model_loader {
|
||||
// use default llava-uhd preprocessing params
|
||||
get_u32(KEY_PROJ_SCALE_FACTOR, hparams.n_merge, false);
|
||||
get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false);
|
||||
hparams.set_limit_image_tokens();
|
||||
} break;
|
||||
case PROJECTOR_TYPE_LFM2:
|
||||
{
|
||||
@@ -1471,6 +1472,7 @@ struct clip_model_loader {
|
||||
get_u32(KEY_SPATIAL_MERGE_SIZE, hparams.n_merge, false);
|
||||
hparams.image_longest_edge = hparams.image_size;
|
||||
get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false);
|
||||
hparams.set_limit_image_tokens();
|
||||
hparams.set_warmup_n_tokens(256); // avoid OOM on warmup
|
||||
} break;
|
||||
case PROJECTOR_TYPE_DOTS_OCR:
|
||||
@@ -1595,6 +1597,7 @@ struct clip_model_loader {
|
||||
if (hparams.image_longest_edge == 0) {
|
||||
hparams.image_longest_edge = 3024;
|
||||
}
|
||||
// note: the step3vl preprocessor slices based on a fixed window grid, so it does not support custom min/max image tokens
|
||||
hparams.warmup_image_size = hparams.image_size;
|
||||
} break;
|
||||
case PROJECTOR_TYPE_YOUTUVL:
|
||||
|
||||
@@ -112,7 +112,6 @@ public:
|
||||
c2w_state.clear();
|
||||
audio_pcm.clear();
|
||||
overlay.clear();
|
||||
overlay_idx = 0;
|
||||
h_state_buf.clear();
|
||||
out_buf.clear();
|
||||
prompt_embd_buf.clear();
|
||||
@@ -205,11 +204,9 @@ public:
|
||||
top_p = inp->top_p > 0 ? inp->top_p : 1.0f;
|
||||
out_type = inp->out_type;
|
||||
|
||||
// the text stream keeps flowing during generation: after frame k, the input adds
|
||||
// trailing text row k on top of the codes embedding, then tts_eos, then tts_pad
|
||||
for (int i = 3; i < n_ids - 5; i++) overlay.push_back(row(ids[(size_t) i]));
|
||||
overlay.push_back(row(tts_eos));
|
||||
overlay.push_back(row(tts_pad));
|
||||
// the prompt above holds the whole text stream up to tts_eos, so every generated
|
||||
// frame adds tts_pad on top of the codes embedding
|
||||
overlay = row(tts_pad);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -265,9 +262,7 @@ public:
|
||||
}
|
||||
|
||||
std::vector<float> fb(out.embd, out.embd + n_embd);
|
||||
const auto & ov = overlay[std::min(overlay_idx, overlay.size() - 1)];
|
||||
for (int i = 0; i < n_embd; i++) fb[(size_t) i] += ov[(size_t) i];
|
||||
overlay_idx++;
|
||||
for (int i = 0; i < n_embd; i++) fb[(size_t) i] += overlay[(size_t) i];
|
||||
|
||||
const int n_pos_per_embd = mrope ? 4 : 1;
|
||||
decode_embd_batch batch_embd(fb.data(), 1, n_pos_per_embd, n_embd);
|
||||
@@ -437,8 +432,7 @@ private:
|
||||
std::vector<int32_t> codes_buf;
|
||||
std::vector<uint8_t> c2w_state;
|
||||
std::vector<float> audio_pcm;
|
||||
std::vector<std::vector<float>> overlay;
|
||||
size_t overlay_idx = 0;
|
||||
std::vector<float> overlay;
|
||||
std::vector<float> h_state_buf;
|
||||
mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
|
||||
std::vector<char> out_buf;
|
||||
|
||||
+51
-47
@@ -139,50 +139,46 @@ struct img_tool {
|
||||
}
|
||||
}
|
||||
|
||||
// calculate the size of the **resized** image, while preserving the aspect ratio
|
||||
// the calculated size will be aligned to the nearest multiple of align_size
|
||||
// if H or W size is larger than longest_edge, it will be resized to longest_edge
|
||||
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int longest_edge) {
|
||||
GGML_ASSERT(align_size > 0);
|
||||
if (inp_size.width <= 0 || inp_size.height <= 0 || longest_edge <= 0) {
|
||||
struct calc_size_opt {
|
||||
int align_size = 1;
|
||||
int min_pixels = 0; // 0 = disabled
|
||||
int max_pixels = 0; // 0 = disabled
|
||||
// applied before min/max_pixels, so min_pixels can push an edge back above longest_edge
|
||||
int longest_edge = 0; // 0 = disabled
|
||||
};
|
||||
|
||||
// calculate the size of the **resized** image, while preserving the aspect ratio and
|
||||
// aligning to the nearest multiple of align_size ("smart_resize" in transformers code)
|
||||
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const calc_size_opt & opts) {
|
||||
GGML_ASSERT(opts.align_size > 0);
|
||||
const int width = inp_size.width;
|
||||
const int height = inp_size.height;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
float scale = std::min(static_cast<float>(longest_edge) / inp_size.width,
|
||||
static_cast<float>(longest_edge) / inp_size.height);
|
||||
auto round_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::round(x / static_cast<float>(f))) * f; };
|
||||
auto ceil_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
|
||||
auto floor_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::floor(x / static_cast<float>(f))) * f; };
|
||||
|
||||
float target_width_f = static_cast<float>(inp_size.width) * scale;
|
||||
float target_height_f = static_cast<float>(inp_size.height) * scale;
|
||||
int w_bar, h_bar;
|
||||
if (opts.longest_edge > 0) {
|
||||
const float scale = std::min(static_cast<float>(opts.longest_edge) / width,
|
||||
static_cast<float>(opts.longest_edge) / height);
|
||||
w_bar = ceil_by_factor(width * scale);
|
||||
h_bar = ceil_by_factor(height * scale);
|
||||
} else {
|
||||
// always align up first
|
||||
w_bar = std::max(opts.align_size, round_by_factor(width));
|
||||
h_bar = std::max(opts.align_size, round_by_factor(height));
|
||||
}
|
||||
|
||||
auto ceil_by_factor = [f = align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
|
||||
int aligned_width = ceil_by_factor(target_width_f);
|
||||
int aligned_height = ceil_by_factor(target_height_f);
|
||||
|
||||
return {aligned_width, aligned_height};
|
||||
}
|
||||
|
||||
// calculate the size of the **resized** image, while preserving the aspect ratio
|
||||
// the calculated size will have min_pixels <= W*H <= max_pixels
|
||||
// this is referred as "smart_resize" in transformers code
|
||||
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int min_pixels, const int max_pixels) {
|
||||
GGML_ASSERT(align_size > 0);
|
||||
const int width = inp_size.width;
|
||||
const int height = inp_size.height;
|
||||
|
||||
auto round_by_factor = [f = align_size](float x) { return static_cast<int>(std::round(x / static_cast<float>(f))) * f; };
|
||||
auto ceil_by_factor = [f = align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
|
||||
auto floor_by_factor = [f = align_size](float x) { return static_cast<int>(std::floor(x / static_cast<float>(f))) * f; };
|
||||
|
||||
// always align up first
|
||||
int h_bar = std::max(align_size, round_by_factor(height));
|
||||
int w_bar = std::max(align_size, round_by_factor(width));
|
||||
|
||||
if (h_bar * w_bar > max_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(height * width) / max_pixels);
|
||||
h_bar = std::max(align_size, floor_by_factor(height / beta));
|
||||
w_bar = std::max(align_size, floor_by_factor(width / beta));
|
||||
} else if (h_bar * w_bar < min_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(min_pixels) / (height * width));
|
||||
if (opts.max_pixels > 0 && h_bar * w_bar > opts.max_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(height) * width / opts.max_pixels);
|
||||
h_bar = std::max(opts.align_size, floor_by_factor(height / beta));
|
||||
w_bar = std::max(opts.align_size, floor_by_factor(width / beta));
|
||||
} else if (opts.min_pixels > 0 && h_bar * w_bar < opts.min_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(opts.min_pixels) / (static_cast<float>(height) * width));
|
||||
h_bar = ceil_by_factor(height * beta);
|
||||
w_bar = ceil_by_factor(width * beta);
|
||||
}
|
||||
@@ -937,9 +933,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const clip_i
|
||||
const int cur_merge = hparams.n_merge;
|
||||
const clip_image_size target_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size,
|
||||
hparams.patch_size * cur_merge,
|
||||
hparams.image_min_pixels,
|
||||
hparams.image_max_pixels);
|
||||
{
|
||||
/* align_size */ hparams.patch_size * cur_merge,
|
||||
/* min_pixels */ hparams.image_min_pixels,
|
||||
/* max_pixels */ hparams.image_max_pixels,
|
||||
/* longest_edge */ 0,
|
||||
});
|
||||
img_tool::resize(img, resized_image, target_size,
|
||||
hparams.image_resize_algo,
|
||||
hparams.image_resize_pad,
|
||||
@@ -961,8 +960,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const cl
|
||||
const int cur_merge = hparams.n_merge == 0 ? 1 : hparams.n_merge;
|
||||
const clip_image_size target_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size,
|
||||
hparams.patch_size * cur_merge,
|
||||
hparams.image_longest_edge);
|
||||
{
|
||||
/* align_size */ hparams.patch_size * cur_merge,
|
||||
/* min_pixels */ std::max(0, hparams.image_min_pixels),
|
||||
/* max_pixels */ std::max(0, hparams.image_max_pixels),
|
||||
/* longest_edge */ hparams.image_longest_edge,
|
||||
});
|
||||
img_tool::resize(img, resized_image, target_size,
|
||||
hparams.image_resize_algo,
|
||||
hparams.image_resize_pad,
|
||||
@@ -1000,8 +1003,8 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf
|
||||
mtmd_image_preprocessor_llava_uhd::slice_instructions inst;
|
||||
const int align_size = hparams.patch_size * hparams.n_merge;
|
||||
inst.overview_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size, align_size,
|
||||
hparams.image_min_pixels, hparams.image_max_pixels);
|
||||
original_size,
|
||||
{ align_size, hparams.image_min_pixels, hparams.image_max_pixels, 0 });
|
||||
// tile if either dimension exceeds tile_size with tolerance
|
||||
const bool needs_tiling = original_size.width > tile_size * max_pixels_tolerance || original_size.height > tile_size * max_pixels_tolerance;
|
||||
|
||||
@@ -1109,7 +1112,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
|
||||
// CITE: https://github.com/huggingface/transformers/blob/main/src/transformers/models/idefics3/image_processing_idefics3.py#L737
|
||||
const clip_image_size original_size = img.get_size();
|
||||
const clip_image_size refined_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size, hparams.image_size, hparams.image_longest_edge);
|
||||
original_size,
|
||||
{ hparams.image_size, std::max(0, hparams.image_min_pixels), std::max(0, hparams.image_max_pixels), hparams.image_longest_edge });
|
||||
// LOG_INF("%s: original size: %d x %d, refined size: %d x %d\n",
|
||||
// __func__, original_size.width, original_size.height,
|
||||
// refined_size.width, refined_size.height);
|
||||
|
||||
@@ -201,6 +201,7 @@ Invoke a tool call, request body is a JSON object with:
|
||||
|
||||
Headers:
|
||||
- `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself
|
||||
- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Only `docker-container:<id>` is supported for now, using an already-running container
|
||||
|
||||
Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string):
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ For the full list of features, please refer to [server's changelog](https://gith
|
||||
| `-ctk, --cache-type-k TYPE` | KV cache data type for K<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_K) |
|
||||
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
|
||||
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
|
||||
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
|
||||
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
|
||||
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
|
||||
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
|
||||
@@ -198,6 +197,8 @@ For the full list of features, please refer to [server's changelog](https://gith
|
||||
| `--ui-config, --webui-config JSON` | JSON that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG) |
|
||||
| `--ui-config-file, --webui-config-file PATH` | JSON file that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG_FILE) |
|
||||
| `--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)<br/>(env: LLAMA_ARG_UI_MCP_PROXY) |
|
||||
| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
|
||||
| `--tools-runtime OPTION` | experimental: run tools in a separate runtime environment (default: none, use host environment)<br/>available options:<br/> 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit<br/> 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit<br/><br/>(env: LLAMA_ARG_TOOLS_RUNTIME) |
|
||||
| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime, get_info<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
|
||||
| `--mcp-servers-config PATH` | experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_MCP_SERVERS_CONFIG) |
|
||||
| `--mcp-servers-json JSON` | experimental: inline JSON with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_MCP_SERVERS_JSON) |
|
||||
|
||||
+335
-39
@@ -70,6 +70,188 @@ struct server_subproc {
|
||||
}
|
||||
};
|
||||
|
||||
struct server_lru_sched {
|
||||
server_lru_sched(server_models & models) : models(models) {}
|
||||
|
||||
bool has_capacity(std::unique_lock<std::mutex> & lk) {
|
||||
check_lock(lk);
|
||||
return models.base_params.models_max <= 0
|
||||
|| count_running() < (size_t) models.base_params.models_max;
|
||||
}
|
||||
|
||||
// returns "" if no model can be given up
|
||||
std::string pick_victim(std::unique_lock<std::mutex> & lk, const std::string & exclude) {
|
||||
check_lock(lk);
|
||||
std::string victim;
|
||||
int64_t victim_last_used = 0;
|
||||
for (const auto & m : models.mapping) {
|
||||
if (m.first == exclude) {
|
||||
continue;
|
||||
}
|
||||
// a busy model is mid-request, one still coming up has no request to finish
|
||||
if (m.second.req_count != 0 || !m.second.meta.is_ready_or_sleep()) {
|
||||
continue;
|
||||
}
|
||||
if (victim.empty() || m.second.meta.last_used < victim_last_used) {
|
||||
victim = m.first;
|
||||
victim_last_used = m.second.meta.last_used;
|
||||
}
|
||||
}
|
||||
return victim;
|
||||
}
|
||||
|
||||
// requests wanting the same model share one entry, so they all need only one slot
|
||||
// and all get unblocked by the single load that entry performs
|
||||
void join(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
if (entry_t * e = find(model_id)) {
|
||||
e->n_waiters++;
|
||||
SRV_INF("request for name=%s joined the queue, %d waiting\n", model_id.c_str(), e->n_waiters);
|
||||
return;
|
||||
}
|
||||
queue.push_back({ model_id, 1, false, false });
|
||||
SRV_INF("models_max reached, request for name=%s queued at position %zu\n",
|
||||
model_id.c_str(), queue.size());
|
||||
}
|
||||
|
||||
void leave(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
for (auto it = queue.begin(); it != queue.end(); ++it) {
|
||||
if (it->model_id == model_id) {
|
||||
if (--it->n_waiters <= 0) {
|
||||
queue.erase(it); // last one waiting for this model went away
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool queue_empty(std::unique_lock<std::mutex> & lk) {
|
||||
check_lock(lk);
|
||||
return queue.empty();
|
||||
}
|
||||
|
||||
// true if it is this model's turn to load, and nobody is loading it yet
|
||||
bool try_claim(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
if (queue.empty() || queue.front().model_id != model_id || queue.front().loading) {
|
||||
return false;
|
||||
}
|
||||
if (!has_capacity(lk)) {
|
||||
return false;
|
||||
}
|
||||
queue.front().loading = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ok means the model is up: drop the entry, the other waiters just watch its status now
|
||||
void claim_done(std::unique_lock<std::mutex> & lk, const std::string & model_id, bool ok) {
|
||||
check_lock(lk);
|
||||
for (auto it = queue.begin(); it != queue.end(); ++it) {
|
||||
if (it->model_id == model_id) {
|
||||
if (ok) {
|
||||
queue.erase(it);
|
||||
} else {
|
||||
it->loading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a model is on its way out for this entry, so other requests do not also give up one
|
||||
void mark_slot_pending(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
if (entry_t * e = find(model_id)) {
|
||||
e->slot_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
// model_id went idle: give up its slot if a queued request needs one
|
||||
// thread-safe, caller must NOT hold models.mutex
|
||||
void on_model_idle(const std::string & model_id) {
|
||||
if (models.base_params.models_max <= 0) {
|
||||
return; // no limit, nothing is ever queued
|
||||
}
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(models.mutex);
|
||||
if (queue.empty()) {
|
||||
return;
|
||||
}
|
||||
size_t promised = 0;
|
||||
bool has_unserved = false;
|
||||
for (const auto & e : queue) {
|
||||
if (e.needs_slot()) {
|
||||
has_unserved = true;
|
||||
} else {
|
||||
promised++;
|
||||
}
|
||||
}
|
||||
if (!has_unserved) {
|
||||
return;
|
||||
}
|
||||
if ((int) count_running() - (int) promised < models.base_params.models_max) {
|
||||
return; // a slot is already on its way
|
||||
}
|
||||
// never give up a model that a queued request wants
|
||||
for (const auto & e : queue) {
|
||||
if (e.model_id == model_id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto it = models.mapping.find(model_id);
|
||||
if (it == models.mapping.end() || it->second.req_count != 0 || !it->second.meta.is_ready_or_sleep()) {
|
||||
return;
|
||||
}
|
||||
for (auto & e : queue) {
|
||||
if (!e.slot_pending) {
|
||||
e.slot_pending = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
SRV_INF("model name=%s went idle, giving up its slot to a queued request\n", model_id.c_str());
|
||||
models.unload(model_id);
|
||||
}
|
||||
|
||||
private:
|
||||
struct entry_t {
|
||||
std::string model_id;
|
||||
int n_waiters; // requests waiting for this model
|
||||
bool slot_pending; // a model is already being evicted for this entry
|
||||
bool loading; // one of the waiters is doing the load right now
|
||||
|
||||
// a slot is already coming, or already taken by the load in flight
|
||||
bool needs_slot() const { return !slot_pending && !loading; }
|
||||
};
|
||||
|
||||
entry_t * find(const std::string & model_id) {
|
||||
for (auto & e : queue) {
|
||||
if (e.model_id == model_id) {
|
||||
return &e;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void check_lock(std::unique_lock<std::mutex> & lk) {
|
||||
GGML_ASSERT(lk.owns_lock() && lk.mutex() == &models.mutex);
|
||||
}
|
||||
|
||||
size_t count_running() {
|
||||
size_t count = 0;
|
||||
for (const auto & m : models.mapping) {
|
||||
if (m.second.meta.is_running()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
server_models & models;
|
||||
std::deque<entry_t> queue;
|
||||
};
|
||||
|
||||
// short loopback budget for the resumable stream router to child JSON calls (probe, lookup,
|
||||
// delete). distinct from params.timeout_read/write which only applies to the generation proxy
|
||||
static constexpr int STREAM_LOOKUP_TIMEOUT_MS = 250;
|
||||
@@ -229,7 +411,8 @@ server_models::server_models(
|
||||
: ctx_preset(LLAMA_EXAMPLE_SERVER),
|
||||
base_params(params),
|
||||
base_env(get_environment()),
|
||||
base_preset(ctx_preset.load_from_args(argc, argv)) {
|
||||
base_preset(ctx_preset.load_from_args(argc, argv)),
|
||||
sched(std::make_unique<server_lru_sched>(*this)) {
|
||||
// clean up base preset
|
||||
unset_reserved_args(base_preset, true);
|
||||
// set binary path
|
||||
@@ -241,8 +424,11 @@ server_models::server_models(
|
||||
LOG_WRN("using original argv[0] as fallback: %s\n", argv[0]);
|
||||
}
|
||||
load_models();
|
||||
debug_fake_timing = !common_get_env("LLAMA_SERVER_DEBUG_FAKE_TIMING").empty();
|
||||
}
|
||||
|
||||
server_models::~server_models() = default;
|
||||
|
||||
void server_models::add_model(server_model_meta && meta) {
|
||||
if (mapping.find(meta.name) != mapping.end()) {
|
||||
throw std::runtime_error(string_format("model '%s' appears multiple times", meta.name.c_str()));
|
||||
@@ -713,22 +899,15 @@ void server_models::unload_lru() {
|
||||
return; // no limit
|
||||
}
|
||||
// remove one of the servers if we passed the models_max (least recently used - LRU)
|
||||
std::string lru_model_name = "";
|
||||
int64_t lru_last_used = ggml_time_ms();
|
||||
size_t count_active = 0;
|
||||
std::string lru_model_name;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
for (const auto & m : mapping) {
|
||||
if (m.second.meta.is_running()) {
|
||||
count_active++;
|
||||
if (m.second.meta.last_used < lru_last_used) {
|
||||
lru_model_name = m.first;
|
||||
lru_last_used = m.second.meta.last_used;
|
||||
}
|
||||
}
|
||||
if (sched->has_capacity(lk)) {
|
||||
return;
|
||||
}
|
||||
lru_model_name = sched->pick_victim(lk, "");
|
||||
}
|
||||
if (!lru_model_name.empty() && count_active >= (size_t)base_params.models_max) {
|
||||
if (!lru_model_name.empty()) {
|
||||
SRV_INF("models_max limit reached, removing LRU name=%s\n", lru_model_name.c_str());
|
||||
unload(lru_model_name);
|
||||
// wait for unload to complete
|
||||
@@ -746,6 +925,11 @@ void server_models::load(const std::string & name) {
|
||||
}
|
||||
|
||||
void server_models::load(const std::string & name, const load_options & opts) {
|
||||
if (debug_fake_timing) {
|
||||
// do not hold the mutex here, other requests must keep making progress
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
}
|
||||
|
||||
if (!opts.custom_meta.has_value()) {
|
||||
if (!has_model(name)) {
|
||||
throw std::runtime_error("model name=" + name + " is not found");
|
||||
@@ -1138,7 +1322,7 @@ void server_models::wait(std::unique_lock<std::mutex> & lk, const std::string &
|
||||
});
|
||||
}
|
||||
|
||||
bool server_models::ensure_model_ready(const std::string & name) {
|
||||
bool server_models::ensure_model_ready(const std::string & name, const std::function<bool()> & should_stop) {
|
||||
auto meta = get_meta(name);
|
||||
if (!meta.has_value()) {
|
||||
throw std::runtime_error("model name=" + name + " is not found");
|
||||
@@ -1149,25 +1333,112 @@ bool server_models::ensure_model_ready(const std::string & name) {
|
||||
if (meta->status == SERVER_MODEL_STATUS_SLEEPING) {
|
||||
return false; // child is sleeping but still running; new request will wake it up
|
||||
}
|
||||
if (meta->status == SERVER_MODEL_STATUS_UNLOADED) {
|
||||
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
|
||||
load(name);
|
||||
}
|
||||
|
||||
// wait for loading to complete
|
||||
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
|
||||
wait(name, [&meta](const server_model_meta & new_meta) {
|
||||
if (new_meta.status != SERVER_MODEL_STATUS_LOADING) {
|
||||
meta = new_meta; // update meta for final check after wait
|
||||
return true;
|
||||
bool queued = false;
|
||||
bool did_load = false;
|
||||
std::string victim;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto it = mapping.find(name);
|
||||
if (it != mapping.end() && it->second.meta.status == SERVER_MODEL_STATUS_UNLOADED) {
|
||||
bool has_capacity = sched->has_capacity(lk);
|
||||
if (has_capacity && sched->queue_empty(lk)) {
|
||||
lk.unlock();
|
||||
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
|
||||
load(name);
|
||||
did_load = true;
|
||||
} else {
|
||||
// also queue when a slot looks free but others wait already, else they starve
|
||||
sched->join(lk, name);
|
||||
queued = true;
|
||||
if (!has_capacity) {
|
||||
// an idle model may sit here right now, do not wait for a request to end
|
||||
victim = sched->pick_victim(lk, name);
|
||||
if (!victim.empty()) {
|
||||
sched->mark_slot_pending(lk, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// check final status
|
||||
if (!meta.has_value() || meta->is_failed()) {
|
||||
throw std::runtime_error("model name=" + name + " failed to load");
|
||||
}
|
||||
if (!victim.empty()) {
|
||||
SRV_INF("evicting idle LRU name=%s to make room for name=%s\n", victim.c_str(), name.c_str());
|
||||
unload(victim);
|
||||
}
|
||||
|
||||
// while queued, this is also where the load happens: the head of the queue does it
|
||||
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto leave_queue = [this, &queued, &lk, &name]() {
|
||||
if (queued) {
|
||||
sched->leave(lk, name);
|
||||
queued = false;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
bool saw_loading = false;
|
||||
while (true) {
|
||||
auto it = mapping.find(name);
|
||||
if (it == mapping.end()) {
|
||||
break; // removed by another code path, nothing to wait for
|
||||
}
|
||||
const server_model_status status = it->second.meta.status;
|
||||
|
||||
if (status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_SLEEPING) {
|
||||
break;
|
||||
}
|
||||
if (status == SERVER_MODEL_STATUS_DOWNLOADING || status == SERVER_MODEL_STATUS_DOWNLOADED) {
|
||||
break; // do not wait on a download child
|
||||
}
|
||||
if (status == SERVER_MODEL_STATUS_LOADING) {
|
||||
saw_loading = true;
|
||||
} else if (status == SERVER_MODEL_STATUS_UNLOADED) {
|
||||
if (did_load || saw_loading) {
|
||||
// a spawn happened and the instance came back down
|
||||
if (it->second.meta.is_failed()) {
|
||||
throw std::runtime_error("model name=" + name + " failed to load");
|
||||
}
|
||||
break; // unloaded by another code path, caller reports "not running"
|
||||
}
|
||||
if (!queued) {
|
||||
break; // not queued, and the load someone else started fell over
|
||||
}
|
||||
}
|
||||
|
||||
if (should_stop && should_stop()) {
|
||||
// if a model was evicted for us, the free slot goes to the next waiter
|
||||
throw std::runtime_error("request cancelled while waiting for model name=" + name);
|
||||
}
|
||||
|
||||
// our turn: our model is at the head, and a slot really did free up
|
||||
if (status == SERVER_MODEL_STATUS_UNLOADED && sched->try_claim(lk, name)) {
|
||||
lk.unlock();
|
||||
bool ok = true;
|
||||
try {
|
||||
SRV_INF("slot available, loading queued model name=%s\n", name.c_str());
|
||||
load(name);
|
||||
did_load = true;
|
||||
} catch (const std::exception & e) {
|
||||
// lost a race for the slot, stay in line and retry
|
||||
SRV_WRN("queued load of name=%s did not go through: %s\n", name.c_str(), e.what());
|
||||
ok = false;
|
||||
}
|
||||
lk.lock();
|
||||
sched->claim_done(lk, name, ok);
|
||||
if (ok) {
|
||||
queued = false; // entry is gone, the other waiters watch the status now
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
cv.wait_for(lk, std::chrono::milliseconds(200));
|
||||
}
|
||||
} catch (...) {
|
||||
leave_queue();
|
||||
throw;
|
||||
}
|
||||
leave_queue();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1180,9 +1451,16 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
|
||||
if (!meta->is_running()) {
|
||||
throw std::invalid_argument("model name=" + name + " is not running");
|
||||
}
|
||||
if (update_last_used) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
mapping[name].meta.last_used = ggml_time_ms();
|
||||
if (update_last_used) {
|
||||
mapping[name].meta.last_used = ggml_time_ms();
|
||||
}
|
||||
mapping[name].req_count++;
|
||||
}
|
||||
if (debug_fake_timing) {
|
||||
// sleep after req_count++, so the model counts as busy while we wait here
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
}
|
||||
SRV_INF("proxying request to model %s on port %d\n", name.c_str(), meta->port);
|
||||
std::string proxy_path = req.path;
|
||||
@@ -1198,13 +1476,29 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
|
||||
req.headers,
|
||||
req.body,
|
||||
req.files,
|
||||
// a detached request belongs to a replay session that outlives the client socket:
|
||||
// it reaches the child even when the downstream died during the load wait, the
|
||||
// session buffer is the recipient and DELETE remains the stop
|
||||
detached ? std::function<bool()>([]() { return false; }) : req.should_stop,
|
||||
// a detached request belongs to a replay session
|
||||
detached
|
||||
? std::function<bool()>([]() { return false; })
|
||||
: req.should_stop,
|
||||
base_params.timeout_read,
|
||||
base_params.timeout_write
|
||||
);
|
||||
|
||||
proxy->cleanup = [this, name]() {
|
||||
bool went_idle = false;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto it = mapping.find(name);
|
||||
if (it != mapping.end() && it->second.req_count > 0) {
|
||||
it->second.req_count--;
|
||||
went_idle = it->second.req_count == 0;
|
||||
}
|
||||
}
|
||||
if (went_idle) {
|
||||
sched->on_model_idle(name);
|
||||
}
|
||||
};
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
@@ -1568,7 +1862,7 @@ void server_models_routes::init_routes() {
|
||||
return error_res;
|
||||
}
|
||||
if (autoload) {
|
||||
models.ensure_model_ready(name);
|
||||
models.ensure_model_ready(name, req.should_stop);
|
||||
}
|
||||
return models.proxy_request(req, method, name, false);
|
||||
};
|
||||
@@ -1588,7 +1882,9 @@ void server_models_routes::init_routes() {
|
||||
// this request instead of leaving an orphan generation
|
||||
std::string conv_id = server_stream_conv_id_from_headers(req.headers);
|
||||
uint64_t ticket = models.conv_models.remember(conv_id, name);
|
||||
bool waited = autoload && models.ensure_model_ready(name);
|
||||
// a dead socket must not cancel a session request, only a stop does (checked right below)
|
||||
auto should_stop = ticket == 0 ? req.should_stop : nullptr;
|
||||
bool waited = autoload && models.ensure_model_ready(name, should_stop);
|
||||
if (ticket != 0 && !models.conv_models.alive(conv_id, ticket)) {
|
||||
SRV_INF("request for conv_id=%s cancelled while model name=%s was loading\n",
|
||||
conv_id.c_str(), name.c_str());
|
||||
@@ -2064,7 +2360,7 @@ server_http_proxy::server_http_proxy(
|
||||
cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
|
||||
cli->set_read_timeout(timeout_write, 0);
|
||||
this->status = 500; // to be overwritten upon response
|
||||
this->cleanup = [pipe]() {
|
||||
this->cleanup_pipes = [pipe]() {
|
||||
pipe->close_read();
|
||||
pipe->close_write();
|
||||
};
|
||||
|
||||
@@ -84,7 +84,6 @@ struct server_model_meta {
|
||||
int exit_code = 0; // exit code of the model instance process (only valid if status == FAILED)
|
||||
int stop_timeout = 0; // seconds to wait before force-killing the model instance during shutdown
|
||||
mtmd_caps multimodal; // multimodal capabilities
|
||||
// bool need_download = false; // whether the model needs to be downloaded before loading // TODO @ngxson: implement this
|
||||
|
||||
bool is_ready() const {
|
||||
return status == SERVER_MODEL_STATUS_LOADED;
|
||||
@@ -94,6 +93,10 @@ struct server_model_meta {
|
||||
return status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_LOADING || status == SERVER_MODEL_STATUS_SLEEPING;
|
||||
}
|
||||
|
||||
bool is_ready_or_sleep() const {
|
||||
return status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_SLEEPING;
|
||||
}
|
||||
|
||||
bool is_failed() const {
|
||||
return status == SERVER_MODEL_STATUS_UNLOADED && exit_code != 0;
|
||||
}
|
||||
@@ -103,16 +106,19 @@ struct server_model_meta {
|
||||
};
|
||||
|
||||
struct server_models_routes;
|
||||
struct server_subproc; // defined in server-models.cpp
|
||||
struct server_subproc; // defined in server-models.cpp
|
||||
struct server_lru_sched; // defined in server-models.cpp
|
||||
|
||||
struct server_models {
|
||||
friend struct server_models_routes;
|
||||
friend struct server_lru_sched;
|
||||
|
||||
private:
|
||||
struct instance_t {
|
||||
std::shared_ptr<server_subproc> subproc; // shared between main thread and monitoring thread
|
||||
std::thread th;
|
||||
server_model_meta meta;
|
||||
int req_count = 0; // number of active proxy requests
|
||||
};
|
||||
|
||||
std::mutex mutex;
|
||||
@@ -191,6 +197,12 @@ private:
|
||||
std::vector<std::string> base_env;
|
||||
common_preset base_preset; // base preset from llama-server CLI args
|
||||
|
||||
// queue of requests waiting for a models_max slot
|
||||
std::unique_ptr<server_lru_sched> sched;
|
||||
|
||||
// if true, add some delay to simulate works (useful for testing)
|
||||
bool debug_fake_timing = false;
|
||||
|
||||
void update_meta(const std::string & name, const server_model_meta & meta);
|
||||
|
||||
// unload least recently used models if the limit is reached
|
||||
@@ -207,6 +219,7 @@ public:
|
||||
conv_model_tracker conv_models;
|
||||
|
||||
server_models(const common_params & params, int argc, char ** argv);
|
||||
~server_models();
|
||||
|
||||
server_response sse; // for real-time updates via SSE endpoint
|
||||
|
||||
@@ -263,7 +276,9 @@ public:
|
||||
// ensure the model is in ready state (thread-safe)
|
||||
// return false if model is ready
|
||||
// otherwise, load the model and blocking wait until it's ready, then return true (meta may need to be refreshed)
|
||||
bool ensure_model_ready(const std::string & name);
|
||||
// if models_max is reached, the request waits in a queue until a slot frees up
|
||||
// throws if the load fails, or if should_stop fires while waiting
|
||||
bool ensure_model_ready(const std::string & name, const std::function<bool()> & should_stop = nullptr);
|
||||
|
||||
// proxy an HTTP request to the model instance
|
||||
server_http_res_ptr proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used, bool detached = false);
|
||||
@@ -343,7 +358,6 @@ struct server_models_routes {
|
||||
*/
|
||||
struct server_http_proxy : server_http_res {
|
||||
std::function<void()> cleanup = nullptr;
|
||||
public:
|
||||
server_http_proxy(const std::string & method,
|
||||
const std::string & scheme,
|
||||
const std::string & host,
|
||||
@@ -357,11 +371,15 @@ public:
|
||||
int32_t timeout_write
|
||||
);
|
||||
~server_http_proxy() {
|
||||
if (cleanup_pipes) {
|
||||
cleanup_pipes();
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
private:
|
||||
std::function<void()> cleanup_pipes = nullptr;
|
||||
std::thread thread;
|
||||
struct msg_t {
|
||||
std::map<std::string, std::string> headers;
|
||||
|
||||
@@ -519,7 +519,7 @@ task_params eval_llama_cmpl_schema(
|
||||
const json & data) {
|
||||
task_params params;
|
||||
|
||||
// Sampling parameter defaults are loaded from the global server context (but individual requests can still them)
|
||||
// Sampling parameter defaults are loaded from the global server context (but individual requests can still override them)
|
||||
params.sampling = params_base.sampling;
|
||||
params.speculative = params_base.speculative;
|
||||
params.n_keep = params_base.n_keep;
|
||||
|
||||
+491
-76
@@ -10,12 +10,15 @@
|
||||
#include <ctime>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <unordered_set>
|
||||
#include <tuple>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#if defined(_WIN32)
|
||||
# ifndef NOMINMAX
|
||||
@@ -127,6 +130,13 @@ static int entry_depth(const std::string & rel) {
|
||||
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
|
||||
}
|
||||
|
||||
// directories that a listing reports but never descends into: they can be enormous
|
||||
// lowercase only, the local walker case-folds a name before the lookup
|
||||
static const char * const SERVER_TOOL_JUNK_DIR_NAMES[] = {
|
||||
".git", ".svn", ".hg", "node_modules", "__pycache__",
|
||||
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
|
||||
};
|
||||
|
||||
class tools_io {
|
||||
public:
|
||||
struct exec_result {
|
||||
@@ -165,6 +175,85 @@ public:
|
||||
const std::function<bool(const std::string &)> & on_chunk = nullptr) const = 0;
|
||||
};
|
||||
|
||||
// shared subprocess execution helper, used by both the local and the docker-backed tools_io implementations.
|
||||
// combine_stderr=false when the raw stdout bytes must not be tainted by stderr, e.g. reading file contents.
|
||||
static tools_io::exec_result run_subprocess(
|
||||
const std::vector<std::string> & args,
|
||||
size_t max_output,
|
||||
int timeout_secs,
|
||||
const std::function<bool(const std::string &)> & on_chunk,
|
||||
bool combine_stderr,
|
||||
const std::string & cwd = "") {
|
||||
tools_io::exec_result res;
|
||||
|
||||
common_subproc proc;
|
||||
|
||||
int options = subprocess_option_no_window
|
||||
| subprocess_option_inherit_environment
|
||||
| subprocess_option_search_user_path;
|
||||
if (combine_stderr) {
|
||||
options |= subprocess_option_combined_stdout_stderr;
|
||||
}
|
||||
|
||||
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
|
||||
res.output = "failed to spawn process";
|
||||
return res;
|
||||
}
|
||||
|
||||
std::atomic<bool> done{false};
|
||||
std::atomic<bool> timed_out{false};
|
||||
|
||||
std::thread timeout_thread([&]() {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
|
||||
while (!done.load()) {
|
||||
if (std::chrono::steady_clock::now() >= deadline) {
|
||||
timed_out.store(true);
|
||||
proc.terminate();
|
||||
return;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
});
|
||||
|
||||
FILE * f = proc.stdout_file();
|
||||
std::string output;
|
||||
bool truncated = false;
|
||||
if (f) {
|
||||
char buf[4096];
|
||||
while (fgets(buf, sizeof(buf), f) != nullptr) {
|
||||
if (!truncated) {
|
||||
size_t len = strlen(buf);
|
||||
if (output.size() + len <= max_output) {
|
||||
output.append(buf, len);
|
||||
if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
|
||||
proc.terminate();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
size_t remaining = max_output - output.size();
|
||||
output.append(buf, remaining);
|
||||
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done.store(true);
|
||||
if (timeout_thread.joinable()) {
|
||||
timeout_thread.join();
|
||||
}
|
||||
|
||||
res.exit_code = proc.join();
|
||||
|
||||
res.output = console_output_to_utf8(output);
|
||||
res.timed_out = timed_out.load();
|
||||
if (truncated) {
|
||||
res.output += "\n[output truncated]";
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
class tools_io_basic : public tools_io {
|
||||
public:
|
||||
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
|
||||
@@ -276,72 +365,7 @@ public:
|
||||
size_t max_output,
|
||||
int timeout_secs,
|
||||
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
|
||||
exec_result res;
|
||||
|
||||
common_subproc proc;
|
||||
|
||||
int options = subprocess_option_no_window
|
||||
| subprocess_option_combined_stdout_stderr
|
||||
| subprocess_option_inherit_environment
|
||||
| subprocess_option_search_user_path;
|
||||
|
||||
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
|
||||
res.output = "failed to spawn process";
|
||||
return res;
|
||||
}
|
||||
|
||||
std::atomic<bool> done{false};
|
||||
std::atomic<bool> timed_out{false};
|
||||
|
||||
std::thread timeout_thread([&]() {
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
|
||||
while (!done.load()) {
|
||||
if (std::chrono::steady_clock::now() >= deadline) {
|
||||
timed_out.store(true);
|
||||
proc.terminate();
|
||||
return;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
});
|
||||
|
||||
FILE * f = proc.stdout_file();
|
||||
std::string output;
|
||||
bool truncated = false;
|
||||
if (f) {
|
||||
char buf[4096];
|
||||
while (fgets(buf, sizeof(buf), f) != nullptr) {
|
||||
if (!truncated) {
|
||||
size_t len = strlen(buf);
|
||||
if (output.size() + len <= max_output) {
|
||||
output.append(buf, len);
|
||||
if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
|
||||
proc.terminate();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
size_t remaining = max_output - output.size();
|
||||
output.append(buf, remaining);
|
||||
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done.store(true);
|
||||
if (timeout_thread.joinable()) {
|
||||
timeout_thread.join();
|
||||
}
|
||||
|
||||
res.exit_code = proc.join();
|
||||
|
||||
res.output = console_output_to_utf8(output);
|
||||
res.timed_out = timed_out.load();
|
||||
if (truncated) {
|
||||
res.output += "\n[output truncated]";
|
||||
}
|
||||
return res;
|
||||
return run_subprocess(args, max_output, timeout_secs, on_chunk, /*combine_stderr=*/true, cwd);
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -384,10 +408,8 @@ private:
|
||||
}
|
||||
|
||||
static const std::unordered_set<std::string> & junk_dir_names() {
|
||||
static const std::unordered_set<std::string> names = {
|
||||
".git", ".svn", ".hg", "node_modules", "__pycache__",
|
||||
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
|
||||
};
|
||||
static const std::unordered_set<std::string> names(
|
||||
std::begin(SERVER_TOOL_JUNK_DIR_NAMES), std::end(SERVER_TOOL_JUNK_DIR_NAMES));
|
||||
return names;
|
||||
}
|
||||
|
||||
@@ -450,9 +472,274 @@ private:
|
||||
}
|
||||
};
|
||||
|
||||
// timeout for auxiliary isolate calls (stat/mkdir/ls/cp helpers); exec_shell_command uses its own
|
||||
// caller-controlled timeout instead, enforced separately in run()
|
||||
static constexpr int SERVER_TOOL_ISOLATE_EXEC_TIMEOUT = 15; // seconds
|
||||
static constexpr size_t SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE = 64 * 1024 * 1024; // 64 MB
|
||||
|
||||
// runs every tools_io operation as a command inside an isolate: a container, a remote host, ...
|
||||
// the isolate is created, mounted, and torn down externally by the caller
|
||||
// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout
|
||||
class tools_io_isolate : public tools_io {
|
||||
public:
|
||||
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
|
||||
explicit tools_io_isolate(std::string cwd = "") : cwd(std::move(cwd)) {}
|
||||
|
||||
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged.
|
||||
// isolate paths are always POSIX-style ('/'), regardless of host OS.
|
||||
std::string resolve(const std::string & path) const override {
|
||||
if (cwd.empty() || (!path.empty() && path[0] == '/')) {
|
||||
return path;
|
||||
}
|
||||
return cwd + "/" + path;
|
||||
}
|
||||
|
||||
bool is_directory(const std::string & path) const override {
|
||||
return shell_test("-d", resolve(path));
|
||||
}
|
||||
|
||||
bool is_regular_file(const std::string & path) const override {
|
||||
return shell_test("-f", resolve(path));
|
||||
}
|
||||
|
||||
bool file_size(const std::string & path, uintmax_t & out_size) const override {
|
||||
auto res = exec({"sh", "-c", "wc -c < \"$1\"", "_", resolve(path)}, 64, true);
|
||||
if (res.exit_code != 0 || res.timed_out) return false;
|
||||
try {
|
||||
size_t pos;
|
||||
out_size = (uintmax_t) std::stoull(res.output, &pos);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool read_file(const std::string & path, std::string & out) const override {
|
||||
// combine_stderr=false: stderr must not be spliced into raw file bytes
|
||||
auto res = exec({"cat", "--", resolve(path)}, SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE, false);
|
||||
if (res.exit_code != 0 || res.timed_out) return false;
|
||||
out = res.output;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write_file(const std::string & path, const std::string & content) const override {
|
||||
std::string abs_path = resolve(path);
|
||||
|
||||
std::error_code ec;
|
||||
fs::path tmp_dir = fs::temp_directory_path(ec);
|
||||
if (ec) return false;
|
||||
|
||||
static std::atomic<uint64_t> tmp_counter{0};
|
||||
fs::path tmp = tmp_dir / string_format(
|
||||
"llama-tools-io-isolate-%zu-%llu.tmp",
|
||||
std::hash<std::thread::id>{}(std::this_thread::get_id()),
|
||||
(unsigned long long) tmp_counter.fetch_add(1));
|
||||
|
||||
{
|
||||
std::ofstream f(tmp, std::ios::binary);
|
||||
if (!f) return false;
|
||||
f << content;
|
||||
if (!f) return false;
|
||||
}
|
||||
|
||||
bool ok = shell_run({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\"", "_", abs_path});
|
||||
if (ok) {
|
||||
ok = upload(tmp.string(), abs_path);
|
||||
}
|
||||
|
||||
std::error_code rm_ec;
|
||||
fs::remove(tmp, rm_ec);
|
||||
return ok;
|
||||
}
|
||||
|
||||
list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override {
|
||||
list_result out;
|
||||
|
||||
const std::string abs_base = resolve(base);
|
||||
if (!is_directory(base)) {
|
||||
out.err = "path does not exist or is not a directory";
|
||||
return out;
|
||||
}
|
||||
|
||||
// git ls-files cannot list directories; use the walker when they are requested
|
||||
if (kind == list_kind::files) {
|
||||
auto res = exec(
|
||||
{"sh", "-c", "cd \"$1\" && git ls-files --cached --others --exclude-standard", "_", abs_base},
|
||||
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
|
||||
|
||||
if (res.exit_code == 0 && !res.timed_out) {
|
||||
for (const auto & rel : split_lines(res.output, /*strip_dot_slash=*/false)) {
|
||||
if (max_depth > 0 && entry_depth(rel) > max_depth) continue;
|
||||
out.entries.push_back({rel, false});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
if (kind == list_kind::dirs || kind == list_kind::all) {
|
||||
for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/true, out.truncated)) {
|
||||
out.entries.push_back({std::move(rel), true});
|
||||
}
|
||||
}
|
||||
if (kind == list_kind::files || kind == list_kind::all) {
|
||||
for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/false, out.truncated)) {
|
||||
out.entries.push_back({std::move(rel), false});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// wraps the command with an in-isolate `timeout`, since killing the host-side client
|
||||
// does not kill the process tree running inside the isolate
|
||||
exec_result run(
|
||||
const std::vector<std::string> & args,
|
||||
size_t max_output,
|
||||
int timeout_secs,
|
||||
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
|
||||
std::vector<std::string> inner = {"timeout", std::to_string(timeout_secs) + "s"};
|
||||
inner.insert(inner.end(), args.begin(), args.end());
|
||||
// small buffer over timeout_secs so the in-isolate `timeout` has a chance to exit cleanly
|
||||
// before the host-side supervisory timeout forcibly kills the client
|
||||
return run_subprocess(
|
||||
build_argv(with_cwd(inner), /*needs_stdin=*/true),
|
||||
max_output, timeout_secs + 5, on_chunk, true);
|
||||
}
|
||||
|
||||
protected:
|
||||
// wrap `inner` (a complete POSIX argv) into the host-side argv that runs it in the isolate
|
||||
// a transport that re-parses its args in a remote shell (ssh) must join `inner` with shell_quote_join()
|
||||
virtual std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const = 0;
|
||||
|
||||
// copy a host file into the isolate, `isolate_path` is absolute and its parent already exists
|
||||
virtual bool upload(const std::string & host_path, const std::string & isolate_path) const = 0;
|
||||
|
||||
// quote `argv` into a single string that a POSIX shell re-parses into exactly `argv`
|
||||
static std::string shell_quote_join(const std::vector<std::string> & argv) {
|
||||
std::string out;
|
||||
for (const auto & arg : argv) {
|
||||
if (!out.empty()) out += ' ';
|
||||
out += '\'';
|
||||
for (const char c : arg) {
|
||||
// a single quote cannot be escaped inside single quotes: close, escape, reopen
|
||||
if (c == '\'') out += "'\\''";
|
||||
else out += c;
|
||||
}
|
||||
out += '\'';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string cwd;
|
||||
|
||||
// set the working directory in the command itself, docker's `-w` has no equivalent on every transport
|
||||
// auxiliary calls do not need this, they use the absolute paths from resolve()
|
||||
std::vector<std::string> with_cwd(const std::vector<std::string> & inner) const {
|
||||
if (cwd.empty()) {
|
||||
return inner;
|
||||
}
|
||||
// 127 is what a shell reports for a command it could not run
|
||||
std::vector<std::string> out = {"sh", "-c", "cd \"$1\" || exit 127; shift; exec \"$@\"", "_", cwd};
|
||||
out.insert(out.end(), inner.begin(), inner.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
exec_result exec(const std::vector<std::string> & inner, size_t max_output, bool combine_stderr) const {
|
||||
return run_subprocess(
|
||||
build_argv(inner, /*needs_stdin=*/false),
|
||||
max_output, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, combine_stderr);
|
||||
}
|
||||
|
||||
bool shell_run(const std::vector<std::string> & inner) const {
|
||||
auto res = exec(inner, 4096, true);
|
||||
return res.exit_code == 0 && !res.timed_out;
|
||||
}
|
||||
|
||||
bool shell_test(const char * flag, const std::string & path) const {
|
||||
return shell_run({"sh", "-c", std::string("[ ") + flag + " \"$1\" ]", "_", path});
|
||||
}
|
||||
|
||||
static std::vector<std::string> split_lines(const std::string & text, bool strip_dot_slash) {
|
||||
std::vector<std::string> result;
|
||||
std::istringstream iss(text);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) continue;
|
||||
if (strip_dot_slash && line.rfind("./", 0) == 0) line = line.substr(2);
|
||||
std::replace(line.begin(), line.end(), '\\', '/');
|
||||
result.push_back(line);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// one `find` pass in the isolate. junk directories stay selectable but are never descended into,
|
||||
// and -mindepth/-maxdepth keep a busybox image working as well as a GNU one
|
||||
std::vector<std::string> find_entries(const std::string & abs_base, int max_depth, bool dirs, bool & truncated) const {
|
||||
std::string prune_expr;
|
||||
for (const char * n : SERVER_TOOL_JUNK_DIR_NAMES) {
|
||||
if (!prune_expr.empty()) prune_expr += " -o ";
|
||||
prune_expr += std::string("-name ") + n;
|
||||
}
|
||||
|
||||
std::string cmd = "cd \"$1\" && find . -mindepth 1";
|
||||
if (max_depth > 0) {
|
||||
cmd += " -maxdepth " + std::to_string(max_depth);
|
||||
}
|
||||
cmd += " \\( " + prune_expr + " \\) -prune";
|
||||
cmd += dirs ? " -print -o -type d -print" : " -o -type f -print";
|
||||
|
||||
auto res = exec({"sh", "-c", cmd, "_", abs_base}, SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
|
||||
truncated = truncated || res.timed_out;
|
||||
return split_lines(res.output, /*strip_dot_slash=*/true);
|
||||
}
|
||||
};
|
||||
|
||||
// an already-running docker container, driven through `docker exec` and `docker cp`
|
||||
class tools_io_docker : public tools_io_isolate {
|
||||
public:
|
||||
tools_io_docker(std::string container_id, std::string cwd = "")
|
||||
: tools_io_isolate(std::move(cwd)), container_id(std::move(container_id)) {}
|
||||
|
||||
protected:
|
||||
std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const override {
|
||||
std::vector<std::string> argv = {"docker", "exec"};
|
||||
if (needs_stdin) {
|
||||
argv.push_back("-i");
|
||||
}
|
||||
argv.push_back(container_id);
|
||||
argv.insert(argv.end(), inner.begin(), inner.end());
|
||||
return argv;
|
||||
}
|
||||
|
||||
bool upload(const std::string & host_path, const std::string & isolate_path) const override {
|
||||
auto res = run_subprocess(
|
||||
{"docker", "cp", host_path, container_id + ":" + isolate_path},
|
||||
4096, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, true);
|
||||
return res.exit_code == 0 && !res.timed_out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string container_id;
|
||||
};
|
||||
|
||||
// runtime spec used by --tools-runtime and the x-tool-runtime header
|
||||
// this is the only scheme for now, ssh: and podman: can be added next to it
|
||||
static const std::string SERVER_TOOL_RUNTIME_DOCKER_CONTAINER = "docker-container:";
|
||||
|
||||
// an empty runtime runs the tools on the host
|
||||
static std::unique_ptr<tools_io> make_tools_io(const json & params) {
|
||||
std::string cwd = json_value(params, "cwd", std::string());
|
||||
return std::make_unique<tools_io_basic>(cwd);
|
||||
std::string cwd = json_value(params, "cwd", std::string());
|
||||
std::string runtime = json_value(params, "runtime", std::string());
|
||||
if (runtime.empty()) {
|
||||
return std::make_unique<tools_io_basic>(cwd);
|
||||
}
|
||||
if (runtime.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) {
|
||||
return std::make_unique<tools_io_docker>(runtime.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()), cwd);
|
||||
}
|
||||
// do not fall back to the host, the caller asked for an isolate
|
||||
throw std::runtime_error("unknown tool runtime: " + runtime);
|
||||
}
|
||||
|
||||
// no '/' in pattern -> match basename at any depth; else match full relative path
|
||||
@@ -861,8 +1148,11 @@ struct server_tool_exec_shell_command : server_tool {
|
||||
timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT);
|
||||
max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
|
||||
|
||||
// an isolate is always POSIX regardless of host OS, so it always gets `sh -c`
|
||||
#ifdef _WIN32
|
||||
std::vector<std::string> args = {"cmd", "/c", command};
|
||||
std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
|
||||
? std::vector<std::string>{"sh", "-c", command}
|
||||
: std::vector<std::string>{"cmd", "/c", command};
|
||||
#else
|
||||
std::vector<std::string> args = {"sh", "-c", command};
|
||||
#endif
|
||||
@@ -1355,11 +1645,16 @@ struct server_tool_get_info : server_tool {
|
||||
json invoke(json params, server_tool::stream *) const override {
|
||||
auto io = make_tools_io(params);
|
||||
|
||||
// inside an isolate, we always use the linux command
|
||||
#ifdef _WIN32
|
||||
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
|
||||
? std::vector<std::string>{"uname", "-a"}
|
||||
: std::vector<std::string>{"cmd", "/c", "ver"};
|
||||
#else
|
||||
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
std::vector<std::string> args = {"uname", "-a"};
|
||||
#endif
|
||||
|
||||
auto res = io->run(args, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
// "ver" prints a blank line before the version, so the output is stripped on both ends;
|
||||
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
|
||||
std::string os_info = res.exit_code == 0 && !res.timed_out ? string_strip(res.output) : "unknown";
|
||||
@@ -1461,6 +1756,103 @@ struct server_mcp_tool : server_tool {
|
||||
}
|
||||
};
|
||||
|
||||
// owns the docker container used as the sandboxed runtime for tool invocations, as configured by
|
||||
// --tools-runtime. "spawned" mode starts and stops the container itself; "existing" mode just reuses
|
||||
// a container id the user already has running and never stops it.
|
||||
struct server_tools_docker_runtime {
|
||||
server_tools_docker_runtime(const server_tools_docker_runtime &) = delete;
|
||||
|
||||
explicit server_tools_docker_runtime(const std::string & spec) {
|
||||
static const std::string docker_prefix = "docker:";
|
||||
if (spec.rfind(docker_prefix, 0) == 0) {
|
||||
spawned = true;
|
||||
image = spec.substr(docker_prefix.size());
|
||||
if (image.empty()) {
|
||||
throw std::runtime_error("--tools-runtime docker:<image> requires an image name");
|
||||
}
|
||||
spawn();
|
||||
} else if (spec.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) {
|
||||
spawned = false;
|
||||
container_id = spec.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size());
|
||||
if (container_id.empty()) {
|
||||
throw std::runtime_error("--tools-runtime docker-container:<id> requires a container id");
|
||||
}
|
||||
} else {
|
||||
throw std::runtime_error("unknown --tools-runtime option: " + spec);
|
||||
}
|
||||
}
|
||||
|
||||
~server_tools_docker_runtime() {
|
||||
if (spawned && !container_id.empty()) {
|
||||
// closing stdin signals the container's shell (its pid 1) to exit; --rm then removes it
|
||||
proc.close_stdin();
|
||||
proc.join();
|
||||
}
|
||||
}
|
||||
|
||||
// container id to use for the next tool call; respawns a spawned container that died on its own,
|
||||
// or throws if an externally-managed one is no longer reachable
|
||||
std::string get_container_id() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (!spawned) {
|
||||
if (!is_running(container_id)) {
|
||||
throw std::runtime_error(string_format(
|
||||
"docker container \"%s\" is no longer running, restart it to keep using tools",
|
||||
container_id.c_str()));
|
||||
}
|
||||
return container_id;
|
||||
}
|
||||
|
||||
if (!proc.alive()) {
|
||||
SRV_WRN("docker tools runtime container \"%s\" died, respawning\n", container_id.c_str());
|
||||
spawn();
|
||||
}
|
||||
return container_id;
|
||||
}
|
||||
|
||||
private:
|
||||
bool spawned = false;
|
||||
std::string image; // spawned mode only
|
||||
std::string container_id;
|
||||
common_subproc proc; // spawned mode only: `docker run` client that keeps the container alive
|
||||
std::mutex mutex;
|
||||
|
||||
// spawns "docker run --rm -i <image> sh" and keeps its stdin open; the shell blocks reading stdin,
|
||||
// so the container stays alive until we close it (see destructor) or it is killed from the outside
|
||||
void spawn() {
|
||||
std::error_code ec;
|
||||
fs::path cidfile = fs::temp_directory_path(ec) / string_format(
|
||||
"llama-tools-runtime-cid-%zu.tmp", std::hash<std::thread::id>{}(std::this_thread::get_id()));
|
||||
fs::remove(cidfile, ec);
|
||||
|
||||
std::vector<std::string> args = {"docker", "run", "--rm", "-i", "--cidfile", cidfile.string(), image, "sh"};
|
||||
int options = subprocess_option_no_window
|
||||
| subprocess_option_inherit_environment
|
||||
| subprocess_option_search_user_path;
|
||||
if (!proc.create(args, options)) {
|
||||
throw std::runtime_error("failed to spawn docker container for tools runtime (image: " + image + ")");
|
||||
}
|
||||
|
||||
std::string cid;
|
||||
for (int i = 0; i < 100 && cid.empty(); i++) {
|
||||
std::ifstream f(cidfile);
|
||||
if (f) std::getline(f, cid);
|
||||
if (cid.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
fs::remove(cidfile, ec);
|
||||
if (cid.empty()) {
|
||||
proc.terminate();
|
||||
throw std::runtime_error("timed out waiting for docker container to start (image: " + image + ")");
|
||||
}
|
||||
container_id = cid;
|
||||
}
|
||||
|
||||
static bool is_running(const std::string & id) {
|
||||
auto res = run_subprocess({"docker", "inspect", "-f", "{{.State.Running}}", id}, 16, 5, nullptr, true);
|
||||
return res.exit_code == 0 && !res.timed_out && res.output.rfind("true", 0) == 0;
|
||||
}
|
||||
};
|
||||
|
||||
static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools, const std::string & name, bool require_stream) {
|
||||
for (auto & t : tools) {
|
||||
if (t->name == name) {
|
||||
@@ -1506,8 +1898,16 @@ static std::string get_header(const std::map<std::string, std::string> & headers
|
||||
return default_value;
|
||||
}
|
||||
|
||||
server_tools::server_tools() = default;
|
||||
server_tools::~server_tools() = default;
|
||||
|
||||
void server_tools::setup(const std::vector<std::string> & enabled_tools,
|
||||
server_mcp & mcp_mgr) {
|
||||
server_mcp & mcp_mgr,
|
||||
const std::string & tools_runtime) {
|
||||
if (!tools_runtime.empty()) {
|
||||
docker_runtime = std::make_unique<server_tools_docker_runtime>(tools_runtime);
|
||||
}
|
||||
|
||||
if (!enabled_tools.empty()) {
|
||||
if (!common_subproc::is_supported()) {
|
||||
throw std::runtime_error("subprocess is not enabled on this build");
|
||||
@@ -1590,11 +1990,26 @@ void server_tools::setup(const std::vector<std::string> & enabled_tools,
|
||||
bool stream = body.value("stream", false);
|
||||
|
||||
// accept x-tool-cwd header to override of the process
|
||||
if (params.contains("cwd")) {
|
||||
params.erase("cwd");
|
||||
}
|
||||
auto cwd = get_header(req.headers, "x-tool-cwd");
|
||||
if (!cwd.empty()) {
|
||||
params["cwd"] = cwd;
|
||||
}
|
||||
|
||||
// accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:<id>";
|
||||
// falls back to the --tools-runtime isolate, if configured
|
||||
if (params.contains("runtime")) {
|
||||
params.erase("runtime");
|
||||
}
|
||||
auto runtime = get_header(req.headers, "x-tool-runtime");
|
||||
if (!runtime.empty()) {
|
||||
params["runtime"] = runtime;
|
||||
} else if (docker_runtime) {
|
||||
params["runtime"] = SERVER_TOOL_RUNTIME_DOCKER_CONTAINER + docker_runtime->get_container_id();
|
||||
}
|
||||
|
||||
server_tool & tool = find_tool(tools, tool_name, stream);
|
||||
|
||||
if (stream) {
|
||||
|
||||
@@ -30,6 +30,8 @@ struct server_tool {
|
||||
json to_json() const;
|
||||
};
|
||||
|
||||
struct server_tools_docker_runtime; // impl detail, defined in server-tools.cpp
|
||||
|
||||
struct server_tools {
|
||||
std::vector<std::unique_ptr<server_tool>> tools;
|
||||
|
||||
@@ -37,9 +39,16 @@ struct server_tools {
|
||||
server_response queue_res;
|
||||
std::atomic<int> res_id{0};
|
||||
|
||||
// set when --tools-runtime is configured; owns the docker container used to run tools, if any
|
||||
std::unique_ptr<server_tools_docker_runtime> docker_runtime;
|
||||
|
||||
void setup(const std::vector<std::string> & enabled_tools,
|
||||
server_mcp & mcp_mgr);
|
||||
server_mcp & mcp_mgr,
|
||||
const std::string & tools_runtime);
|
||||
|
||||
server_http_context::handler_t handle_get;
|
||||
server_http_context::handler_t handle_post;
|
||||
|
||||
server_tools();
|
||||
~server_tools();
|
||||
};
|
||||
|
||||
@@ -338,7 +338,7 @@ int llama_server(common_params & params, int argc, char ** argv) {
|
||||
|
||||
if (!params.server_tools.empty() || !mcp_mgr.empty()) {
|
||||
try {
|
||||
tools.setup(params.server_tools, mcp_mgr);
|
||||
tools.setup(params.server_tools, mcp_mgr, params.server_tools_runtime);
|
||||
} catch (const std::exception & e) {
|
||||
SRV_ERR("tools setup failed: %s\n", e.what());
|
||||
return 1;
|
||||
@@ -348,6 +348,9 @@ int llama_server(common_params & params, int argc, char ** argv) {
|
||||
if (!params.server_tools.empty()) {
|
||||
warn_names.push_back("built-in tools (experimental)");
|
||||
}
|
||||
if (!params.server_tools_runtime.empty()) {
|
||||
warn_names.push_back("tools runtime (experimental)");
|
||||
}
|
||||
if (!mcp_mgr.empty()) {
|
||||
warn_names.push_back("MCP servers (experimental)");
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ def stop_server_after_each_test():
|
||||
server.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def do_something():
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def load_server_presets():
|
||||
# this will be run once per test session, before any tests
|
||||
ServerPreset.load_all()
|
||||
|
||||
@@ -14,10 +14,10 @@ fi
|
||||
if [ $# -lt 1 ]
|
||||
then
|
||||
if [[ "${SLOW_TESTS:-0}" == 1 ]]; then
|
||||
pytest -v -x
|
||||
pytest --durations=30 -v -x
|
||||
else
|
||||
pytest -v -x -m "not slow"
|
||||
pytest --durations=30 -v -x -m "not slow"
|
||||
fi
|
||||
else
|
||||
pytest "$@"
|
||||
pytest --durations=30 "$@"
|
||||
fi
|
||||
|
||||
@@ -85,7 +85,7 @@ def _wait_for_model_status(model_id: str, desired: set[str], timeout: int = 60)
|
||||
last_status = _get_model_status(model_id)
|
||||
if last_status in desired:
|
||||
return last_status
|
||||
time.sleep(1)
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(
|
||||
f"Timed out waiting for {model_id} to reach {desired}, last status: {last_status}"
|
||||
)
|
||||
@@ -145,6 +145,156 @@ def test_router_models_max_evicts_lru():
|
||||
assert _get_model_status(first) == "unloaded"
|
||||
|
||||
|
||||
# server_lru_sched tests (relying on LLAMA_SERVER_DEBUG_FAKE_TIMING)
|
||||
|
||||
MODEL_A = "ggml-org/tinygemma3-GGUF:Q8_0"
|
||||
MODEL_B = "ggml-org/test-model-stories260K:F32"
|
||||
MODEL_C = "ggml-org/test-model-stories260K-infill:F32"
|
||||
|
||||
|
||||
def _tokenize(model_id: str, timeout: float | None = DEFAULT_REQUEST_TIMEOUT) -> ServerResponse:
|
||||
return server.make_request(
|
||||
"POST", "/tokenize", data={"model": model_id, "content": "hello world"}, timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
class _Bg:
|
||||
"""runs one request in a thread, keeps its result, error and finish time"""
|
||||
|
||||
def __init__(self, fn):
|
||||
self.result = None
|
||||
self.error: Exception | None = None
|
||||
self.done_at: float = 0.0
|
||||
self._thread = threading.Thread(target=self._run, args=(fn,), daemon=True)
|
||||
|
||||
def _run(self, fn):
|
||||
try:
|
||||
self.result = fn()
|
||||
except Exception as e:
|
||||
self.error = e
|
||||
self.done_at = time.time()
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def join(self, timeout: int = 180):
|
||||
self._thread.join(timeout)
|
||||
assert not self._thread.is_alive(), "background request did not finish in time"
|
||||
return self
|
||||
|
||||
def assert_ok(self, what: str):
|
||||
assert self.error is None, f"{what} raised {self.error!r}"
|
||||
assert self.result is not None and self.result.status_code == 200, \
|
||||
f"{what} failed: {self.result.status_code if self.result else None} {self.result.body if self.result else None}"
|
||||
|
||||
|
||||
def test_router_queue_does_not_evict_busy_model():
|
||||
"""a request that finds no free slot waits, and the model serving a request survives it"""
|
||||
global server
|
||||
server.models_max = 1
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5) # let the request reach the child and take the only slot
|
||||
|
||||
# no slot free and MODEL_A is busy, so this queues instead of evicting mid-request
|
||||
queued = _Bg(lambda: _tokenize(MODEL_B)).start()
|
||||
|
||||
busy.join()
|
||||
queued.join()
|
||||
|
||||
# had MODEL_A been evicted while serving, its own request would have died
|
||||
busy.assert_ok("request against the busy model")
|
||||
queued.assert_ok("queued request")
|
||||
|
||||
_wait_for_model_status(MODEL_B, {"loaded"}, timeout=120)
|
||||
assert _get_model_status(MODEL_A) == "unloaded"
|
||||
|
||||
|
||||
def test_router_queue_coalesces_requests_for_same_model():
|
||||
"""many requests for one missing model share a slot, so only one model is given up"""
|
||||
global server
|
||||
server.models_max = 2
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
_load_model_and_wait(MODEL_B, timeout=120)
|
||||
|
||||
# keep MODEL_A busy so MODEL_B is the only model that can be given up
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5)
|
||||
|
||||
waiters = [_Bg(lambda: _tokenize(MODEL_C)).start() for _ in range(3)]
|
||||
|
||||
busy.join()
|
||||
for w in waiters:
|
||||
w.join()
|
||||
|
||||
busy.assert_ok("request against the busy model")
|
||||
for i, w in enumerate(waiters):
|
||||
w.assert_ok(f"queued request {i}")
|
||||
|
||||
_wait_for_model_status(MODEL_C, {"loaded"}, timeout=120)
|
||||
# one entry for 3 requests means one eviction: MODEL_B goes, MODEL_A is left alone.
|
||||
# without coalescing the leftover entries still ask for a slot,
|
||||
# and MODEL_A is taken too as soon as it goes idle
|
||||
assert _get_model_status(MODEL_A) == "loaded"
|
||||
assert _get_model_status(MODEL_B) == "unloaded"
|
||||
|
||||
|
||||
def test_router_queue_client_disconnect_keeps_model():
|
||||
"""a client that leaves while queued must not cost a running model its slot"""
|
||||
global server
|
||||
server.models_max = 1
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5)
|
||||
|
||||
# queues behind MODEL_A, then gives up long before MODEL_A goes idle
|
||||
with pytest.raises(requests.exceptions.RequestException):
|
||||
_tokenize(MODEL_B, timeout=1)
|
||||
|
||||
busy.join()
|
||||
busy.assert_ok("request against the busy model")
|
||||
|
||||
# nobody is waiting anymore, so MODEL_A keeps its slot
|
||||
time.sleep(3)
|
||||
assert _get_model_status(MODEL_A) == "loaded"
|
||||
assert _get_model_status(MODEL_B) == "unloaded"
|
||||
|
||||
|
||||
def test_router_queue_is_fifo():
|
||||
"""the queue is served in arrival order"""
|
||||
global server
|
||||
server.models_max = 1
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5)
|
||||
|
||||
first = _Bg(lambda: _tokenize(MODEL_B)).start()
|
||||
time.sleep(1) # keep the arrival order unambiguous
|
||||
second = _Bg(lambda: _tokenize(MODEL_C)).start()
|
||||
|
||||
busy.join()
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
busy.assert_ok("request against the busy model")
|
||||
first.assert_ok("first queued request")
|
||||
second.assert_ok("second queued request")
|
||||
|
||||
assert first.done_at < second.done_at, "queue was not served in arrival order"
|
||||
|
||||
|
||||
def test_router_no_models_autoload():
|
||||
global server
|
||||
server.no_models_autoload = True
|
||||
@@ -310,7 +460,7 @@ def _wait_for_sse_event(collected: list, event_type: str, model: str, timeout: i
|
||||
while time.time() < deadline:
|
||||
if any(e.get("event") == event_type and e.get("model") == model for e in collected):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from utils import *
|
||||
@@ -146,6 +148,95 @@ def test_tools_builtin_cwd_header():
|
||||
os.remove(marker_path)
|
||||
|
||||
|
||||
def _docker_unavailable_reason() -> str | None:
|
||||
"""None if docker can be used to run a container, otherwise the reason it can't."""
|
||||
docker_bin = shutil.which("docker")
|
||||
if docker_bin is None:
|
||||
return "docker is not installed"
|
||||
try:
|
||||
subprocess.run([docker_bin, "info"], capture_output=True, timeout=5, check=True)
|
||||
except Exception as e:
|
||||
return f"docker daemon is not usable: {e}"
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docker_container():
|
||||
reason = _docker_unavailable_reason()
|
||||
if reason is not None:
|
||||
pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type]
|
||||
|
||||
proc = subprocess.run(
|
||||
["docker", "run", "-d", "--rm", "busybox", "sleep", "300"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
pytest.skip(f"failed to start docker container: {proc.stderr.strip()}") # ty: ignore[too-many-positional-arguments, invalid-argument-type]
|
||||
|
||||
container_id = proc.stdout.strip()
|
||||
try:
|
||||
yield container_id
|
||||
finally:
|
||||
subprocess.run(["docker", "rm", "-f", container_id], capture_output=True)
|
||||
|
||||
|
||||
def test_tools_builtin_runtime_header(docker_container: str):
|
||||
global server
|
||||
server.start()
|
||||
|
||||
headers = {"x-tool-runtime": f"docker-container:{docker_container}", "x-tool-cwd": "/tmp"}
|
||||
|
||||
write_res = call_tool("write_file", {"path": "test.log", "content": "hello docker\n"}, headers=headers)
|
||||
assert write_res["result"] == "file written successfully"
|
||||
|
||||
read_res = call_tool("read_file", {"path": "test.log"}, headers=headers)
|
||||
assert read_res["plain_text_response"] == "hello docker\n"
|
||||
|
||||
exec_res = call_tool("exec_shell_command", {"command": "cat test.log"}, headers=headers)
|
||||
assert "hello docker" in exec_res["plain_text_response"]
|
||||
|
||||
|
||||
def test_tools_builtin_runtime_header_unknown_scheme():
|
||||
global server
|
||||
server.start()
|
||||
|
||||
# an unknown runtime must fail, never silently fall back to running on the host
|
||||
res = server.make_request("POST", "/tools",
|
||||
data={"tool": "exec_shell_command", "params": {"command": "echo hi"}},
|
||||
headers={"x-tool-runtime": "ssh:example.com"})
|
||||
assert res.status_code == 500, res.body
|
||||
assert "unknown tool runtime" in str(res.body)
|
||||
|
||||
|
||||
def test_tools_builtin_docker_runtime_cleans_up_spawned_container():
|
||||
reason = _docker_unavailable_reason()
|
||||
if reason is not None:
|
||||
pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type]
|
||||
|
||||
global server
|
||||
server.server_tools_runtime = "docker:busybox"
|
||||
server.start()
|
||||
|
||||
# exec_shell_command runs inside the container spawned for --tools-runtime; docker sets
|
||||
# the container's hostname to its own short id, so this also tells us which one to check
|
||||
res = call_tool("exec_shell_command", {"command": "hostname"})
|
||||
container_id = res["plain_text_response"].splitlines()[0].strip()
|
||||
assert len(container_id) >= 8, res
|
||||
|
||||
running = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Running}}", container_id],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert running.returncode == 0 and running.stdout.strip() == "true", running.stderr
|
||||
|
||||
server.stop()
|
||||
|
||||
# a clean server shutdown must stop and remove the container it spawned (it runs with --rm),
|
||||
# not leave it behind as an abandoned child
|
||||
leftover = subprocess.run(["docker", "inspect", container_id], capture_output=True, text=True)
|
||||
assert leftover.returncode != 0, f"container {container_id} was not cleaned up after server exit"
|
||||
|
||||
|
||||
def test_tools_builtin_edit_file_rejects_overlapping_edits():
|
||||
global server
|
||||
server.start()
|
||||
|
||||
@@ -115,6 +115,7 @@ class ServerProcess:
|
||||
backend_sampling: bool = False
|
||||
gcp_compat: bool = False
|
||||
server_tools: str | None = None
|
||||
server_tools_runtime: str | None = None
|
||||
mcp_servers_config: str | None = None
|
||||
mcp_servers_json: str | None = None
|
||||
cors_origins: str | None = None
|
||||
@@ -132,7 +133,10 @@ class ServerProcess:
|
||||
self.external_server = "DEBUG_EXTERNAL" in os.environ
|
||||
|
||||
def start(self, timeout_seconds: int = DEFAULT_HTTP_TIMEOUT) -> None:
|
||||
env = {**os.environ}
|
||||
env = {
|
||||
**os.environ,
|
||||
"LLAMA_SERVER_DEBUG_FAKE_TIMING": "1",
|
||||
}
|
||||
if "LLAMA_CACHE" not in os.environ:
|
||||
env["LLAMA_CACHE"] = "tmp"
|
||||
if self.external_server:
|
||||
@@ -267,6 +271,8 @@ class ServerProcess:
|
||||
server_args.append("--ui-mcp-proxy")
|
||||
if self.server_tools:
|
||||
server_args.extend(["--tools", self.server_tools])
|
||||
if self.server_tools_runtime:
|
||||
server_args.extend(["--tools-runtime", self.server_tools_runtime])
|
||||
if self.mcp_servers_config:
|
||||
server_args.extend(["--mcp-servers-config", self.mcp_servers_config])
|
||||
if self.mcp_servers_json:
|
||||
@@ -303,6 +309,7 @@ class ServerProcess:
|
||||
|
||||
# wait for server to start
|
||||
start_time = time.time()
|
||||
last_print_time = start_time
|
||||
while time.time() - start_time < timeout_seconds:
|
||||
try:
|
||||
response = self.make_request("GET", "/health", headers={
|
||||
@@ -317,8 +324,10 @@ class ServerProcess:
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError(f"Server process died with return code {self.process.returncode}")
|
||||
|
||||
print(f"Waiting for server to start...")
|
||||
time.sleep(0.5)
|
||||
if time.time() - last_print_time >= 1.0:
|
||||
print(f"Waiting for server to start...")
|
||||
last_print_time = time.time()
|
||||
time.sleep(0.01)
|
||||
raise TimeoutError(f"Server did not start within {timeout_seconds} seconds")
|
||||
|
||||
def stop(self) -> None:
|
||||
|
||||
+5
-2
@@ -179,17 +179,20 @@ int main(int argc, char ** argv) {
|
||||
const char * data = nullptr;
|
||||
size_t data_len = 0;
|
||||
int64_t n_samples = 0;
|
||||
const int64_t t_wav_start_us = ggml_time_us();
|
||||
if (gen.get_output(&sample_rate, &data, &data_len, &n_samples) != 0) {
|
||||
LOG_ERR("get_output failed\n");
|
||||
return 1;
|
||||
}
|
||||
const double t_wav_s = (ggml_time_us() - t_wav_start_us) / 1e6;
|
||||
|
||||
LOG_INF("generated %d frames, %zu bytes of WAV audio (%d Hz)\n", n_frames, data_len, sample_rate);
|
||||
|
||||
const double t_prompt_s = (t_gen_start_us - t_prompt_start_us) / 1e6;
|
||||
const double t_total_s = t_prompt_s + t_gen_s;
|
||||
const double t_total_s = t_prompt_s + t_gen_s + t_wav_s;
|
||||
const double audio_s = sample_rate > 0 ? (double) n_samples / sample_rate : 0.0;
|
||||
LOG_INF("timings: prompt eval %.2fs + generation %.2fs = total %.2fs\n", t_prompt_s, t_gen_s, t_total_s);
|
||||
LOG_INF("timings: prompt eval %.2fs + generation %.2fs + vocoder %.2fs = total %.2fs\n",
|
||||
t_prompt_s, t_gen_s, t_wav_s, t_total_s);
|
||||
LOG_INF(" output audio = %.2fs (audio time = %.2fx process time)\n", audio_s, t_total_s > 0 ? audio_s / t_total_s : 0.0);
|
||||
FILE * f = fopen(params.out_file.c_str(), "wb");
|
||||
if (!f) {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
engine-strict=true
|
||||
ignore-scripts=true
|
||||
min-release-age=7
|
||||
|
||||
Vendored
+2
-4
@@ -143,10 +143,8 @@ declare global {
|
||||
idxThemeStyle?: number;
|
||||
idxCodeBlock?: number;
|
||||
|
||||
// File System Access API - missing from older DOM lib versions.
|
||||
// Used by ChatFormWorkingDirectory's native folder picker. Feature availability
|
||||
// is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
|
||||
showDirectoryPicker: (options?: {
|
||||
// File System Access API - not in the DOM lib and unavailable in some browsers
|
||||
showDirectoryPicker?: (options?: {
|
||||
id?: string;
|
||||
mode?: 'read' | 'readwrite';
|
||||
startIn?: FileSystemHandle | string;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
ChatAttachmentsList,
|
||||
ChatFormActions,
|
||||
ChatFormContenteditable,
|
||||
ChatFormFileInputInvisible,
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
@@ -14,9 +15,7 @@
|
||||
INPUT_CLASSES,
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
INITIAL_FILE_SIZE,
|
||||
PROMPT_CONTENT_SEPARATOR,
|
||||
PROMPT_TRIGGER_PREFIX,
|
||||
RESOURCE_TRIGGER_PREFIX
|
||||
PROMPT_CONTENT_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
ContentPartType,
|
||||
@@ -39,8 +38,25 @@
|
||||
activeConversation,
|
||||
pendingCwd
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
|
||||
import type {
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo,
|
||||
MCPResourceInfo,
|
||||
PromptMessage
|
||||
} from '$lib/types';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
containsCodeSpan,
|
||||
containsFileMentionLink,
|
||||
findCommandToken,
|
||||
findMentionToken,
|
||||
isIMEComposing,
|
||||
isOffsetInCodeBlock,
|
||||
parseClipboardContent,
|
||||
uuid
|
||||
} from '$lib/utils';
|
||||
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
@@ -97,29 +113,67 @@
|
||||
}: Props = $props();
|
||||
|
||||
// Component References
|
||||
// Shared handle of the two input renderers (textarea + contenteditable).
|
||||
type ChatInputHandle = {
|
||||
focus(): void;
|
||||
resetHeight(): void;
|
||||
getElement(): HTMLElement | undefined;
|
||||
getCaretOffset(): number;
|
||||
setCaretOffset(offset: number): void;
|
||||
};
|
||||
|
||||
let audioRecorder: AudioRecorder | undefined;
|
||||
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
|
||||
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
|
||||
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
|
||||
$state(undefined);
|
||||
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
|
||||
let inputRef: ChatInputHandle | undefined = $state(undefined);
|
||||
|
||||
// Render-mode gate: the plain textarea by default, the contenteditable
|
||||
// while the buffer carries a `file://` mention link or a complete code
|
||||
// span (badges and code chips need a DOM the textarea cannot provide).
|
||||
// Demotes back once neither remains.
|
||||
let useContenteditable = $state(false);
|
||||
|
||||
// Audio Recording State
|
||||
let isRecording = $state(false);
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
// Picker State
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
let isInlineResourcePickerOpen = $state(false);
|
||||
let resourceSearchQuery = $state('');
|
||||
// Invisible anchor at the form's top edge so the mention/WD popovers
|
||||
// float above the box.
|
||||
let mentionAnchor: HTMLDivElement | null = $state(null);
|
||||
|
||||
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
|
||||
|
||||
async function handleWorkingDirectoryChange(value: string | null) {
|
||||
await conversationsStore.setCwd(value);
|
||||
const pickers = useChatFormPickers({
|
||||
getValue: () => value,
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
onValueChange?.(v);
|
||||
},
|
||||
getCaretOffset: () => inputRef?.getCaretOffset(),
|
||||
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
|
||||
focusInput: refocusInput,
|
||||
getShowModelSelector: () => showModelSelector,
|
||||
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
|
||||
hasBuiltinTools: () => toolsStore.builtinTools.length > 0,
|
||||
getCwd: () => cwd,
|
||||
getServerHome: () => toolsStore.serverHome ?? null,
|
||||
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
|
||||
getPickersRef: () => pickersRef
|
||||
});
|
||||
|
||||
async function handleWorkingDirectoryChange(newDir: string | null) {
|
||||
// Committing a directory consumes the `/cwd` token; the chip's
|
||||
// clear-X path has no token to consume.
|
||||
const token = findCommandToken(value);
|
||||
if (token && token.name === 'cwd') {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
await conversationsStore.setCwd(newDir);
|
||||
if (conversationsStore.activeConversation) {
|
||||
await chatStore.recordCwdChange(value?.trim() || null);
|
||||
await chatStore.recordCwdChange(newDir?.trim() || null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,23 +220,45 @@
|
||||
);
|
||||
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
|
||||
|
||||
// Caret offset restored after a renderer swap. Callers that mutate
|
||||
// `value` themselves (e.g. the mention picker) pin the target offset
|
||||
// BEFORE the assignment; otherwise the swap effect snapshots the
|
||||
// current caret.
|
||||
let pendingCaretOffset = 0;
|
||||
let caretOffsetPinned = false;
|
||||
|
||||
function queueCaretRestore() {
|
||||
queueMicrotask(() => {
|
||||
inputRef?.focus();
|
||||
inputRef?.setCaretOffset(pendingCaretOffset);
|
||||
caretOffsetPinned = false;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const wantContenteditable =
|
||||
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
|
||||
if (useContenteditable === wantContenteditable) return;
|
||||
|
||||
if (!caretOffsetPinned) {
|
||||
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
|
||||
}
|
||||
|
||||
useContenteditable = wantContenteditable;
|
||||
queueCaretRestore();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
recordingSupported = isAudioRecordingSupported();
|
||||
audioRecorder = new AudioRecorder();
|
||||
});
|
||||
|
||||
// Defer so the closing popover's focus scope tears down first - bits-ui
|
||||
// yanks a synchronous focus() back into the still-mounted popover.
|
||||
function refocusInput() {
|
||||
queueMicrotask(() => textareaRef?.focus());
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
textareaRef?.focus();
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
export function resetTextareaHeight() {
|
||||
textareaRef?.resetHeight();
|
||||
inputRef?.resetHeight();
|
||||
}
|
||||
|
||||
export function openModelSelector() {
|
||||
@@ -216,46 +292,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = value.slice(1);
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
} else if (
|
||||
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
|
||||
hasServers &&
|
||||
mcpStore.hasResourcesCapability(perChatOverrides)
|
||||
) {
|
||||
isInlineResourcePickerOpen = true;
|
||||
resourceSearchQuery = value.slice(1);
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
} else {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (pickersRef?.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
// Pickers consume navigation/escape keys first; when consumed, skip
|
||||
// the enter-to-submit logic below.
|
||||
if (pickers.handleKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,6 +303,15 @@
|
||||
const isModifier = event.ctrlKey || event.metaKey;
|
||||
const sendOnEnter = currentConfig.sendOnEnter !== false;
|
||||
|
||||
// Caret inside a fenced code block (closed, or still open
|
||||
// while being typed): Enter adds a line, never submits. The
|
||||
// contenteditable consumes this case locally; this gate
|
||||
// covers the plain textarea, where skipping submit lets the
|
||||
// native newline through.
|
||||
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendOnEnter || isModifier) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -332,7 +381,7 @@
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
textareaRef?.focus();
|
||||
inputRef?.focus();
|
||||
}, 10);
|
||||
|
||||
return;
|
||||
@@ -359,13 +408,7 @@
|
||||
promptInfo: MCPPromptInfo,
|
||||
args?: Record<string, string>
|
||||
) {
|
||||
// Only clear the value if the prompt was triggered by typing '/'
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
pickers.closePromptPicker();
|
||||
|
||||
const promptName = promptInfo.title || promptInfo.name;
|
||||
const placeholder: ChatUploadedFile = {
|
||||
@@ -384,7 +427,7 @@
|
||||
|
||||
uploadedFiles = [...uploadedFiles, placeholder];
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
textareaRef?.focus();
|
||||
inputRef?.focus();
|
||||
}
|
||||
|
||||
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
|
||||
@@ -426,39 +469,36 @@
|
||||
onUploadedFilesChange?.(uploadedFiles);
|
||||
}
|
||||
|
||||
function handlePromptPickerClose() {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
// Deferred so the closing popover's focus scope tears down first -
|
||||
// bits-ui yanks a synchronous focus() back into the still-mounted popover.
|
||||
function refocusInput() {
|
||||
queueMicrotask(() => inputRef?.focus());
|
||||
}
|
||||
|
||||
function handleInlineResourcePickerClose() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
// Splice the mention link in place of the `@<query>` token. Uses the
|
||||
// live cursor, not a stale snapshot - the token may have been edited.
|
||||
function handleMentionSelect(entry: FileMentionEntry) {
|
||||
const cursor = inputRef?.getCaretOffset() ?? value.length;
|
||||
const token = findMentionToken(value, cursor);
|
||||
if (!token) return;
|
||||
|
||||
function handleInlineResourceSelect() {
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
const built = buildMentionInsertion(entry, value, token);
|
||||
if (!built) return;
|
||||
|
||||
// Pin the post-insertion caret BEFORE the swap effect runs;
|
||||
// otherwise the effect clobbers it with the textarea's selection
|
||||
// at promotion time (browser-dependent: usually reset to 0).
|
||||
pendingCaretOffset = built.caretOffset;
|
||||
caretOffsetPinned = true;
|
||||
|
||||
value = built.newValue;
|
||||
onValueChange?.(built.newValue);
|
||||
|
||||
// Already in contenteditable mode: no renderer flip, so the swap
|
||||
// effect's caret restore never runs.
|
||||
if (useContenteditable) {
|
||||
queueCaretRestore();
|
||||
}
|
||||
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handleBrowseResources() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
|
||||
isResourceDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleMicClick() {
|
||||
@@ -503,19 +543,32 @@
|
||||
>
|
||||
<ChatFormPickers
|
||||
bind:this={pickersRef}
|
||||
{isPromptPickerOpen}
|
||||
{promptSearchQuery}
|
||||
{isInlineResourcePickerOpen}
|
||||
{resourceSearchQuery}
|
||||
onPromptPickerClose={handlePromptPickerClose}
|
||||
onInlineResourcePickerClose={handleInlineResourcePickerClose}
|
||||
onInlineResourceSelect={handleInlineResourceSelect}
|
||||
isCommandPickerOpen={pickers.isCommandPickerOpen}
|
||||
commandQuery={pickers.commandQuery}
|
||||
commands={pickers.availableCommands}
|
||||
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()}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
onInlineResourceBrowse={handleBrowseResources}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={mentionAnchor}
|
||||
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
@@ -534,20 +587,36 @@
|
||||
|
||||
<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!"
|
||||
onpaste={handlePaste}
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={textareaRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
{#if useContenteditable}
|
||||
<ChatFormContenteditable
|
||||
class="px-5 py-1.5 md:pt-0 mb-0.5"
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
{:else}
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
pickers.handleInput();
|
||||
onValueChange?.(value);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
{disabled}
|
||||
{placeholder}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mcpHasResourceAttachments()}
|
||||
<ChatFormMcpResourcesList
|
||||
@@ -574,7 +643,7 @@
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
|
||||
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -585,8 +654,12 @@
|
||||
{#if toolsStore.builtinTools.length > 0}
|
||||
<ChatFormWorkingDirectory
|
||||
directory={cwd}
|
||||
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
||||
bind:query={pickers.workingDirectoryQuery}
|
||||
customAnchor={mentionAnchor}
|
||||
onChange={handleWorkingDirectoryChange}
|
||||
onClose={refocusInput}
|
||||
onClose={pickers.handleWorkingDirectoryClose}
|
||||
onOpen={pickers.handleWorkingDirectoryOpen}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||
import { ColorMode } from '$lib/enums';
|
||||
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
|
||||
import {
|
||||
badgeAwareWordJump,
|
||||
buildFragment,
|
||||
domMatchesTokens,
|
||||
highlightCode,
|
||||
isIMEComposing,
|
||||
isOffsetInCodeBlock,
|
||||
leadingBadgeEdgeOffset,
|
||||
rangeToTextOffset,
|
||||
serializeContent,
|
||||
SourceHistory,
|
||||
stripBlockBoundaryLineBreaks,
|
||||
syncCodeBlockHatches,
|
||||
tokenizeContent,
|
||||
textOffsetToRange
|
||||
} from '$lib/utils';
|
||||
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
onInput?: () => void;
|
||||
onKeydown?: (event: KeyboardEvent) => void;
|
||||
onPaste?: (event: ClipboardEvent) => void;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
onInput,
|
||||
onKeydown,
|
||||
onPaste,
|
||||
placeholder = 'Ask anything...',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
let rootElement: HTMLDivElement | undefined = $state();
|
||||
let lastEmittedValue = '';
|
||||
let isComposing = $state(false);
|
||||
|
||||
// Undo/redo in source space: the imperative token rebuilds destroy the
|
||||
// browser's native undo stack.
|
||||
const history = new SourceHistory();
|
||||
|
||||
// Browsers disagree on what an empty contenteditable contains (`<br>`,
|
||||
// `<div><br></div>`, or nothing), so emptiness is decided by the
|
||||
// serialized source, not the DOM shape.
|
||||
function syncEmptyState(serialized?: string) {
|
||||
if (!rootElement) return;
|
||||
const source = serialized ?? serializeContent(rootElement);
|
||||
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
|
||||
}
|
||||
|
||||
function renderTokens(tokens: ContentToken[]) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
|
||||
rootElement.replaceChildren(buildFragment(tokens));
|
||||
|
||||
syncCodeBlockHatches(rootElement);
|
||||
highlightCodeBlocks(rootElement);
|
||||
|
||||
restoreCaret(caret);
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
}
|
||||
|
||||
// Last highlighted source segment per block element - typing inside
|
||||
// a block re-highlights only when the segment actually changed.
|
||||
const highlightedSegments = new WeakMap<HTMLElement, string>();
|
||||
|
||||
const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
|
||||
|
||||
/**
|
||||
* Apply syntax highlighting to a code block element's CONTENT. The
|
||||
* fence lines stay plain text, and the blank padding that
|
||||
* `highlightCode` trims is re-added as plain text, so the element's
|
||||
* textContent stays byte-exact with the source segment. Replaces
|
||||
* the element's children - callers restore the caret afterwards.
|
||||
* Returns false when nothing changed.
|
||||
*/
|
||||
function highlightCodeBlockElement(el: HTMLElement): boolean {
|
||||
const segment = el.textContent ?? '';
|
||||
if (highlightedSegments.get(el) === segment) return false;
|
||||
|
||||
const open = CODE_BLOCK_OPEN_RE.exec(segment);
|
||||
if (!open) return false;
|
||||
|
||||
const prefix = open[0];
|
||||
const language = open[1].trim().split(/\s+/)[0] ?? '';
|
||||
const content = segment.slice(prefix.length, -3);
|
||||
|
||||
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
|
||||
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
|
||||
const core = content.slice(leading.length, content.length - trailing.length);
|
||||
|
||||
// autoDetect off: re-guessing the language on every keystroke
|
||||
// costs ~38ms a call and flickers while typing
|
||||
const html = core ? highlightCode(core, language || 'text', false) : '';
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
|
||||
el.replaceChildren(
|
||||
document.createTextNode(prefix + leading),
|
||||
tpl.content.cloneNode(true),
|
||||
document.createTextNode(trailing + '```')
|
||||
);
|
||||
highlightedSegments.set(el, segment);
|
||||
return true;
|
||||
}
|
||||
|
||||
function highlightCodeBlocks(root: HTMLElement) {
|
||||
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
|
||||
highlightCodeBlockElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-highlight the code block the caret sits in after an edit.
|
||||
* Skipped when the block's segment is unchanged since its last
|
||||
* highlight, so edits outside blocks cost nothing.
|
||||
*/
|
||||
function rehighlightCaretCodeBlock() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range) return;
|
||||
|
||||
let node: Node | null = range.startContainer;
|
||||
if (node === rootElement) {
|
||||
node = rootElement.childNodes[range.startOffset - 1] ?? null;
|
||||
}
|
||||
|
||||
while (node && node !== rootElement) {
|
||||
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
|
||||
const caret = rangeToTextOffset(rootElement, range);
|
||||
if (highlightCodeBlockElement(node)) {
|
||||
restoreCaret(caret);
|
||||
}
|
||||
return;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the caret inside a fenced code block region? Source-level
|
||||
* (not DOM-level) so the still-OPEN fence counts too: while the
|
||||
* user is typing a block, no closing ``` exists yet and the
|
||||
* buffer is plain text with no block element to find. Root-level
|
||||
* caret positions right at a closed block's edge (escape
|
||||
* hatches, element boundaries restored by `textOffsetToRange`)
|
||||
* resolve past the closing fence, so they count as OUTSIDE.
|
||||
*/
|
||||
function caretInCodeBlock(): boolean {
|
||||
if (!rootElement) return false;
|
||||
|
||||
return isOffsetInCodeBlock(
|
||||
serializeContent(rootElement),
|
||||
rangeToTextOffset(rootElement, safeRange())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* hljs theme for the highlighted code blocks. Mirrors
|
||||
* SyntaxHighlightedCode.svelte: one shared style element
|
||||
* (deduped via the data attribute) swapped on mode change.
|
||||
*/
|
||||
function loadHighlightTheme(isDark: boolean) {
|
||||
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('data-highlight-theme-preview', 'true');
|
||||
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadHighlightTheme(mode.current === ColorMode.DARK);
|
||||
});
|
||||
|
||||
function safeRange(): Range | null {
|
||||
if (!rootElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
function restoreCaret(offset: number, extend = false) {
|
||||
if (!rootElement) return;
|
||||
|
||||
const target = textOffsetToRange(rootElement, offset);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (extend && selection.anchorNode) {
|
||||
selection.setBaseAndExtent(
|
||||
selection.anchorNode,
|
||||
selection.anchorOffset,
|
||||
target.startContainer,
|
||||
target.startOffset
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(target);
|
||||
}
|
||||
|
||||
function resizeHeight() {
|
||||
if (!rootElement) return;
|
||||
rootElement.style.height = 'auto';
|
||||
rootElement.style.height = `${rootElement.scrollHeight}px`;
|
||||
}
|
||||
|
||||
function recordHistory(newGroup: boolean) {
|
||||
if (!rootElement) return;
|
||||
history.push(
|
||||
{ value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) },
|
||||
Date.now(),
|
||||
newGroup
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emit the current markdown source value to the parent, then
|
||||
* reconcile the DOM against the token stream: when a code span
|
||||
* was just completed or broken, the token boundaries no longer
|
||||
* match the element structure and the DOM is rebuilt (caret
|
||||
* preserved through the source-offset mapping).
|
||||
*/
|
||||
function processInput(inputType?: string) {
|
||||
if (isComposing || !rootElement) return;
|
||||
|
||||
syncEmptyState();
|
||||
resizeHeight();
|
||||
|
||||
// Shift+Enter right after a code block leaves an all-newline
|
||||
// text node (the fence's separator line plus Chromium's
|
||||
// artificial end-of-buffer line break). Strip both so the caret
|
||||
// lands on the line directly below the block.
|
||||
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
if (stripBlockBoundaryLineBreaks(rootElement)) {
|
||||
restoreCaret(caret);
|
||||
} else {
|
||||
const source = serializeContent(rootElement);
|
||||
let end = caret;
|
||||
|
||||
// the caret must end up after the inserted \n; some browsers
|
||||
// leave it before (stuck at the end of the old line). A
|
||||
// preceding \n means it already sits past the break
|
||||
// (Chromium's artificial trailing newline) - leave it.
|
||||
if (source[end] === '\n' && source[end - 1] !== '\n') {
|
||||
end += 1;
|
||||
restoreCaret(end);
|
||||
}
|
||||
|
||||
// a line break at the buffer end renders only with a second,
|
||||
// artificial trailing \n: a lone trailing \n is collapsed, so
|
||||
// the new line is invisible and the next typed character
|
||||
// consumes it. Append it when missing - unless the trailing
|
||||
// \n doubles as a block's separator line (source ends with
|
||||
// \n\n) or sits inside a block element.
|
||||
let last = rootElement.lastChild;
|
||||
while (last && last.nodeName === 'BR') last = last.previousSibling;
|
||||
if (
|
||||
end === source.length &&
|
||||
source.endsWith('\n') &&
|
||||
source[source.length - 2] !== '\n' &&
|
||||
last?.nodeType === Node.TEXT_NODE
|
||||
) {
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
|
||||
rootElement.appendChild(document.createTextNode('\n'));
|
||||
restoreCaret(source.length);
|
||||
resizeHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncCodeBlockHatches(rootElement);
|
||||
|
||||
const serialized = serializeContent(rootElement);
|
||||
syncEmptyState(serialized);
|
||||
if (serialized === lastEmittedValue) return;
|
||||
|
||||
// Plain typing/deletes coalesce per time window; structural edits
|
||||
// (paste, newline, cut, autocorrect) start a new undo group.
|
||||
recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
|
||||
|
||||
lastEmittedValue = serialized;
|
||||
value = serialized;
|
||||
|
||||
// Rebuild when token boundaries shifted (a code span was just
|
||||
// completed or broken) - the browser-owned text nodes cannot
|
||||
// restyle themselves across element boundaries.
|
||||
const tokens = tokenizeContent(serialized);
|
||||
if (!domMatchesTokens(rootElement, tokens)) {
|
||||
renderTokens(tokens);
|
||||
|
||||
// The rebuild can re-shape the DOM in a way that changes the
|
||||
// serialization (e.g. Chromium merged trailing text into the
|
||||
// block element and the rebuild splits it back out, which
|
||||
// synthesizes the separator newline) - keep value in sync.
|
||||
const reserialized = serializeContent(rootElement);
|
||||
if (reserialized !== serialized) {
|
||||
lastEmittedValue = reserialized;
|
||||
value = reserialized;
|
||||
}
|
||||
} else {
|
||||
rehighlightCaretCodeBlock();
|
||||
}
|
||||
|
||||
onInput?.();
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
processInput((event as InputEvent).inputType);
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing = true;
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing = false;
|
||||
processInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a line break at the caret MANUALLY. Native Shift+Enter at
|
||||
* the buffer end varies across browsers (a lone trailing \n that the
|
||||
* renderer collapses, or a <br> that the hatch sync strips), which
|
||||
* can leave the caret stuck on the old line; splitting the text node
|
||||
* ourselves keeps the DOM shape - and the caret - deterministic.
|
||||
* `processInput` then appends the artificial trailing \n when the
|
||||
* break lands at the buffer end.
|
||||
*/
|
||||
function insertLineBreak() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range) return;
|
||||
|
||||
if (!range.collapsed) {
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const nl = document.createTextNode('\n');
|
||||
|
||||
// a break at the very end of a code block exits the block (the
|
||||
// new line belongs below it, not inside)
|
||||
let exitBlock: HTMLElement | null = null;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
let node: Node | null = container.parentNode;
|
||||
while (node && node !== rootElement) {
|
||||
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
|
||||
const tail = document.createRange();
|
||||
tail.setStart(container, offset);
|
||||
tail.setEnd(node, node.childNodes.length);
|
||||
if (tail.toString().length === 0) exitBlock = node;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitBlock) {
|
||||
exitBlock.after(nl);
|
||||
} else if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container as Text;
|
||||
if (offset === 0) {
|
||||
text.before(nl);
|
||||
} else if (offset === text.length) {
|
||||
text.after(nl);
|
||||
} else {
|
||||
text.splitText(offset).before(nl);
|
||||
}
|
||||
} else {
|
||||
container.insertBefore(nl, container.childNodes[offset] ?? null);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const after = document.createRange();
|
||||
after.setStartAfter(nl);
|
||||
after.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(after);
|
||||
|
||||
processInput('insertLineBreak');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow escape to the line BEFORE a leading code block. Native
|
||||
* caret movement has no position above a buffer-starting block,
|
||||
* so a transient `<br>` hatch is created on demand: it gives the
|
||||
* caret a visible line, is consumed by the first character typed
|
||||
* on it, and is removed again when the caret leaves (see
|
||||
* handleSelectionChange). Returns true when the caret was moved.
|
||||
*/
|
||||
function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
|
||||
if (!rootElement) return false;
|
||||
|
||||
// a hatch already exists - native movement handles it
|
||||
if (rootElement.firstChild?.nodeName === 'BR') return false;
|
||||
|
||||
const first = rootElement.firstChild;
|
||||
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range || !range.collapsed) return false;
|
||||
|
||||
// the caret must sit inside the block: on its very first
|
||||
// character for ArrowLeft, anywhere on its first line for
|
||||
// ArrowUp
|
||||
if (!first.contains(range.startContainer)) return false;
|
||||
|
||||
const caret = rangeToTextOffset(rootElement, range);
|
||||
if (key === 'ArrowLeft') {
|
||||
if (caret !== 0) return false;
|
||||
} else {
|
||||
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
|
||||
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
|
||||
rootElement.prepend(document.createElement('br'));
|
||||
restoreCaret(0, extend);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the transient leading hatch once the caret leaves it.
|
||||
* The hatch only exists to give the caret a line above a leading
|
||||
* code block; with the caret anywhere else the empty line would
|
||||
* just be visual noise. Typing on the hatch line consumes it via
|
||||
* the stale-hatch removal in `syncCodeBlockHatches` instead (the
|
||||
* new text node takes its place before the block).
|
||||
*/
|
||||
function handleSelectionChange() {
|
||||
if (!rootElement) return;
|
||||
|
||||
const first = rootElement.firstChild;
|
||||
if (first?.nodeName !== 'BR') return;
|
||||
|
||||
const second = first.nextSibling;
|
||||
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
|
||||
|
||||
const range = safeRange();
|
||||
const onHatch =
|
||||
range !== null && range.startContainer === rootElement && range.startOffset === 0;
|
||||
if (!onHatch) {
|
||||
first.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo is replayed from source snapshots (the token rebuilds
|
||||
* destroy the native undo stack). Arrow keys around badges are
|
||||
* repaired locally: a badge is a non-editable island, so plain
|
||||
* ArrowLeft after a leading badge has no native previous position
|
||||
* and word jumps overshoot it by a word.
|
||||
*
|
||||
* Plain Enter inside a fenced code block (closed, or still open
|
||||
* while being typed) acts as Shift+Enter and adds a line instead of
|
||||
* submitting. ArrowLeft/ArrowUp at the edge of a leading code block
|
||||
* create the transient before-block hatch.
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const mod = event.ctrlKey || event.metaKey;
|
||||
if (mod && !event.altKey && !isComposing && rootElement) {
|
||||
const key = event.key.toLowerCase();
|
||||
const isUndo = key === 'z' && !event.shiftKey;
|
||||
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||
|
||||
if (isUndo || isRedo) {
|
||||
event.preventDefault();
|
||||
const current = {
|
||||
value: lastEmittedValue,
|
||||
caret: rangeToTextOffset(rootElement, safeRange())
|
||||
};
|
||||
const entry = isUndo ? history.undo(current) : history.redo(current);
|
||||
if (entry) applyHistoryEntry(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isIMEComposing(event) &&
|
||||
!disabled &&
|
||||
!caretInCodeBlock() &&
|
||||
safeRange()
|
||||
) {
|
||||
// Own the break outside code blocks: native end-of-buffer
|
||||
// behavior varies across browsers and can leave the caret
|
||||
// stuck on the old line (see insertLineBreak).
|
||||
event.preventDefault();
|
||||
insertLineBreak();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isIMEComposing(event) &&
|
||||
caretInCodeBlock()
|
||||
) {
|
||||
// The native plain-Enter path must never run: it splits the
|
||||
// buffer into `<div>` wrappers that `serializeContent` cannot
|
||||
// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
|
||||
// text node) and fires `input` synchronously, so the usual
|
||||
// re-tokenize/re-highlight follows.
|
||||
event.preventDefault();
|
||||
document.execCommand('insertLineBreak');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
rootElement &&
|
||||
(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
|
||||
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
|
||||
const isPlainLeft =
|
||||
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
|
||||
|
||||
if (isWordJump || isPlainLeft) {
|
||||
const source = serializeContent(rootElement);
|
||||
const caret = rangeToTextOffset(rootElement, safeRange());
|
||||
const target = isWordJump
|
||||
? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
|
||||
: leadingBadgeEdgeOffset(source, caret);
|
||||
|
||||
if (target !== null) {
|
||||
event.preventDefault();
|
||||
restoreCaret(target, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown?.(event);
|
||||
}
|
||||
|
||||
// lastEmittedValue is set before `value` so the sync effect treats the
|
||||
// change as our own and does not re-render.
|
||||
function applyHistoryEntry(entry: SourceHistoryEntry) {
|
||||
if (!rootElement) return;
|
||||
renderTokens(tokenizeContent(entry.value));
|
||||
lastEmittedValue = entry.value;
|
||||
value = entry.value;
|
||||
onInput?.();
|
||||
restoreCaret(entry.caret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text paste. preventDefault + manual insertText keeps the
|
||||
* browser from producing stray `<div>` wrappers mid-paste; insertText
|
||||
* fires `input` synchronously, so `processInput` re-tokenizes the
|
||||
* buffer and rebuilds when the pasted text carries badge or code
|
||||
* tokens.
|
||||
*/
|
||||
function handlePasteEvent(event: ClipboardEvent) {
|
||||
const pasted = event.clipboardData?.getData('text/plain');
|
||||
if (pasted && pasted.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Snap a collapsed caret through the offset mapping first: at
|
||||
// element-boundary carets (e.g. right before a badge) Chromium's
|
||||
// insertText can drop the preceding text node's trailing whitespace.
|
||||
const range = safeRange();
|
||||
if (rootElement && range && range.collapsed) {
|
||||
restoreCaret(rangeToTextOffset(rootElement, range));
|
||||
}
|
||||
|
||||
document.execCommand('insertText', false, pasted);
|
||||
}
|
||||
}
|
||||
|
||||
// The parent's paste handler runs first and preventDefaults when it
|
||||
// consumes the event (files, quoted prompts, long text).
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
onPaste?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
handlePasteEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// The selection as markdown SOURCE (each badge contributes its full
|
||||
// `[name](file://...)` link), so copy/cut carry raw markdown and
|
||||
// pasting back re-renders the badges. Null for collapsed/outside
|
||||
// selections - native clipboard behavior is fine there.
|
||||
function selectionSourceSlice(): { text: string; range: Range } | null {
|
||||
if (!rootElement) return null;
|
||||
|
||||
const range = safeRange();
|
||||
if (!range || range.collapsed) return null;
|
||||
|
||||
const startRange = range.cloneRange();
|
||||
startRange.collapse(true);
|
||||
|
||||
const source = serializeContent(rootElement);
|
||||
const start = rangeToTextOffset(rootElement, startRange);
|
||||
const end = rangeToTextOffset(rootElement, range);
|
||||
|
||||
return { text: source.slice(start, end), range };
|
||||
}
|
||||
|
||||
function handleCopy(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCut(event: ClipboardEvent) {
|
||||
const slice = selectionSourceSlice();
|
||||
if (!slice) return;
|
||||
|
||||
event.clipboardData?.setData('text/plain', slice.text);
|
||||
event.preventDefault();
|
||||
|
||||
// preventDefault suppresses the native deletion, so remove the
|
||||
// selection manually and re-emit.
|
||||
slice.range.deleteContents();
|
||||
processInput('deleteByCut');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// untrack: the DOM is managed manually from input events, so the
|
||||
// initial render must not subscribe to the value.
|
||||
renderTokens(tokenizeContent(untrack(() => value)));
|
||||
lastEmittedValue = untrack(() => value ?? '');
|
||||
resizeHeight();
|
||||
syncEmptyState();
|
||||
document.addEventListener('selectionchange', handleSelectionChange);
|
||||
if (!isMobile.current) {
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||
});
|
||||
|
||||
// External `value` updates. When incoming === lastEmittedValue the
|
||||
// change came from our own input, so leave the DOM alone - the
|
||||
// browser already owns the right shape.
|
||||
$effect(() => {
|
||||
const incoming = value ?? '';
|
||||
if (incoming === lastEmittedValue) return;
|
||||
|
||||
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
|
||||
renderTokens(tokenizeContent(incoming));
|
||||
lastEmittedValue = incoming;
|
||||
});
|
||||
|
||||
export function getElement() {
|
||||
return rootElement;
|
||||
}
|
||||
|
||||
export function getCaretOffset(): number {
|
||||
if (!rootElement) return 0;
|
||||
return rangeToTextOffset(rootElement, safeRange());
|
||||
}
|
||||
|
||||
// Focus first: `selection.addRange` requires it on some browsers.
|
||||
export function setCaretOffset(offset: number) {
|
||||
if (rootElement && rootElement !== document.activeElement) {
|
||||
rootElement.focus({ preventScroll: true });
|
||||
}
|
||||
restoreCaret(offset);
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
if (isMobile.current) return;
|
||||
rootElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
export function resetHeight() {
|
||||
if (rootElement) {
|
||||
rootElement.style.height = '';
|
||||
resizeHeight();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className}">
|
||||
<div
|
||||
bind:this={rootElement}
|
||||
contenteditable={!disabled}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled}
|
||||
aria-placeholder={placeholder}
|
||||
data-placeholder={placeholder}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
class={[
|
||||
'chat-form-contenteditable text-md min-h-12 w-full 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}
|
||||
oncompositionend={handleCompositionEnd}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onpaste={handlePaste}
|
||||
oncopy={handleCopy}
|
||||
oncut={handleCut}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* pre-wrap is load-bearing: without it Chromium collapses \n in
|
||||
text nodes and converts them to spaces while typing */
|
||||
.chat-form-contenteditable {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-form-contenteditable:global([data-empty='true'])::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inline code - mirrors markdown-content.css */
|
||||
.chat-form-contenteditable :global(code[data-code-token='inline']) {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
|
||||
.chat-form-contenteditable :global(code[data-code-token='block']) {
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--code-background);
|
||||
color: var(--code-foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, Sparkles } from '@lucide/svelte';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import { ChatFormCommandAction } from '$lib/enums';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
import {
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerPopover
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
/**
|
||||
* Slash-command picker; `query` (typed after `/`) filters the commands.
|
||||
* The parent owns the "dismissed token, don't act until it changes"
|
||||
* snapshot, so this picker just renders and reports selection.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
commands: ChatFormCommand[];
|
||||
onClose: () => void;
|
||||
onSelect: (command: ChatFormCommand) => void;
|
||||
}
|
||||
|
||||
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
|
||||
|
||||
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
|
||||
[ChatFormCommandAction.PROMPT]: Sparkles,
|
||||
[ChatFormCommandAction.CWD]: FolderOpen,
|
||||
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
|
||||
};
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
|
||||
|
||||
const filteredCommands = $derived(
|
||||
trimmedQuery
|
||||
? commands.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(trimmedQuery) ||
|
||||
c.description.toLowerCase().includes(trimmedQuery) ||
|
||||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
|
||||
)
|
||||
: commands
|
||||
);
|
||||
|
||||
function firstEnabledIndex(): number {
|
||||
return filteredCommands.findIndex((c) => !c.disabled);
|
||||
}
|
||||
|
||||
function stepEnabled(from: number, dir: number): number {
|
||||
const n = filteredCommands.length;
|
||||
if (n === 0) return -1;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const idx = (from + dir * i + n) % n;
|
||||
if (!filteredCommands[idx].disabled) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => filteredCommands.length,
|
||||
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(filteredCommands[index])
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
return;
|
||||
}
|
||||
if (filteredCommands[nav.hoveredIndex].disabled) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelect(command: ChatFormCommand) {
|
||||
if (command.disabled) return;
|
||||
onSelect(command);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open command picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredCommands}
|
||||
isLoading={false}
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { File, Folder } from '@lucide/svelte';
|
||||
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import type { FileMentionEntry } from '$lib/types';
|
||||
import {
|
||||
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
|
||||
HOME_TILDE,
|
||||
SEARCH_DEBOUNCE_MS
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Floating file/folder mention picker. The chat input is the search
|
||||
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
|
||||
* call scoped to `scopePath`. The parent owns the "dismissed token,
|
||||
* don't re-open until it changes" snapshot.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
customAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (entry: FileMentionEntry) => void;
|
||||
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
|
||||
onOpened?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen,
|
||||
query,
|
||||
customAnchor = null,
|
||||
scopePath = null,
|
||||
onClose,
|
||||
onSelect,
|
||||
onOpened
|
||||
}: Props = $props();
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => displayedItems.length,
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(displayedItems[index])
|
||||
});
|
||||
|
||||
// When the server does not expose file_glob_search (started without
|
||||
// --tools) or the user disabled it, the picker still opens but explains
|
||||
// why instead of firing searches that would only fail.
|
||||
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
|
||||
const fileSearchEnabled = $derived(
|
||||
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
|
||||
);
|
||||
|
||||
let searchResults = $state<FileMentionEntry[]>([]);
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
// Coerce the depth setting to a positive integer; an invalid value
|
||||
// would otherwise reach the server as max_depth 0 = unlimited.
|
||||
const searchDepth = $derived.by(() => {
|
||||
const n = Number(config().mentionSearchMaxDepth);
|
||||
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
|
||||
});
|
||||
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
|
||||
// A smaller window than the WD picker suffices: entries are ranked client-side.
|
||||
const MENTION_SEARCH_LIMIT = 50;
|
||||
|
||||
const search = useDebouncedSearch({
|
||||
debounceMs: SEARCH_DEBOUNCE_MS,
|
||||
canRun: () => isOpen && fileSearchEnabled,
|
||||
getQuery: () => trimmedQuery,
|
||||
run: async (query, signal, isCurrent) => {
|
||||
try {
|
||||
// A trailing path separator targets a directory, so also list its
|
||||
// children. Accept both `/` and `\`.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
query,
|
||||
scopePath ?? home ?? HOME_TILDE,
|
||||
searchDepth,
|
||||
MENTION_SEARCH_LIMIT,
|
||||
signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
if (res.error) {
|
||||
searchResults = [];
|
||||
searchError = res.error;
|
||||
return;
|
||||
}
|
||||
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
|
||||
path: e.path,
|
||||
name: e.name,
|
||||
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
|
||||
});
|
||||
searchResults = res.entries.map(toEntry);
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
searchResults = [];
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim());
|
||||
const displayedItems = $derived(searchResults);
|
||||
|
||||
const emptyMessage = $derived.by(() => {
|
||||
if (fileSearchKey === null) {
|
||||
return 'File search is unavailable on this server (started without --tools)';
|
||||
}
|
||||
if (!fileSearchEnabled) {
|
||||
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
|
||||
}
|
||||
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
|
||||
});
|
||||
|
||||
const showTooltip = $derived(!isMobile.current);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(0);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) onOpened?.();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = (query ?? '').trim();
|
||||
if (!isOpen || !q || !fileSearchEnabled) {
|
||||
search.cancel();
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
search.setLoading(true);
|
||||
search.run(q);
|
||||
});
|
||||
|
||||
function handleSelect(entry: FileMentionEntry) {
|
||||
onSelect(entry);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
// Always consume Enter while the picker is open - even with no
|
||||
// result yet (skeletons) or no matches - so the chat form's
|
||||
// Enter-to-submit never fires mid-search.
|
||||
if (isOpen && event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
|
||||
handleSelect(displayedItems[nav.hoveredIndex]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
|
||||
from closing the picker when the user clicks inside the textarea.
|
||||
We open programmatically via `open={isOpen}`, so it is inert
|
||||
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
|
||||
Positioning comes from `customAnchor` at the form's top edge. -->
|
||||
<Popover.Trigger
|
||||
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
|
||||
]}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={displayedItems}
|
||||
isLoading={search.isSearching}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
searchQuery={query ?? ''}
|
||||
{emptyMessage}
|
||||
itemKey={(entry) => entry.type + ':' + entry.path}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
>
|
||||
{#snippet item(entry, index, isSelected)}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleSelect(entry)}
|
||||
onmouseenter={() => nav.setHover(index)}
|
||||
>
|
||||
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
|
||||
<Icon
|
||||
class={[
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
entry.type === FileMentionEntryType.DIRECTORY
|
||||
? 'text-amber-500'
|
||||
: '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}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{entry.path}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{: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} />
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
+51
-22
@@ -2,6 +2,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -11,11 +12,19 @@
|
||||
searchQuery: string;
|
||||
showSearchInput: boolean;
|
||||
searchPlaceholder?: string;
|
||||
// Omit to distinguish "haven't searched yet" from "search returned nothing".
|
||||
emptyMessage?: string;
|
||||
autofocus?: boolean;
|
||||
inputRef?: HTMLInputElement | null;
|
||||
onSearchClose?: () => void;
|
||||
itemKey: (item: T, index: number) => string;
|
||||
item: Snippet<[T, number, boolean]>;
|
||||
skeleton?: Snippet;
|
||||
skeletonCount?: number;
|
||||
footer?: Snippet;
|
||||
// Counter bumped by the picker on keyboard nav; scrolls the selected
|
||||
// row into view without scrolling on hover or result replacement.
|
||||
scrollTrigger?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,49 +34,69 @@
|
||||
searchQuery = $bindable(),
|
||||
showSearchInput,
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No items available',
|
||||
emptyMessage,
|
||||
autofocus = false,
|
||||
inputRef = $bindable(null),
|
||||
onSearchClose,
|
||||
itemKey,
|
||||
item,
|
||||
skeleton,
|
||||
footer
|
||||
skeletonCount = 6,
|
||||
footer,
|
||||
scrollTrigger
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-picker-index="${selectedIndex}"]`
|
||||
) as HTMLElement;
|
||||
let listPaddingTop = $derived(
|
||||
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
|
||||
);
|
||||
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
// selectedIndex/items.length are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
|
||||
useScrollActiveRow({
|
||||
getTrigger: () => scrollTrigger,
|
||||
getContainer: () => listContainer,
|
||||
getIndex: () => selectedIndex,
|
||||
getCount: () => items.length,
|
||||
dataIndex: 'picker'
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea>
|
||||
{#if showSearchInput}
|
||||
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
|
||||
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
|
||||
<SearchInput
|
||||
{autofocus}
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchQuery}
|
||||
bind:ref={inputRef}
|
||||
onClose={onSearchClose}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
bind:this={listContainer}
|
||||
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
|
||||
>
|
||||
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
|
||||
{#if isLoading}
|
||||
{#if skeleton}
|
||||
{@render skeleton()}
|
||||
{:else}
|
||||
<div aria-busy="true" aria-live="polite" class="flex flex-col">
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if items && items.length === 0}
|
||||
{#if emptyMessage}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{:else if items.length === 0}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{:else}
|
||||
{#each items as itemData, index (itemKey(itemData, index))}
|
||||
{@render item(itemData, index, index === selectedIndex)}
|
||||
|
||||
+15
-2
@@ -3,21 +3,34 @@
|
||||
|
||||
interface Props {
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
onclick: () => void;
|
||||
onmouseenter?: () => void;
|
||||
dataIndex?: number;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
|
||||
let {
|
||||
class: className = '',
|
||||
isSelected = false,
|
||||
disabled = false,
|
||||
onclick,
|
||||
onmouseenter,
|
||||
dataIndex,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-picker-index={dataIndex}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
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}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
+1
@@ -42,6 +42,7 @@
|
||||
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()}
|
||||
>
|
||||
|
||||
+6
@@ -45,6 +45,9 @@
|
||||
let promptArgs = $state<Record<string, string>>({});
|
||||
let selectedIndex = $state(0);
|
||||
let internalSearchQuery = $state('');
|
||||
// Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
|
||||
// nav but not on hover or result changes.
|
||||
let scrollTrigger = $state(0);
|
||||
let promptError = $state<string | null>(null);
|
||||
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
|
||||
|
||||
@@ -295,6 +298,7 @@
|
||||
event.preventDefault();
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -304,6 +308,7 @@
|
||||
event.preventDefault();
|
||||
if (filteredPrompts.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -400,6 +405,7 @@
|
||||
searchPlaceholder="Search prompts..."
|
||||
emptyMessage="No MCP prompts available"
|
||||
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
|
||||
{scrollTrigger}
|
||||
>
|
||||
{#snippet item(prompt, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(prompt.serverName)}
|
||||
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
ChatFormPickerPopover,
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerItemHeader,
|
||||
ChatFormPickerListItemSkeleton
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
onClose?: () => void;
|
||||
onResourceSelect?: (resource: MCPResourceInfo) => void;
|
||||
onBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onResourceSelect,
|
||||
onBrowse
|
||||
}: Props = $props();
|
||||
|
||||
let resources = $state<MCPResourceInfo[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let internalSearchQuery = $state('');
|
||||
|
||||
let serverSettingsMap = $derived.by(() => {
|
||||
const servers = mcpStore.getServers();
|
||||
const map = new SvelteMap<string, MCPServerSettingsEntry>();
|
||||
|
||||
for (const server of servers) {
|
||||
map.set(server.id, server);
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
loadResources();
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
resources = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await mcpStore.fetchAllResources();
|
||||
resources = mcpResourceStore.getAllResourceInfos();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
|
||||
resources = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo) {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
|
||||
onResourceSelect?.(resource);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function isResourceAttached(uri: string): boolean {
|
||||
return mcpResourceStore.isAttached(uri);
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!isOpen) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
onClose?.();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredResources.length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
if (filteredResources[selectedIndex]) {
|
||||
handleResourceClick(filteredResources[selectedIndex]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let filteredResources = $derived.by(() => {
|
||||
const sortedServers = mcpStore.getServers();
|
||||
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
|
||||
|
||||
const sortedResources = [...resources].sort((a, b) => {
|
||||
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const query = (searchQuery || internalSearchQuery).toLowerCase();
|
||||
if (!query) return sortedResources;
|
||||
|
||||
return sortedResources.filter(
|
||||
(resource) =>
|
||||
resource.name.toLowerCase().includes(query) ||
|
||||
resource.title?.toLowerCase().includes(query) ||
|
||||
resource.description?.toLowerCase().includes(query) ||
|
||||
resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
let showSearchInput = $derived(resources.length > 3);
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open resource picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredResources}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="No MCP resources available"
|
||||
itemKey={(resource) => resource.serverName + ':' + resource.uri}
|
||||
>
|
||||
{#snippet item(resource, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(resource.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
|
||||
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleResourceClick(resource)}
|
||||
>
|
||||
<ChatFormPickerItemHeader
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={resource.title || resource.name}
|
||||
description={resource.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if isResourceAttached(resource.uri)}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
>
|
||||
attached
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet subtitle()}
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
|
||||
{resource.uri}
|
||||
</p>
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
|
||||
{#snippet skeleton()}
|
||||
<ChatFormPickerListItemSkeleton />
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if onBrowse && resources.length > 3}
|
||||
<Button
|
||||
class="fixed right-3 bottom-3"
|
||||
type="button"
|
||||
onclick={onBrowse}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<FolderOpen class="h-3 w-3" />
|
||||
|
||||
Browse all
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
+59
-26
@@ -1,16 +1,30 @@
|
||||
<script lang="ts">
|
||||
import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
|
||||
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
|
||||
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
||||
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
import type {
|
||||
ChatFormCommand,
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo
|
||||
} from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
isCommandPickerOpen?: boolean;
|
||||
commandQuery?: string;
|
||||
commands?: ChatFormCommand[];
|
||||
isPromptPickerOpen?: boolean;
|
||||
promptSearchQuery?: string;
|
||||
isInlineResourcePickerOpen?: boolean;
|
||||
resourceSearchQuery?: string;
|
||||
isMentionPickerOpen?: boolean;
|
||||
mentionQuery?: string;
|
||||
mentionAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onCommandPickerClose?: () => void;
|
||||
onCommandSelect?: (command: ChatFormCommand) => void;
|
||||
onPromptPickerClose?: () => void;
|
||||
onInlineResourcePickerClose?: () => void;
|
||||
onInlineResourceSelect?: () => void;
|
||||
onMentionPickerClose?: () => void;
|
||||
onMentionOpened?: () => void;
|
||||
onMentionSelect?: (entry: FileMentionEntry) => void;
|
||||
onPromptLoadStart?: (
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
@@ -18,36 +32,44 @@
|
||||
) => void;
|
||||
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
|
||||
onPromptLoadError?: (placeholderId: string, error: string) => void;
|
||||
onInlineResourceBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isCommandPickerOpen,
|
||||
commandQuery,
|
||||
commands = [],
|
||||
onCommandPickerClose,
|
||||
onCommandSelect,
|
||||
isPromptPickerOpen,
|
||||
promptSearchQuery,
|
||||
isInlineResourcePickerOpen,
|
||||
resourceSearchQuery,
|
||||
isMentionPickerOpen,
|
||||
mentionQuery,
|
||||
mentionAnchor,
|
||||
scopePath,
|
||||
onPromptPickerClose,
|
||||
onInlineResourcePickerClose,
|
||||
onInlineResourceSelect,
|
||||
onMentionPickerClose,
|
||||
onMentionOpened,
|
||||
onMentionSelect,
|
||||
onPromptLoadStart,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError,
|
||||
onInlineResourceBrowse
|
||||
onPromptLoadError
|
||||
}: Props = $props();
|
||||
|
||||
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
|
||||
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
||||
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
|
||||
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
|
||||
|
||||
/**
|
||||
* Delegates keyboard events to the active picker child.
|
||||
* Returns true if the event was handled.
|
||||
*/
|
||||
/** Delegate keyboard events to the active picker child; true if handled. */
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
|
||||
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -55,6 +77,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormCommandPicker
|
||||
bind:this={commandPickerRef}
|
||||
isOpen={isCommandPickerOpen ?? false}
|
||||
query={commandQuery ?? ''}
|
||||
{commands}
|
||||
onClose={onCommandPickerClose ?? (() => {})}
|
||||
onSelect={onCommandSelect ?? (() => {})}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpPrompts
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
@@ -65,11 +96,13 @@
|
||||
{onPromptLoadError}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpResources
|
||||
bind:this={resourcePickerRef}
|
||||
isOpen={isInlineResourcePickerOpen}
|
||||
searchQuery={resourceSearchQuery}
|
||||
onClose={onInlineResourcePickerClose}
|
||||
onResourceSelect={onInlineResourceSelect}
|
||||
onBrowse={onInlineResourceBrowse}
|
||||
<ChatFormMentionPicker
|
||||
bind:this={mentionPickerRef}
|
||||
isOpen={isMentionPickerOpen ?? false}
|
||||
query={mentionQuery ?? ''}
|
||||
customAnchor={mentionAnchor}
|
||||
scopePath={scopePath ?? null}
|
||||
onClose={onMentionPickerClose ?? (() => {})}
|
||||
onOpened={onMentionOpened}
|
||||
onSelect={onMentionSelect ?? (() => {})}
|
||||
/>
|
||||
|
||||
@@ -28,11 +28,10 @@
|
||||
onMount(() => {
|
||||
if (textareaElement) {
|
||||
autoResizeTextarea(textareaElement);
|
||||
textareaElement.focus();
|
||||
textareaElement.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Expose the textarea element for external access
|
||||
export function getElement() {
|
||||
return textareaElement;
|
||||
}
|
||||
@@ -48,6 +47,17 @@
|
||||
textareaElement.style.height = '1rem';
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text caret offsets, shared with the contenteditable variant so
|
||||
// the picker/paste flows can address either renderer through one handle.
|
||||
export function getCaretOffset(): number {
|
||||
if (!textareaElement) return 0;
|
||||
return textareaElement.selectionStart ?? textareaElement.value.length;
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
textareaElement?.setSelectionRange(offset, offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className}">
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||
@@ -10,23 +8,22 @@
|
||||
buildCaseInsensitiveGlob,
|
||||
joinPath,
|
||||
lastPathSegment,
|
||||
rankEntries,
|
||||
splitPathQuery,
|
||||
runGlobSearchWithChildren,
|
||||
type GlobEntry
|
||||
} from '$lib/utils';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
|
||||
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
|
||||
import {
|
||||
DEFAULT_MOBILE_BREAKPOINT,
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
MAX_RESULTS_SHOWN,
|
||||
NATIVE_LIMIT,
|
||||
NATIVE_MAX_DEPTH,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
SEARCH_LIMIT,
|
||||
SEARCH_MAX_DEPTH
|
||||
@@ -39,228 +36,147 @@
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
directory?: string | null;
|
||||
/** Controlled open state; the host owns it so the chip click and the
|
||||
* `/cwd` slash command open the picker through the same path. */
|
||||
isOpen: boolean;
|
||||
/** Two-way bound query, kept in sync with the text after `/cwd `. */
|
||||
query: string;
|
||||
/** Anchor at the form's top edge so the popover floats above the box. */
|
||||
customAnchor?: HTMLElement | null;
|
||||
onChange?: (directory: string | null) => void;
|
||||
/**
|
||||
* Lets the host refocus the chat input so typing can resume without
|
||||
* an extra click after the popover closes.
|
||||
*/
|
||||
/** Lets the host refocus the chat input after the popover closes. */
|
||||
onClose?: () => void;
|
||||
/** Fired when the chip is clicked so the host can open the picker. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
directory = $bindable(null),
|
||||
directory = null,
|
||||
isOpen,
|
||||
query = $bindable(''),
|
||||
customAnchor = null,
|
||||
onChange,
|
||||
onClose
|
||||
onClose,
|
||||
onOpen
|
||||
}: Props = $props();
|
||||
|
||||
// File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
|
||||
// exposes a "Browse" button that opens the native folder picker. When unavailable the
|
||||
// popover still works via the text input - no alerts, no upload semantics.
|
||||
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
|
||||
// exposes a "Browse" button only when available.
|
||||
const pickerSupported =
|
||||
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
|
||||
|
||||
// Popover open state; the element handles outside-click and Escape.
|
||||
let isOpen = $state(false);
|
||||
let inputValue = $state('');
|
||||
let searchInputRef: HTMLInputElement | null = $state(null);
|
||||
|
||||
let queryResults = $state<string[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let searchError = $state<string | null>(null);
|
||||
let hoveredIndex = $state(-1);
|
||||
// Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
|
||||
// highlighted row into view only via this trigger, never on hover.
|
||||
let scrollTrigger = $state(0);
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Absolute home directory on the server, resolved once per session by
|
||||
// the tools store. Anchors both the search scope and the chip's `~`
|
||||
// abbreviation.
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => queryResults.length,
|
||||
onClose: closePicker,
|
||||
onSelect: (index) => commit(queryResults[index])
|
||||
});
|
||||
|
||||
let homeBase = $derived(toolsStore.serverHome);
|
||||
|
||||
// AbortController + sequence counter to discard stale responses when the user
|
||||
// keeps typing; a newer call aborts the previous one. The sequence counter
|
||||
// also covers the gap between abort and the catch handler.
|
||||
let searchController: AbortController | null = null;
|
||||
let searchSeq = 0;
|
||||
|
||||
// Cache of the last file_glob_search result per (parent, include, max_depth),
|
||||
// so repeated queries in the same directory don't re-walk the tree. Entering
|
||||
// a directory hits it every time: the children listed for an exactly typed
|
||||
// segment are what the next keystroke, the trailing slash, asks for again.
|
||||
const SEARCH_CACHE_TTL_MS = 2000;
|
||||
const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
|
||||
|
||||
const runSearch = debounce((query: string) => {
|
||||
void doSearch(query);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Resolve home eagerly on mount so the chip can abbreviate before the
|
||||
// user opens the picker. resolveServerHome() is cached, so repeat calls
|
||||
// (e.g. from handleOpenChange) are no-ops.
|
||||
// Resolve home eagerly so the chip can abbreviate before the picker opens.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
// Auto-focus the search input when the popover opens.
|
||||
// HTML `autofocus` is unreliable on dynamically shown elements, so we
|
||||
// use a microtask (0ms setTimeout) after the effect flushes.
|
||||
// HTML `autofocus` is unreliable on dynamically shown elements.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
|
||||
});
|
||||
|
||||
let lastScrollTrigger: number | null = null;
|
||||
|
||||
// hoveredIndex/queryResults are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger
|
||||
$effect(() => {
|
||||
if (scrollTrigger === lastScrollTrigger) return;
|
||||
lastScrollTrigger = scrollTrigger;
|
||||
untrack(() => {
|
||||
if (!listContainer) return;
|
||||
if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-result-index="${hoveredIndex}"]`
|
||||
) as HTMLElement | null;
|
||||
selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
if (!isOpen) return;
|
||||
const q = query.trim();
|
||||
nav.reset(-1);
|
||||
if (q) {
|
||||
search.run(q);
|
||||
} else {
|
||||
search.cancel();
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
nav.reset(-1);
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
}
|
||||
});
|
||||
|
||||
function cancelSearch() {
|
||||
searchController?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
useScrollActiveRow({
|
||||
getTrigger: () => nav.scrollTrigger,
|
||||
getContainer: () => listContainer,
|
||||
getIndex: () => nav.hoveredIndex,
|
||||
getCount: () => queryResults.length,
|
||||
dataIndex: 'result'
|
||||
});
|
||||
|
||||
// Effective directory the current search runs against (shown in the
|
||||
// footer); updated by doSearch, including when an exactly-typed
|
||||
// directory is "entered".
|
||||
let searchScope = $state(HOME_TILDE);
|
||||
|
||||
// Runs a directory listing through the cache, so a repeated query in the
|
||||
// same directory does not re-walk the tree on the server.
|
||||
async function searchDirs(
|
||||
path: string,
|
||||
include: string,
|
||||
maxDepth: number,
|
||||
signal: AbortSignal
|
||||
): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
|
||||
const key = `${path}\u0000${include}\u0000${maxDepth}`;
|
||||
const cached = searchCache.get(key);
|
||||
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
|
||||
return { base: cached.base, entries: cached.results };
|
||||
}
|
||||
const res = await ToolsService.executeToolRaw(
|
||||
BuiltInTool.FILE_GLOB_SEARCH,
|
||||
{ path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
|
||||
signal
|
||||
);
|
||||
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const now = Date.now();
|
||||
for (const [k, v] of searchCache) {
|
||||
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
|
||||
}
|
||||
searchCache.set(key, { results: entries, base, at: now });
|
||||
return { base, entries };
|
||||
}
|
||||
|
||||
async function doSearch(query: string) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
isSearching = false;
|
||||
hoveredIndex = -1;
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
return;
|
||||
}
|
||||
|
||||
cancelSearch();
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
const mySeq = ++searchSeq;
|
||||
|
||||
const pathQuery = splitPathQuery(trimmed);
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
// A generous limit is requested because ranking happens
|
||||
// client-side; only the top 20 are shown.
|
||||
const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
|
||||
const include = pathQuery
|
||||
? pathQuery.last
|
||||
? buildCaseInsensitiveGlob(pathQuery.last)
|
||||
: GLOB_WILDCARD
|
||||
: buildCaseInsensitiveGlob(trimmed);
|
||||
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
|
||||
const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (res.error) {
|
||||
// An exactly-typed directory is "entered": the shared search lists its
|
||||
// children too, so path navigation does not require a trailing slash.
|
||||
const search = useDebouncedSearch({
|
||||
debounceMs: SEARCH_DEBOUNCE_MS,
|
||||
canRun: () => isOpen,
|
||||
getQuery: () => query.trim(),
|
||||
run: async (q, signal, isCurrent) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
searchError = res.error;
|
||||
searchError = null;
|
||||
nav.reset(-1);
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
return;
|
||||
}
|
||||
const { base, entries } = res;
|
||||
const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
|
||||
let results = ranked.map((e) => joinPath(base, e.path));
|
||||
searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
|
||||
|
||||
// An exactly-typed directory is "entered": list its children too,
|
||||
// so path navigation doesn't require a trailing slash.
|
||||
const last = pathQuery?.last;
|
||||
const exact = last
|
||||
? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
|
||||
: undefined;
|
||||
if (exact) {
|
||||
const exactDir = joinPath(base, exact.path);
|
||||
const childRes = await searchDirs(
|
||||
exactDir,
|
||||
GLOB_WILDCARD,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
controller.signal
|
||||
try {
|
||||
// Generous limit: ranking is client-side, only the top
|
||||
// MAX_RESULTS_SHOWN are shown.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
trimmed,
|
||||
homeBase ?? HOME_TILDE,
|
||||
SEARCH_MAX_DEPTH,
|
||||
SEARCH_LIMIT,
|
||||
signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (!childRes.error) {
|
||||
const children = childRes.entries
|
||||
.map((e) => joinPath(childRes.base, e.path))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
results = [...results, ...children];
|
||||
searchScope = exactDir;
|
||||
if (!isCurrent()) return;
|
||||
if (res.error) {
|
||||
queryResults = [];
|
||||
nav.reset(-1);
|
||||
searchError = res.error;
|
||||
return;
|
||||
}
|
||||
|
||||
searchScope = res.exactDir ?? res.args.path;
|
||||
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
|
||||
if (queryResults.length > 0) {
|
||||
nav.reset(0);
|
||||
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
|
||||
} else {
|
||||
nav.reset(-1);
|
||||
}
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
queryResults = [];
|
||||
nav.reset(-1);
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
queryResults = results.slice(0, MAX_RESULTS_SHOWN);
|
||||
hoveredIndex = queryResults.length > 0 ? 0 : -1;
|
||||
// new results: scroll the list back to the top (first item is hovered)
|
||||
if (hoveredIndex === 0) scrollTrigger++;
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (mySeq !== searchSeq) return;
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
if (controller.signal.aborted) return;
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
if (mySeq === searchSeq) isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Single funnel for every local close so the host refocus fires
|
||||
// regardless of which commit/dismiss path ended the interaction.
|
||||
});
|
||||
// Single funnel for every local close so the host refocus always fires.
|
||||
function closePicker() {
|
||||
isOpen = false;
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function commit(path: string) {
|
||||
directory = path;
|
||||
onChange?.(path);
|
||||
closePicker();
|
||||
}
|
||||
@@ -268,15 +184,12 @@
|
||||
function setDirectory(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
directory = trimmed;
|
||||
onChange?.(trimmed);
|
||||
}
|
||||
|
||||
// Resolve a folder name picked via the browser-native picker (which exposes
|
||||
// only the leaf name) to a server-side absolute path. Returns null when the
|
||||
// server cannot locate a matching directory, so the caller can fail visibly
|
||||
// instead of committing a bare leaf name that would resolve against the
|
||||
// server process working directory.
|
||||
// Resolve a browser-picked folder name (which exposes only the leaf name)
|
||||
// to a server-side absolute path; null when the server cannot locate it,
|
||||
// so the caller fails visibly instead of committing a bare leaf name.
|
||||
async function resolveNativeName(name: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
|
||||
@@ -318,7 +231,7 @@
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const value = inputValue.trim();
|
||||
const value = query.trim();
|
||||
if (!value) {
|
||||
closePicker();
|
||||
return;
|
||||
@@ -330,47 +243,33 @@
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
// Commit the highlighted result, falling back to the raw input
|
||||
// only when the query returned no matches.
|
||||
if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
|
||||
commit(queryResults[hoveredIndex]);
|
||||
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
|
||||
commit(queryResults[nav.hoveredIndex]);
|
||||
} else if (queryResults.length === 0) {
|
||||
handleSubmit();
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = (hoveredIndex + 1) % queryResults.length;
|
||||
scrollTrigger++;
|
||||
nav.move(1);
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
|
||||
scrollTrigger++;
|
||||
nav.move(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputInput(value: string) {
|
||||
hoveredIndex = -1;
|
||||
if (value.trim().length > 0) {
|
||||
runSearch(value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearDirectory(event?: MouseEvent) {
|
||||
// Stop the click from bubbling into the popover trigger and re-opening
|
||||
// Stop the click from bubbling into the chip button and re-opening
|
||||
// the picker on top of the now-cleared state.
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
directory = null;
|
||||
onChange?.(null);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
// The chip is always visible; the X clears the directory (no-op when
|
||||
// already empty).
|
||||
function handleDismiss(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
@@ -380,105 +279,104 @@
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
isOpen = open;
|
||||
if (open) {
|
||||
// Seed the search field with the current path so the user can refine it
|
||||
// (or hit Enter to confirm / clear via the X icon).
|
||||
inputValue = directory ?? '';
|
||||
hoveredIndex = -1;
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
void toolsStore.resolveServerHome();
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
if (inputValue.trim()) runSearch(inputValue);
|
||||
} else {
|
||||
cancelSearch();
|
||||
// bits-ui-initiated close (Escape on the content, outside-click,
|
||||
// trigger toggle) - the only path that bypasses closePicker().
|
||||
search.cancel();
|
||||
// bits-ui-initiated close (Escape on the content, outside-click) -
|
||||
// the only path that bypasses closePicker().
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltips only on wider viewports - hover surfaces get in the way on
|
||||
// touch / narrow layouts. Mirrors the gate used in ActionIcon.
|
||||
let innerWidth = $state(0);
|
||||
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
|
||||
</script>
|
||||
|
||||
<div
|
||||
<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,
|
||||
isOpen && 'w-full'
|
||||
className
|
||||
]}
|
||||
onclick={onOpen}
|
||||
{disabled}
|
||||
>
|
||||
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger {disabled} class="flex justify-start">
|
||||
<ChatFormWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
onClear={handleDismiss}
|
||||
<ChatFormWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
onClear={handleDismiss}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div class="p-2 min-h-28 flex flex-col justify-between">
|
||||
<SearchInput
|
||||
bind:ref={searchInputRef}
|
||||
bind:value={inputValue}
|
||||
placeholder="Choose working directory"
|
||||
onInput={handleInputInput}
|
||||
onClose={closePicker}
|
||||
class="w-full"
|
||||
{#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormWorkingDirectoryResultsList
|
||||
results={queryResults}
|
||||
hoveredIndex={nav.hoveredIndex}
|
||||
isSearching={search.isSearching}
|
||||
error={searchError}
|
||||
rawQuery={query}
|
||||
bind:container={listContainer}
|
||||
onCommit={commit}
|
||||
onHover={(index) => nav.setHover(index)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormWorkingDirectoryResultsList
|
||||
results={queryResults}
|
||||
{hoveredIndex}
|
||||
{isSearching}
|
||||
error={searchError}
|
||||
rawQuery={inputValue}
|
||||
bind:container={listContainer}
|
||||
onCommit={commit}
|
||||
onHover={(index) => (hoveredIndex = index)}
|
||||
/>
|
||||
{/if}
|
||||
{#if pickerSupported}
|
||||
<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}
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pickerSupported}
|
||||
<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}
|
||||
{#if homeBase}
|
||||
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
|
||||
|
||||
<span class="px-2 py-1.5 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
<span class="truncate text-muted-foreground/70" title={searchScope}
|
||||
>{abbreviateHome(searchScope, homeBase)}</span
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if homeBase}
|
||||
<div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
|
||||
|
||||
<span class="px-2 py-2 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
<span class="truncate text-muted-foreground/70" title={searchScope}
|
||||
>{abbreviateHome(searchScope, homeBase)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Folder, X } from '@lucide/svelte';
|
||||
import { abbreviateWorkingDir } from '$lib/utils';
|
||||
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIcon } from '$lib/components/app/actions';
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const displayLabel = $derived(
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
|
||||
);
|
||||
// Full path surface: hover the abbreviated label to recall the exact directory.
|
||||
const displayLabelTitle = $derived(directory ?? '');
|
||||
|
||||
+4
-4
@@ -183,8 +183,8 @@
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
|
||||
{/if}
|
||||
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
{#if displayedModel}
|
||||
{#if displayedModel}
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
<ChatMessageAssistantModel
|
||||
{displayedModel}
|
||||
@@ -200,8 +200,8 @@
|
||||
showMessageStats={currentConfig.showMessageStats}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
<ChatMessageActionIcons
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@
|
||||
? `max-height: ${MAX_HEIGHT}px;`
|
||||
: 'max-height: none;'}
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
{#if !currentConfig.renderContentAsRawText}
|
||||
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
|
||||
<MarkdownContent class="markdown-system-content" content={message.content} />
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -98,9 +98,10 @@
|
||||
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
|
||||
);
|
||||
|
||||
// No subtitle while the call is in flight - the spinner already
|
||||
// signals activity; only terminal states get a pill.
|
||||
function subtitleFor(errorMessage?: string): string | undefined {
|
||||
if (extraLiveStreaming) return 'streaming...';
|
||||
if (showSpinner) return 'executing...';
|
||||
if (showSpinner) return undefined;
|
||||
if (errorMessage) return 'failed';
|
||||
if (isStreamingCall && !isStreaming) return 'incomplete';
|
||||
return undefined;
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
data-multiline={isMultiline ? '' : undefined}
|
||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
|
||||
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
|
||||
<div bind:this={messageElement}>
|
||||
<MarkdownContent class="markdown-user-content" {content} />
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
|
||||
let expandedStates: Record<number, boolean> = $state({});
|
||||
|
||||
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
|
||||
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
|
||||
const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
|
||||
const showMessageStats = $derived(Boolean(config().showMessageStats));
|
||||
@@ -186,7 +185,6 @@
|
||||
{section}
|
||||
open={isExpanded(index, section)}
|
||||
{isStreaming}
|
||||
{renderThinkingAsMarkdown}
|
||||
{hasReasoningError}
|
||||
attachments={message?.extra}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
|
||||
@@ -10,7 +11,6 @@
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
renderThinkingAsMarkdown: boolean;
|
||||
hasReasoningError?: boolean;
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
onToggle?: () => void;
|
||||
@@ -20,12 +20,13 @@
|
||||
section,
|
||||
open,
|
||||
isStreaming,
|
||||
renderThinkingAsMarkdown,
|
||||
hasReasoningError = false,
|
||||
attachments,
|
||||
onToggle
|
||||
}: Props = $props();
|
||||
|
||||
const currentConfig = config();
|
||||
|
||||
const REASONING_HEADER = 'Reasoning';
|
||||
const REASONING_HEADER_PENDING = 'Reasoning...';
|
||||
const REASONING_SUBTITLE_ERROR = 'Error';
|
||||
@@ -128,7 +129,7 @@
|
||||
class:is-streaming={isPending}
|
||||
onscroll={handleScrollEvent}
|
||||
>
|
||||
{#if renderThinkingAsMarkdown}
|
||||
{#if !currentConfig.renderContentAsRawText}
|
||||
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
|
||||
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts
|
||||
* - Composes ChatFormTextarea (or ChatFormContenteditable for messages with
|
||||
* file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
|
||||
* - Manages file upload state via `uploadedFiles` bindable prop
|
||||
* - Integrates with ModelsSelectorDropdown for model selection in router mode
|
||||
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
|
||||
@@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
|
||||
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
|
||||
|
||||
/**
|
||||
* Auto-resizing textarea with IME composition support. Automatically adjusts
|
||||
* height based on content. Handles IME input correctly (waits for composition
|
||||
* end before processing Enter key). Exposes focus() and resetHeight() methods.
|
||||
* Auto-resizing contenteditable input that renders `[name](file://...)`
|
||||
* mention links as inline chips while keeping the value as the markdown
|
||||
* source string. ChatForm swaps it in once a mention link lands in the
|
||||
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
|
||||
*/
|
||||
export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte';
|
||||
|
||||
/**
|
||||
* Plain auto-resizing textarea with IME composition support. Default input
|
||||
* renderer inside ChatForm until a file mention lands.
|
||||
*/
|
||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
@@ -351,14 +359,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
|
||||
* Generic scrollable list for picker popovers. Provides search input,
|
||||
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
|
||||
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
||||
*/
|
||||
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
|
||||
|
||||
/**
|
||||
* Generic button wrapper for picker list items. Provides consistent styling,
|
||||
* hover/selected states, and data-picker-index attribute for scroll-into-view.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
||||
*/
|
||||
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
|
||||
|
||||
@@ -376,30 +384,23 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
|
||||
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickerMcpResources** - MCP resource selection interface
|
||||
*
|
||||
* Floating picker for browsing and attaching MCP Server Resources.
|
||||
* Triggered by typing `@` in the chat input.
|
||||
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
|
||||
*
|
||||
* **Features:**
|
||||
* - Search/filter resources by name, title, description, or URI across all connected servers
|
||||
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
|
||||
* - Shows attached state for already-attached resources
|
||||
* - Loading states with skeleton placeholders
|
||||
* - Server information header per resource for visual identification
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
|
||||
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
|
||||
* input to a filesystem match via the server's `file_glob_search` built-in
|
||||
* tool, scoped to the conversation cwd (or server home when unset).
|
||||
* Selection splices a `[name](file:///<abs path>)` link into the input.
|
||||
*/
|
||||
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
|
||||
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickers** - Chat input picker container
|
||||
*
|
||||
* Container component that hosts both MCP prompt and MCP resource pickers.
|
||||
* Manages shared state, keyboard navigation, and coordination between the two
|
||||
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
|
||||
* `/`-triggered slash-command picker. Lists the available slash commands
|
||||
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
|
||||
* hands the command to the parent for dispatch.
|
||||
*/
|
||||
export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
|
||||
|
||||
/**
|
||||
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
|
||||
* and delegates keyboard events to the active one.
|
||||
*/
|
||||
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
@@ -33,7 +34,8 @@
|
||||
preprocessLaTeX,
|
||||
getImageErrorFallbackHtml,
|
||||
copyCodeToClipboard,
|
||||
copyToClipboard
|
||||
copyToClipboard,
|
||||
splitGluedClosingCodeFences
|
||||
} from '$lib/utils';
|
||||
import {
|
||||
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
||||
@@ -174,6 +176,7 @@
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
@@ -340,7 +343,11 @@
|
||||
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
|
||||
* @param markdown - The raw markdown string to process
|
||||
*/
|
||||
async function processMarkdown(markdown: string) {
|
||||
async function processMarkdown(rawMarkdown: string) {
|
||||
// Text glued to a closing code fence is not a fence to the parser -
|
||||
// the block would swallow it. Split it onto its own line first.
|
||||
const markdown = splitGluedClosingCodeFences(rawMarkdown);
|
||||
|
||||
// Early exit if content unchanged (can happen with rapid coalescing)
|
||||
if (markdown === previousContent) {
|
||||
return;
|
||||
|
||||
@@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) {
|
||||
/* Code blocks */
|
||||
|
||||
.markdown-content :global(.code-block-wrapper) {
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
@@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) {
|
||||
max-height: var(--max-message-height);
|
||||
}
|
||||
|
||||
.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content:global(.dark) :global(.code-block-wrapper) {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Rehype plugin that rewrites `file://` markdown anchors into the inline
|
||||
* mention chip, sharing the class string with the contenteditable
|
||||
* tokenizer via `$lib/constants/mention-badge`.
|
||||
*
|
||||
* The chip is presentational: `file://` navigation is blocked from
|
||||
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
|
||||
* no tab stop); the full path stays available on `title`.
|
||||
*/
|
||||
|
||||
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
|
||||
import {
|
||||
FILE_URI_PREFIX,
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
PATH_SEPARATOR,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Root, Element } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// Trailing path separators mark a directory and are kept out of the label.
|
||||
const TRAILING_SEPARATOR_REGEX = /\/+$/;
|
||||
|
||||
function decodeHrefPath(href: string): string {
|
||||
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
|
||||
return decodeFileLinkPath(stripped);
|
||||
}
|
||||
|
||||
function labelFromFileUrl(href: string): string {
|
||||
const decoded = decodeHrefPath(href);
|
||||
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
|
||||
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
|
||||
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
||||
}
|
||||
|
||||
// A trailing `/` in the target marks a directory and selects the folder
|
||||
// icon, matching the convention the mention picker inserts with.
|
||||
function iconElement(href: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'svg',
|
||||
properties: {
|
||||
...MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
|
||||
},
|
||||
children: getMentionBadgeIconPaths(href).map((d) => ({
|
||||
type: 'element',
|
||||
tagName: 'path',
|
||||
properties: { d },
|
||||
children: []
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export const rehypeFileBadge: Plugin<[], Root> = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
if (node.tagName !== 'a') return;
|
||||
|
||||
const props = node.properties ?? {};
|
||||
const href = typeof props.href === 'string' ? props.href : null;
|
||||
|
||||
if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
|
||||
|
||||
const label = labelFromFileUrl(href);
|
||||
const titleAttr = typeof props.title === 'string' ? props.title : href;
|
||||
const decodedPath = decodeHrefPath(href);
|
||||
|
||||
node.tagName = 'span';
|
||||
node.properties = {
|
||||
className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
|
||||
title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
|
||||
};
|
||||
node.children = [
|
||||
iconElement(href),
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: ['shrink-0', 'truncate'] },
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: getMentionBadgeLabel(
|
||||
label,
|
||||
decodedPath,
|
||||
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
|
||||
toolsStore.serverHome
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||
import { URL_PARAMS } from '$lib/constants';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -22,7 +23,7 @@
|
||||
function handleSelectModel(model: string) {
|
||||
// Build URL with selected model, preserving other params
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('model', model);
|
||||
url.searchParams.set(URL_PARAMS.MODEL, model);
|
||||
|
||||
handleOpenChange(false);
|
||||
goto(url.toString());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { highlightMatch } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
query: string;
|
||||
matchClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
query,
|
||||
matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
|
||||
}: Props = $props();
|
||||
|
||||
let segments = $derived(highlightMatch(text, query));
|
||||
</script>
|
||||
|
||||
{#each segments as seg, i (i)}
|
||||
{#if seg.match}
|
||||
<mark class={matchClass}>{seg.text}</mark>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -42,3 +42,11 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
|
||||
* Supports placeholder, autofocus, and change callbacks.
|
||||
*/
|
||||
export { default as SearchInput } from './SearchInput.svelte';
|
||||
|
||||
/**
|
||||
* **HighlightedMatch** - Substring-match text highlight
|
||||
*
|
||||
* Renders `text` with each case-insensitive occurrence of `query` wrapped
|
||||
* in `<mark>`.
|
||||
*/
|
||||
export { default as HighlightedMatch } from './HighlightedMatch.svelte';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
@@ -35,7 +36,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedIndex = $state<number>(-1);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -43,15 +44,77 @@
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedIndex = -1;
|
||||
highlightedId = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedIndex = -1;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the dropdown's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
|
||||
|
||||
function moveHighlight(direction: 1 | -1) {
|
||||
const len = visualOrder.length;
|
||||
if (len === 0) {
|
||||
highlightedId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = highlightedIndex;
|
||||
if (index === -1) {
|
||||
index = direction === 1 ? 0 : len - 1;
|
||||
} else {
|
||||
index = (index + direction + len) % len;
|
||||
}
|
||||
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
void ms.handleSelect(modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = routerModels().find((m) => m.id === modelId);
|
||||
const status = model?.status?.value as ServerModelStatus | undefined;
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.unloadModel(modelId);
|
||||
}
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
@@ -61,33 +124,17 @@
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
|
||||
highlightedIndex = 0;
|
||||
} else {
|
||||
highlightedIndex += 1;
|
||||
}
|
||||
moveHighlight(1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === 0) {
|
||||
highlightedIndex = ms.filteredOptions.length - 1;
|
||||
} else {
|
||||
highlightedIndex -= 1;
|
||||
}
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
|
||||
const option = ms.filteredOptions[highlightedIndex];
|
||||
|
||||
ms.handleSelect(option.id);
|
||||
} else if (ms.filteredOptions.length > 0) {
|
||||
highlightedIndex = 0;
|
||||
if (highlightedId) {
|
||||
void handleModelKeyAction(highlightedId, event.altKey);
|
||||
} else if (visualOrder.length > 0) {
|
||||
highlightedId = visualOrder[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +156,7 @@
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
@@ -150,7 +197,7 @@
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
@@ -186,6 +233,7 @@
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
@@ -217,9 +265,9 @@
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option, flatIndex } = item}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = flatIndex === highlightedIndex}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
@@ -230,11 +278,11 @@
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
ms.handleSelect(option.id);
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -275,7 +323,7 @@
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
|
||||
@@ -62,9 +62,10 @@
|
||||
<div
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer hover:bg-muted focus:bg-muted',
|
||||
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
|
||||
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
|
||||
'cursor-pointer',
|
||||
isSelected && 'bg-accent/50 text-accent-foreground',
|
||||
isHighlighted && 'bg-accent',
|
||||
!isSelected && !isHighlighted && 'hover:bg-muted',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
role="option"
|
||||
|
||||
@@ -98,7 +98,12 @@
|
||||
const numValue = Number(processedConfig[field]);
|
||||
if (!isNaN(numValue)) {
|
||||
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
|
||||
processedConfig[field] = Math.max(1, Math.round(numValue));
|
||||
const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
|
||||
(section) => section.fields ?? []
|
||||
).find((entry) => entry.key === field);
|
||||
const lo = entryByMinMax?.min ?? 1;
|
||||
const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
|
||||
processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
|
||||
} else {
|
||||
processedConfig[field] = numValue;
|
||||
}
|
||||
|
||||
@@ -83,12 +83,18 @@
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.isPositiveInteger ? 'number' : 'text'}
|
||||
{...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
|
||||
{...field.isPositiveInteger
|
||||
? {
|
||||
min: String(field.min ?? 1),
|
||||
step: '1',
|
||||
...(field.max != null ? { max: String(field.max) } : {})
|
||||
}
|
||||
: {}}
|
||||
value={currentValue}
|
||||
oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||
placeholder={currentModelParams[field.key] != null
|
||||
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
|
||||
: ''}
|
||||
: (field.placeholder ?? '')}
|
||||
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
|
||||
/>
|
||||
{#if isCustomRealTime}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants/working-directory';
|
||||
import { ChatFormCommandAction } from '$lib/enums';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
|
||||
interface ChatCommandsOptions {
|
||||
/** Gates `/model`. */
|
||||
showModelSelector: boolean;
|
||||
/** Gates `/prompt`. */
|
||||
hasPrompts: () => boolean;
|
||||
/** Gates `/cwd`. */
|
||||
hasBuiltinTools: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slash commands surfaced by the `/` command picker, in display order.
|
||||
*
|
||||
* Availability is supplied as predicates rather than store imports: this
|
||||
* module is re-exported through the `$lib/constants` barrel, and importing
|
||||
* stores at module load would create a circular dependency (the stores
|
||||
* themselves import from `$lib/constants`).
|
||||
*/
|
||||
export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
|
||||
return [
|
||||
{
|
||||
name: 'prompt',
|
||||
description: 'Insert an MCP prompt',
|
||||
action: ChatFormCommandAction.PROMPT,
|
||||
disabled: !options.hasPrompts()
|
||||
},
|
||||
{
|
||||
name: 'cwd',
|
||||
description: SET_WORKING_DIRECTORY_LABEL,
|
||||
keywords: ['current working directory'],
|
||||
action: ChatFormCommandAction.CWD,
|
||||
disabled: !options.hasBuiltinTools()
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Select model',
|
||||
action: ChatFormCommandAction.MODEL,
|
||||
disabled: !options.showModelSelector
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -2,5 +2,4 @@ export const INITIAL_FILE_SIZE = 0;
|
||||
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
||||
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
|
||||
export const PROMPT_TRIGGER_PREFIX = '/';
|
||||
export const RESOURCE_TRIGGER_PREFIX = '@';
|
||||
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
|
||||
|
||||
@@ -19,6 +19,10 @@ export const PANEL_CLASSES = `
|
||||
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
||||
export const DIALOG_SUBMENU_CONTENT = 'w-60';
|
||||
|
||||
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
|
||||
export const CHAT_INPUT_FOCUS_SELECTOR =
|
||||
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
|
||||
|
||||
/** Default Tailwind size class for inline icon components (lucide, etc.). */
|
||||
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './binary-detection';
|
||||
export * from './built-in-tools';
|
||||
export * from './cache';
|
||||
export * from './chat-form';
|
||||
export * from './chat-commands';
|
||||
export * from './cli-flags';
|
||||
export * from './code-blocks';
|
||||
export * from './icons';
|
||||
@@ -39,6 +40,7 @@ export * from './max-bundle-size';
|
||||
export * from './mcp';
|
||||
export * from './mcp-form';
|
||||
export * from './mcp-resource';
|
||||
export * from './mention-badge';
|
||||
export * from './message-export';
|
||||
export * from './path-display';
|
||||
export * from './model-id';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Shared visual contract between the two DOM-only badge paths (the
|
||||
* contenteditable tokenizer + the rehype plugin). Svelte cannot be
|
||||
* mounted at the per-keystroke tokenizer hot path nor from a hast tree,
|
||||
* so both emit the badge with the same class string literal; Tailwind's
|
||||
* scanner picks it up in both sources.
|
||||
*/
|
||||
export const MENTION_BADGE_CLASSNAME =
|
||||
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
|
||||
|
||||
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
|
||||
|
||||
/**
|
||||
* SVG attributes shared by the DOM-built and hast-built badge icons.
|
||||
* The tokenizer applies them via `setAttribute`, the rehype plugin
|
||||
* spreads them onto the hast `<svg>` `properties`; string values are
|
||||
* valid for both.
|
||||
*/
|
||||
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'aria-hidden': 'true'
|
||||
};
|
||||
|
||||
/**
|
||||
* SVG path strings for the badge's inline icon; each entry becomes one
|
||||
* `<path>` child of the wrapper `<svg>`. Paths match `lucide-svelte`'s
|
||||
* current `File` and `Folder` glyphs.
|
||||
*/
|
||||
export const MENTION_BADGE_FILE_ICON_PATHS: readonly string[] = [
|
||||
'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z',
|
||||
'M14 2v5a1 1 0 0 0 1 1h5'
|
||||
];
|
||||
|
||||
export const MENTION_BADGE_FOLDER_ICON_PATHS: readonly string[] = [
|
||||
'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'
|
||||
];
|
||||
@@ -1,4 +1,14 @@
|
||||
export const NEW_CHAT_PARAM = 'new_chat';
|
||||
/** Query params the chat routes read from the URL. */
|
||||
export const URL_PARAMS = {
|
||||
/** Prompt to send on arrival. */
|
||||
QUERY: 'q',
|
||||
/** Model to select. */
|
||||
MODEL: 'model',
|
||||
/** Load the selected model instead of waiting for the first message. */
|
||||
LOAD: 'load',
|
||||
/** Start a new chat. */
|
||||
NEW_CHAT: 'new_chat'
|
||||
} as const;
|
||||
|
||||
/** Settings section slugs — used for routes and navigation. */
|
||||
export const SETTINGS_SECTION_SLUGS = {
|
||||
@@ -16,7 +26,7 @@ export const ROUTES = {
|
||||
/** Root — start of the app. */
|
||||
START: '#/',
|
||||
/** New chat — root with new chat query param. */
|
||||
NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`,
|
||||
NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
|
||||
/** Chat base — for dynamic chat URLs use RouterService. */
|
||||
CHAT: '#/chat',
|
||||
/** MCP servers. */
|
||||
|
||||
@@ -23,7 +23,7 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
|
||||
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
||||
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
|
||||
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
|
||||
RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
|
||||
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
|
||||
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
|
||||
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
||||
@@ -31,8 +31,9 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_BUILD_VERSION: 'showBuildVersion',
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
|
||||
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
|
||||
// Sampling
|
||||
TEMPERATURE: 'temperature',
|
||||
DYNATEMP_RANGE: 'dynatemp_range',
|
||||
|
||||
@@ -23,7 +23,12 @@ import type {
|
||||
SettingsSectionEntry,
|
||||
SettingsSection
|
||||
} from '$lib/types';
|
||||
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
|
||||
import { CLI_FLAGS } from './cli-flags';
|
||||
import { DEFAULT_MCP_CONFIG } from './mcp';
|
||||
import {
|
||||
FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
|
||||
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
|
||||
} from './working-directory';
|
||||
import { SETTINGS_KEYS } from './settings-keys';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
||||
import { TITLE_GENERATION } from './title-generation';
|
||||
@@ -228,21 +233,13 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||
label: 'Render user content as Markdown',
|
||||
help: 'Render user messages using markdown formatting in the chat.',
|
||||
key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
|
||||
label: 'Render content as raw text',
|
||||
help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
|
||||
label: 'Render thinking as Markdown',
|
||||
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
|
||||
defaultValue: true,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
|
||||
label: 'Use full height code blocks',
|
||||
@@ -298,6 +295,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
|
||||
label: 'Show full path in mentions',
|
||||
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -555,6 +560,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
|
||||
label: 'Mention search depth',
|
||||
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
|
||||
defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
|
||||
placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
|
||||
min: 1,
|
||||
max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -699,6 +716,9 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||
type: s.type,
|
||||
isExperimental: s.isExperimental,
|
||||
isPositiveInteger: s.isPositiveInteger,
|
||||
placeholder: s.placeholder,
|
||||
min: s.min,
|
||||
max: s.max,
|
||||
dependsOn: s.dependsOn,
|
||||
help: s.help,
|
||||
options: s.options,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import { ROUTES } from './routes';
|
||||
@@ -6,6 +6,9 @@ import { ROUTES } from './routes';
|
||||
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||
|
||||
/** Icon used for the model selector and the `/model` slash command. */
|
||||
export const MODEL_SELECTOR_ICON = Package;
|
||||
|
||||
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
||||
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
export const GLOB_WILDCARD = '*';
|
||||
|
||||
/** Label shown for the working-directory picker / `/cwd` slash command. */
|
||||
export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
|
||||
|
||||
/** Character that starts and ends a glob character-class fragment. */
|
||||
export const GLOB_RANGE_OPEN = '[';
|
||||
export const GLOB_RANGE_CLOSE = ']';
|
||||
@@ -38,3 +41,9 @@ export const PATH_NAV_MAX_DEPTH = 1;
|
||||
// Native folder-picker resolution searches a shallow, bounded window.
|
||||
export const NATIVE_MAX_DEPTH = 4;
|
||||
export const NATIVE_LIMIT = 20;
|
||||
|
||||
/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
|
||||
export const FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH = 32;
|
||||
|
||||
/** Depth the pickers fall back to when the user setting is invalid. */
|
||||
export const FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH = 10;
|
||||
|
||||
@@ -78,3 +78,14 @@ export enum PdfViewMode {
|
||||
TEXT = 'text',
|
||||
PAGES = 'pages'
|
||||
}
|
||||
|
||||
export enum ChatFormCommandAction {
|
||||
PROMPT = 'prompt',
|
||||
CWD = 'cwd',
|
||||
MODEL = 'model'
|
||||
}
|
||||
|
||||
export enum FileMentionEntryType {
|
||||
FILE = 'file',
|
||||
DIRECTORY = 'directory'
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ export {
|
||||
MessageRole,
|
||||
MessageType,
|
||||
PdfViewMode,
|
||||
ReasoningFormat
|
||||
ReasoningFormat,
|
||||
ChatFormCommandAction,
|
||||
FileMentionEntryType
|
||||
} from './chat.enums';
|
||||
|
||||
export { SessionRecordType } from './conversation-import.enums';
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
|
||||
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
import {
|
||||
findCommandToken,
|
||||
findMentionToken,
|
||||
takeCommandDismissSnapshot,
|
||||
takeMentionDismissSnapshot,
|
||||
type CommandDismissSnapshot,
|
||||
type MentionDismissSnapshot
|
||||
} from '$lib/utils';
|
||||
|
||||
/** Dependencies injected as getters so the hook stays free of store circular imports. */
|
||||
export interface UseChatFormPickersOptions {
|
||||
getValue: () => string;
|
||||
/** Also fires the form's onChange. */
|
||||
setValue: (value: string) => void;
|
||||
/** Undefined when unmounted. */
|
||||
getCaretOffset: () => number | undefined;
|
||||
setCaretOffset: (offset: number) => void;
|
||||
focusInput: () => void;
|
||||
/** Gates `/model`. */
|
||||
getShowModelSelector: () => boolean;
|
||||
/** Gates `/prompt`. */
|
||||
hasPrompts: () => boolean;
|
||||
/** Gates `/cwd`. */
|
||||
hasBuiltinTools: () => boolean;
|
||||
getCwd: () => string | null;
|
||||
/** Mention search fallback scope. */
|
||||
getServerHome: () => string | null;
|
||||
openModelSelector: () => void;
|
||||
/** Delegate a keydown to the mounted pickers component, if any. */
|
||||
getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-form picker state and the `/`+`@` routing that drives them.
|
||||
* Owns open/query state, dismiss snapshots and slash-command dispatch;
|
||||
* textarea/caret/attachment handling stays in the chat form.
|
||||
*/
|
||||
export function useChatFormPickers(opts: UseChatFormPickersOptions) {
|
||||
let isCommandPickerOpen = $state(false);
|
||||
let commandQuery = $state('');
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
let isMentionPickerOpen = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
let isWorkingDirectoryPickerOpen = $state(false);
|
||||
let workingDirectoryQuery = $state('');
|
||||
|
||||
// Last dismissed `@`-mention token; while intact, the picker does not
|
||||
// reopen, so an escaped `@<query>` stays literal until edited.
|
||||
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
|
||||
|
||||
// Same dismissal contract for the `/`-command token.
|
||||
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
|
||||
|
||||
// Fall back to the server home so the picker still finds matches
|
||||
// before a cwd is set.
|
||||
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
|
||||
|
||||
const availableCommands = $derived(
|
||||
getChatCommands({
|
||||
showModelSelector: opts.getShowModelSelector(),
|
||||
hasPrompts: opts.hasPrompts,
|
||||
hasBuiltinTools: opts.hasBuiltinTools
|
||||
})
|
||||
);
|
||||
|
||||
// Dispatch a slash command picked from the list: consume the token and
|
||||
// open the target picker, seeding its search with `args`. Runs only on
|
||||
// explicit selection (Enter/click), so the buffer is never cleared
|
||||
// mid-typing.
|
||||
function dispatchCommand(command: ChatFormCommand, args: string) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
|
||||
switch (command.action) {
|
||||
case ChatFormCommandAction.PROMPT:
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
opts.setValue('');
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = args.trim();
|
||||
break;
|
||||
case ChatFormCommandAction.CWD: {
|
||||
// Keep `/cwd <args>` in the input so the search field and the
|
||||
// token stay two-way bound; normalize partial tokens (`/cw foo`).
|
||||
const trimmed = args.trim();
|
||||
const newValue = `/cwd ${trimmed}`;
|
||||
if (opts.getValue() !== newValue) {
|
||||
opts.setValue(newValue);
|
||||
queueMicrotask(() => opts.setCaretOffset(newValue.length));
|
||||
}
|
||||
workingDirectoryQuery = trimmed;
|
||||
isWorkingDirectoryPickerOpen = true;
|
||||
break;
|
||||
}
|
||||
case ChatFormCommandAction.MODEL:
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
opts.setValue('');
|
||||
opts.openModelSelector();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const value = opts.getValue();
|
||||
const cursor = opts.getCaretOffset() ?? value.length;
|
||||
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const token = findCommandToken(value);
|
||||
if (!token) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// While the `/cwd` picker is open the token doubles as its search
|
||||
// field: keep the two in sync instead of re-dispatching.
|
||||
if (isWorkingDirectoryPickerOpen) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
if (token.name === 'cwd') {
|
||||
workingDirectoryQuery = token.args.trim();
|
||||
} else {
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
workingDirectoryQuery = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismissed token stays literal until it changes.
|
||||
const isDismissedSticky =
|
||||
commandDismissedSnapshot !== null &&
|
||||
commandDismissedSnapshot.name === token.name &&
|
||||
commandDismissedSnapshot.args === token.args;
|
||||
|
||||
if (isDismissedSticky) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Commands dispatch only on explicit selection (Enter/click),
|
||||
// never mid-typing: `/model is broken` is prose until the user
|
||||
// picks the command from the list.
|
||||
if (availableCommands.length > 0) {
|
||||
isCommandPickerOpen = true;
|
||||
commandQuery = token.name;
|
||||
} else {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
if (commandDismissedSnapshot !== null) {
|
||||
commandDismissedSnapshot = null;
|
||||
}
|
||||
if (isWorkingDirectoryPickerOpen) {
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
}
|
||||
|
||||
const token = findMentionToken(value, cursor);
|
||||
|
||||
if (token) {
|
||||
// Dismissed token stays literal: don't reopen until it changes.
|
||||
const isDismissedSticky =
|
||||
mentionDismissedSnapshot !== null &&
|
||||
mentionDismissedSnapshot.start === token.start &&
|
||||
mentionDismissedSnapshot.query === token.query;
|
||||
|
||||
if (!isDismissedSticky) {
|
||||
// Only search once a char follows `@`; a bare `@` is a no-op
|
||||
// (otherwise the picker flashes an empty hint on re-type).
|
||||
if (token.query.length > 0) {
|
||||
mentionDismissedSnapshot = null;
|
||||
isMentionPickerOpen = true;
|
||||
mentionQuery = token.query;
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
|
||||
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
|
||||
if (mentionDismissedSnapshot !== null && !token) {
|
||||
mentionDismissedSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (opts.getPickersRef()?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleCommandSelect(command: ChatFormCommand) {
|
||||
// Dispatch on the live token so typed args seed the target picker.
|
||||
const token = findCommandToken(opts.getValue());
|
||||
dispatchCommand(command, token?.args ?? '');
|
||||
}
|
||||
|
||||
// Picker dismissed: snapshot the live token so it stays literal until
|
||||
// deleted or retyped.
|
||||
function handleCommandPickerClose() {
|
||||
if (isCommandPickerOpen) {
|
||||
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
|
||||
}
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
// Target picker manages its own focus: don't yank it back to the input.
|
||||
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
|
||||
opts.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Same dismissal snapshot for the mention token.
|
||||
function handleMentionPickerClose() {
|
||||
if (isMentionPickerOpen) {
|
||||
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
|
||||
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
|
||||
}
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
opts.focusInput();
|
||||
}
|
||||
|
||||
function handlePromptPickerClose() {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
opts.focusInput();
|
||||
}
|
||||
|
||||
function handleWorkingDirectoryOpen() {
|
||||
workingDirectoryQuery = opts.getCwd() ?? '';
|
||||
isWorkingDirectoryPickerOpen = true;
|
||||
}
|
||||
|
||||
function handleWorkingDirectoryClose() {
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
workingDirectoryQuery = '';
|
||||
opts.focusInput();
|
||||
}
|
||||
|
||||
// Two-way bind the text after `/cwd ` and the picker search input; the
|
||||
// reverse direction is handled by handleInput.
|
||||
$effect(() => {
|
||||
if (!isWorkingDirectoryPickerOpen) return;
|
||||
const value = opts.getValue();
|
||||
const token = findCommandToken(value);
|
||||
if (!token || token.name !== 'cwd') return;
|
||||
const newValue = `/cwd ${workingDirectoryQuery}`;
|
||||
if (newValue === value) return;
|
||||
opts.setValue(newValue);
|
||||
queueMicrotask(() => opts.setCaretOffset(newValue.length));
|
||||
});
|
||||
|
||||
return {
|
||||
get isCommandPickerOpen() {
|
||||
return isCommandPickerOpen;
|
||||
},
|
||||
set isCommandPickerOpen(v: boolean) {
|
||||
isCommandPickerOpen = v;
|
||||
},
|
||||
get commandQuery() {
|
||||
return commandQuery;
|
||||
},
|
||||
set commandQuery(v: string) {
|
||||
commandQuery = v;
|
||||
},
|
||||
get isPromptPickerOpen() {
|
||||
return isPromptPickerOpen;
|
||||
},
|
||||
set isPromptPickerOpen(v: boolean) {
|
||||
isPromptPickerOpen = v;
|
||||
},
|
||||
get promptSearchQuery() {
|
||||
return promptSearchQuery;
|
||||
},
|
||||
set promptSearchQuery(v: string) {
|
||||
promptSearchQuery = v;
|
||||
},
|
||||
get isMentionPickerOpen() {
|
||||
return isMentionPickerOpen;
|
||||
},
|
||||
set isMentionPickerOpen(v: boolean) {
|
||||
isMentionPickerOpen = v;
|
||||
},
|
||||
get mentionQuery() {
|
||||
return mentionQuery;
|
||||
},
|
||||
set mentionQuery(v: string) {
|
||||
mentionQuery = v;
|
||||
},
|
||||
get isWorkingDirectoryPickerOpen() {
|
||||
return isWorkingDirectoryPickerOpen;
|
||||
},
|
||||
set isWorkingDirectoryPickerOpen(v: boolean) {
|
||||
isWorkingDirectoryPickerOpen = v;
|
||||
},
|
||||
get workingDirectoryQuery() {
|
||||
return workingDirectoryQuery;
|
||||
},
|
||||
set workingDirectoryQuery(v: string) {
|
||||
workingDirectoryQuery = v;
|
||||
},
|
||||
get availableCommands() {
|
||||
return availableCommands;
|
||||
},
|
||||
get mentionScopePath() {
|
||||
return mentionScopePath;
|
||||
},
|
||||
handleInput,
|
||||
// True when a picker consumed the event, so the form skips submit.
|
||||
handleKeydown,
|
||||
dispatchCommand,
|
||||
handleCommandSelect,
|
||||
handleCommandPickerClose,
|
||||
handleMentionPickerClose,
|
||||
handlePromptPickerClose,
|
||||
handleWorkingDirectoryOpen,
|
||||
handleWorkingDirectoryClose,
|
||||
openPromptPicker() {
|
||||
isPromptPickerOpen = true;
|
||||
},
|
||||
closePromptPicker() {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
|
||||
/**
|
||||
* Shared debounced async-search machinery for the chat-form pickers:
|
||||
* AbortController + sequence counter to discard stale responses, a
|
||||
* debounce, and a live `isSearching` flag.
|
||||
*/
|
||||
|
||||
export interface UseDebouncedSearchOptions {
|
||||
debounceMs: number;
|
||||
/** Fire-time guard: a scheduled call that outlives a reset is dropped. */
|
||||
canRun: () => boolean;
|
||||
/** Live query, used to drop a scheduled call whose query changed. */
|
||||
getQuery: () => string;
|
||||
/** Perform the search and commit results; bail out when `isCurrent()` is false. */
|
||||
run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
|
||||
let controller: AbortController | null = null;
|
||||
let searchSeq = 0;
|
||||
let isSearching = $state(false);
|
||||
|
||||
function isCurrent(seq: number) {
|
||||
return seq === searchSeq;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
controller?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
const schedule = debounce((query: string) => {
|
||||
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
|
||||
void start(query);
|
||||
}, opts.debounceMs);
|
||||
|
||||
async function start(query: string) {
|
||||
cancel();
|
||||
const fresh = new AbortController();
|
||||
controller = fresh;
|
||||
const mySeq = ++searchSeq;
|
||||
isSearching = true;
|
||||
try {
|
||||
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
|
||||
} finally {
|
||||
if (isCurrent(mySeq)) isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get isSearching() {
|
||||
return isSearching;
|
||||
},
|
||||
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
|
||||
setLoading(value: boolean) {
|
||||
isSearching = value;
|
||||
},
|
||||
run(query: string) {
|
||||
schedule(query);
|
||||
},
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
singleModelName
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
@@ -139,11 +140,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
handleOpenChange(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||
'[data-slot="chat-form"] textarea'
|
||||
);
|
||||
const input = document.querySelector<HTMLElement>(CHAT_INPUT_FOCUS_SELECTOR);
|
||||
|
||||
textarea?.focus({ preventScroll: true });
|
||||
input?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Shared keyboard navigation state for the chat-form pickers: a highlighted
|
||||
* row, a scroll trigger, and Arrow/Escape/Enter handling.
|
||||
*/
|
||||
export interface UsePickerNavigationOptions {
|
||||
/** Gates all key handling. */
|
||||
isOpen: () => boolean;
|
||||
count: () => number;
|
||||
/**
|
||||
* Resolve the row to highlight for a movement step, or -1 when no move
|
||||
* is possible. Defaults to plain wraparound across `count()`.
|
||||
*/
|
||||
step?: (from: number, dir: 1 | -1) => number;
|
||||
onClose: () => void;
|
||||
/** Called on Enter when `hoveredIndex` points at a selectable row. */
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
function wrapStep(from: number, dir: 1 | -1, count: number): number {
|
||||
return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
|
||||
}
|
||||
|
||||
export function usePickerNavigation(opts: UsePickerNavigationOptions) {
|
||||
let hoveredIndex = $state(-1);
|
||||
let scrollTrigger = $state(0);
|
||||
|
||||
function resolve(from: number, dir: 1 | -1): number {
|
||||
const n = opts.count();
|
||||
if (n === 0) return -1;
|
||||
if (opts.step) return opts.step(from, dir);
|
||||
return wrapStep(from, dir, n);
|
||||
}
|
||||
|
||||
function move(dir: 1 | -1) {
|
||||
const next = resolve(hoveredIndex, dir);
|
||||
if (next >= 0) {
|
||||
hoveredIndex = next;
|
||||
scrollTrigger++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the highlight without bumping the scroll trigger. */
|
||||
function reset(index: number) {
|
||||
hoveredIndex = index;
|
||||
}
|
||||
|
||||
/** Bump the scroll trigger without moving the highlight. */
|
||||
function bumpScroll() {
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
/** Mouse hover highlights a row but must NOT bump the scroll trigger. */
|
||||
function setHover(index: number) {
|
||||
hoveredIndex = index;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!opts.isOpen()) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
opts.onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
|
||||
event.preventDefault();
|
||||
opts.onSelect(hoveredIndex);
|
||||
return true;
|
||||
}
|
||||
// No selectable row - let the caller's Enter-to-submit run.
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
get hoveredIndex() {
|
||||
return hoveredIndex;
|
||||
},
|
||||
get scrollTrigger() {
|
||||
return scrollTrigger;
|
||||
},
|
||||
reset,
|
||||
setHover,
|
||||
move,
|
||||
bumpScroll,
|
||||
handleKeydown
|
||||
};
|
||||
}
|
||||
|
||||
export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
/**
|
||||
* Scrolls the highlighted row of a picker list into view when the scroll
|
||||
* trigger is bumped, without scrolling on mouse hover or result
|
||||
* replacement.
|
||||
*/
|
||||
export interface UseScrollActiveRowOptions {
|
||||
/** Counter bumped by keyboard nav; `undefined` disables the effect. */
|
||||
getTrigger: () => number | undefined;
|
||||
getContainer: () => HTMLDivElement | null;
|
||||
getIndex: () => number;
|
||||
getCount: () => number;
|
||||
/** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
|
||||
dataIndex: string;
|
||||
}
|
||||
|
||||
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
|
||||
let lastTrigger: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const trigger = opts.getTrigger();
|
||||
if (trigger === undefined) return;
|
||||
|
||||
// Skip the initial run on mount: the list opens with the first row
|
||||
// already in view, and scrolling here fires before the popover is
|
||||
// positioned, which would scroll the whole page to the top.
|
||||
if (lastTrigger === null) {
|
||||
lastTrigger = trigger;
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger === lastTrigger) return;
|
||||
lastTrigger = trigger;
|
||||
untrack(() => {
|
||||
const container = opts.getContainer();
|
||||
const index = opts.getIndex();
|
||||
if (!container || index < 0 || index >= opts.getCount()) return;
|
||||
const row = container.querySelector(
|
||||
`[data-${opts.dataIndex}-index="${index}"]`
|
||||
) as HTMLElement | null;
|
||||
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type UseScrollActiveRowReturn = ReturnType<typeof useScrollActiveRow>;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { base } from '$app/paths';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { ServerModelStatus, ServerModelsSseEventType, ModelModality } from '$lib/enums';
|
||||
import {
|
||||
ServerModelStatus,
|
||||
ServerModelsSseEventType,
|
||||
ModelModality,
|
||||
FileTypeCategory
|
||||
} from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { PropsService } from '$lib/services/props.service';
|
||||
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||
@@ -394,6 +399,7 @@ class ModelsStore {
|
||||
model: modelId,
|
||||
description: details?.description,
|
||||
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
||||
modalities: this.buildArchitectureModalities(item.architecture),
|
||||
details: details?.details,
|
||||
meta: item.meta ?? null,
|
||||
parsedId: ModelsService.parseModelId(modelId),
|
||||
@@ -1000,6 +1006,21 @@ class ModelsStore {
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the router modalities, the only source available while a model is not loaded. */
|
||||
private buildArchitectureModalities(
|
||||
architecture: ApiModelDataEntry['architecture']
|
||||
): ModelModalities | undefined {
|
||||
if (!architecture) return undefined;
|
||||
|
||||
const inputs = architecture.input_modalities;
|
||||
|
||||
return {
|
||||
vision: inputs.includes(FileTypeCategory.IMAGE),
|
||||
audio: inputs.includes(FileTypeCategory.AUDIO),
|
||||
video: inputs.includes(FileTypeCategory.VIDEO)
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.unsubscribeStatus();
|
||||
this.statusWaiters.forEach((waiter) => waiter.reject(new Error('Models store cleared')));
|
||||
|
||||
@@ -135,6 +135,30 @@ class SettingsStore {
|
||||
...savedVal
|
||||
};
|
||||
|
||||
// Migrate the legacy render keys into `renderContentAsRawText`
|
||||
// (inverted semantics: the old keys opted INTO markdown). Any
|
||||
// explicit raw-text preference wins when the legacy keys disagree.
|
||||
const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
|
||||
const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
|
||||
const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
|
||||
(key) => key in savedVal
|
||||
);
|
||||
if (legacyKeys.length > 0) {
|
||||
if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
|
||||
if (LEGACY_RAW_TEXT_KEY in savedVal) {
|
||||
this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
|
||||
} else {
|
||||
this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
|
||||
(key) => key in savedVal
|
||||
).some((key) => savedVal[key] === false);
|
||||
}
|
||||
}
|
||||
for (const key of legacyKeys) {
|
||||
delete (this.config as Record<string, unknown>)[key];
|
||||
}
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||
if (isMobile.current) {
|
||||
|
||||
Vendored
+11
@@ -98,10 +98,21 @@ export interface ApiModelDataEntry {
|
||||
aliases?: string[];
|
||||
/** Informational tags for this model */
|
||||
tags?: string[];
|
||||
/** Modality capabilities, reported by the router for every model regardless of load state */
|
||||
architecture?: ApiModelArchitecture;
|
||||
/** Legacy meta field (may be present in older responses) */
|
||||
meta?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modality capabilities of a model, as advertised by the ROUTER /models endpoint.
|
||||
* Read from the model manifest, so it is available before the model is loaded.
|
||||
*/
|
||||
export interface ApiModelArchitecture {
|
||||
/** Accepted input modalities, always contains "text" */
|
||||
input_modalities: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stage reported by the /models/sse feed, in load order.
|
||||
*/
|
||||
|
||||
Vendored
+25
-1
@@ -1,4 +1,4 @@
|
||||
import type { ErrorDialogType } from '$lib/enums';
|
||||
import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
|
||||
import type { ApiChatCompletionToolCall } from './api';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
|
||||
|
||||
@@ -166,3 +166,27 @@ export interface FileProcessingResult {
|
||||
extras: DatabaseMessageExtra[];
|
||||
emptyFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A file or folder picked in the @-mention picker. `path` is the absolute
|
||||
* server-side path; `name` is the basename.
|
||||
*/
|
||||
export interface FileMentionEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
type: FileMentionEntryType;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slash command surfaced by the `/` command picker. `disabled` marks a
|
||||
* command whose backing capability is unavailable (e.g. `/prompt` when no
|
||||
* MCP server exposes prompts): visible but greyed out and not selectable.
|
||||
*/
|
||||
export interface ChatFormCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Extra search terms that should match this command in the picker. */
|
||||
keywords?: string[];
|
||||
action: ChatFormCommandAction;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ export type {
|
||||
LiveProcessingStats,
|
||||
LiveGenerationStats,
|
||||
AttachmentDisplayItemsOptions,
|
||||
FileProcessingResult
|
||||
FileProcessingResult,
|
||||
FileMentionEntry,
|
||||
ChatFormCommand
|
||||
} from './chat.d';
|
||||
|
||||
// Database types
|
||||
|
||||
Vendored
+6
@@ -31,6 +31,9 @@ export interface SettingsEntry {
|
||||
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dependsOn?: string;
|
||||
sync?: {
|
||||
serverKey: string;
|
||||
@@ -52,6 +55,9 @@ export interface SettingsFieldConfig {
|
||||
type: SettingsFieldType;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dependsOn?: string;
|
||||
help?: string;
|
||||
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
|
||||
|
||||
@@ -17,6 +17,56 @@ export interface IncompleteCodeBlock {
|
||||
openingIndex: number;
|
||||
}
|
||||
|
||||
// A fence line: up to 3 leading spaces (CommonMark), 3+ backticks, then
|
||||
// whatever trails on the same line.
|
||||
const FENCE_LINE_REGEX = /^ {0,3}(`{3,})(.*)$/;
|
||||
|
||||
/**
|
||||
* Splits text glued to a closing code fence onto its own line:
|
||||
*
|
||||
* ```ts
|
||||
* let foo = 'bar';
|
||||
* ```create this file on ...
|
||||
*
|
||||
* A closing fence with trailing text is not a fence to the markdown
|
||||
* parser, so the block would swallow the text as code. The chat form
|
||||
* normally keeps the fence on its own line, but older messages and
|
||||
* hand-pasted content can carry the glued form.
|
||||
*
|
||||
* Only trailing text containing whitespace is split: a single word
|
||||
* after the backticks inside a fenced block is more likely nested
|
||||
* markdown (a ```python example inside a ```md block) than glued prose.
|
||||
*/
|
||||
export function splitGluedClosingCodeFences(markdown: string): string {
|
||||
if (!markdown.includes('```')) return markdown;
|
||||
|
||||
const lines = markdown.split(NEWLINE);
|
||||
let inside = false;
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = FENCE_LINE_REGEX.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
|
||||
if (!inside) {
|
||||
inside = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
inside = false;
|
||||
|
||||
const trailing = match[2];
|
||||
if (trailing.includes('`') || !/\s/.test(trailing)) continue;
|
||||
|
||||
lines[i] = lines[i].slice(0, lines[i].length - trailing.length);
|
||||
lines.splice(i + 1, 0, trailing.trim());
|
||||
i++;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? lines.join(NEWLINE) : markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips empty lines (whitespace-only) from the start and end of code.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Slash-command token detection for the chat form. Valid only at offset 0.
|
||||
*/
|
||||
export function findCommandToken(
|
||||
value: string
|
||||
): { name: string; args: string; end: number } | null {
|
||||
if (!value.startsWith('/')) return null;
|
||||
|
||||
const rest = value.slice(1);
|
||||
const spaceIdx = rest.search(/\s/);
|
||||
const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
|
||||
|
||||
return { name, args, end: value.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of a slash-command token for use as a "dismissed"
|
||||
* marker: while the picker is closed and this exact token is still intact,
|
||||
* the picker does not re-open on in-token edits.
|
||||
*/
|
||||
export interface CommandDismissSnapshot {
|
||||
name: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
|
||||
const token = findCommandToken(value);
|
||||
if (!token) return null;
|
||||
return { name: token.name, args: token.args };
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
/**
|
||||
* Maps between the chat-form contenteditable's markdown source and the
|
||||
* badge/code/text token stream the DOM is built from. A badge is one
|
||||
* opaque source contribution (`[name](file://path)`); its own subtree
|
||||
* is never walked, and the caret cannot land inside it, so offsets
|
||||
* resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
|
||||
* are EDITABLE, unlike badges: they carry the full source segment
|
||||
* (backtick fences included) as their text, so their textContent
|
||||
* serializes verbatim and source offsets map 1:1 to text offsets.
|
||||
*
|
||||
* The tokenizer emits a flat DOM (text nodes + badges + code spans),
|
||||
* but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
|
||||
* shapes). Serialization folds those back into `\n` so the source
|
||||
* never diverges from what is on screen; both offset mappers
|
||||
* understand the same shapes.
|
||||
*
|
||||
* The newline separating a fenced block from adjacent content is a
|
||||
* SOURCE-level concept, never stored in the DOM: the block is
|
||||
* display:block, so a leading `\n` in the following text node would
|
||||
* render as a phantom empty line. Serialization synthesizes exactly
|
||||
* one `\n` at every block boundary and `buildFragment` strips it from
|
||||
* text tokens. A text node's own leading/trailing `\n` next to a
|
||||
* block is an ADDITIONAL blank line.
|
||||
*/
|
||||
|
||||
import {
|
||||
decodeFileLinkPath,
|
||||
fileMentionLinkRe,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel
|
||||
} from './mention-badge';
|
||||
import {
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
|
||||
export type ContentToken =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'badge'; name: string; path: string }
|
||||
| { kind: 'inlineCode'; text: string }
|
||||
| { kind: 'codeBlock'; text: string };
|
||||
|
||||
// Block wrappers browsers insert for newlines; each folds back into a
|
||||
// single `\n` during serialization.
|
||||
const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
|
||||
|
||||
// `file://` is required so plain URLs stay as text; `)` terminates only
|
||||
// when not followed by whitespace or `[` (adjacent badges keep working).
|
||||
const MENTION_BADGE_RE = fileMentionLinkRe('g');
|
||||
|
||||
function badgeSourceLength(name: string, path: string): number {
|
||||
if (!name || !path) return 0;
|
||||
return `[${name}](file://${path})`.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize complete code spans. Fenced blocks (triple backticks,
|
||||
* optional language, possibly multiline) take priority over inline
|
||||
* spans (single backticks, single line, non-empty). Only CLOSED
|
||||
* spans match: an unclosed fence stays plain text until the closing
|
||||
* backticks land. The match includes the fences so the token's
|
||||
* source length equals its rendered text length.
|
||||
*/
|
||||
const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
|
||||
|
||||
/**
|
||||
* Cheap gate check for `ChatForm`: does the buffer contain a
|
||||
* complete code span (inline or fenced)? Used to promote the plain
|
||||
* textarea to the contenteditable renderer.
|
||||
*/
|
||||
export function containsCodeSpan(value: string): boolean {
|
||||
CODE_SPAN_RE.lastIndex = 0;
|
||||
return CODE_SPAN_RE.test(value);
|
||||
}
|
||||
|
||||
const CODE_FENCE_RE = /```/g;
|
||||
|
||||
/**
|
||||
* Is `offset` inside a fenced code block region? Toggle-based: an
|
||||
* odd number of ``` fences before the offset means the position
|
||||
* sits in block content. Unlike `containsCodeSpan` this also
|
||||
* counts the still-OPEN fence while the user is typing a block
|
||||
* (no closing ``` yet), so Enter can add a line instead of
|
||||
* submitting the message.
|
||||
*/
|
||||
export function isOffsetInCodeBlock(source: string, offset: number): boolean {
|
||||
let inside = false;
|
||||
CODE_FENCE_RE.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CODE_FENCE_RE.exec(source)) !== null) {
|
||||
if (match.index + match[0].length > offset) break;
|
||||
inside = !inside;
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a markdown source value into the segments the
|
||||
* contenteditable will render. Code spans are carved out first
|
||||
* (their content is literal - a `file://` link inside backticks
|
||||
* must NOT render as a badge), then plain text and badges
|
||||
* interleave in the remaining gaps. Any whitespace after a badge
|
||||
* stays in a plain text token so the round trip is byte-exact.
|
||||
*/
|
||||
export function tokenizeContent(input: string): ContentToken[] {
|
||||
const tokens: ContentToken[] = [];
|
||||
let cursor = 0;
|
||||
CODE_SPAN_RE.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CODE_SPAN_RE.exec(input)) !== null) {
|
||||
const start = match.index;
|
||||
|
||||
if (start > cursor) {
|
||||
pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
|
||||
}
|
||||
|
||||
tokens.push(
|
||||
match[1] !== undefined
|
||||
? { kind: 'codeBlock', text: match[1] }
|
||||
: { kind: 'inlineCode', text: match[2] }
|
||||
);
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
|
||||
if (cursor < input.length) {
|
||||
pushTextAndBadgeTokens(input.slice(cursor), tokens);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a code-free segment into text and badge tokens.
|
||||
*/
|
||||
function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
|
||||
let cursor = 0;
|
||||
MENTION_BADGE_RE.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
|
||||
const [whole, name, path] = match;
|
||||
const start = match.index;
|
||||
|
||||
if (start > cursor) {
|
||||
tokens.push({ kind: 'text', text: input.slice(cursor, start) });
|
||||
}
|
||||
|
||||
tokens.push({ kind: 'badge', name, path });
|
||||
cursor = start + whole.length;
|
||||
}
|
||||
|
||||
if (cursor < input.length) {
|
||||
tokens.push({ kind: 'text', text: input.slice(cursor) });
|
||||
}
|
||||
}
|
||||
|
||||
function isCodeBlockElement(node: Node | null): node is HTMLElement {
|
||||
return node instanceof HTMLElement && node.dataset.codeToken === 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a contenteditable subtree back to source. `<br>` and block
|
||||
* wrappers the browser inserted for newlines fold back into `\n` (a
|
||||
* trailing `<br>` is the browser's caret placeholder, not a newline);
|
||||
* any other element is transparent. Code spans serialize their
|
||||
* textContent verbatim (fences included). One separator `\n` is
|
||||
* synthesized at every fenced-block boundary (the DOM never stores
|
||||
* it), and a `<br>` adjacent to a code block is an escape hatch, not
|
||||
* a newline.
|
||||
*/
|
||||
export function serializeContent(root: HTMLElement): string {
|
||||
let out = '';
|
||||
let pendingBlockBoundary = false;
|
||||
|
||||
const walk = (parent: Node) => {
|
||||
let first = true; // no source-contributing sibling seen yet
|
||||
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent ?? '';
|
||||
if (text.length > 0) {
|
||||
if (pendingBlockBoundary) {
|
||||
out += '\n';
|
||||
pendingBlockBoundary = false;
|
||||
}
|
||||
out += text;
|
||||
first = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
|
||||
if (el.dataset.mentionBadge === 'true') {
|
||||
const name = el.dataset.mentionName ?? '';
|
||||
const path = el.dataset.mentionPath ?? '';
|
||||
if (name && path) {
|
||||
if (pendingBlockBoundary) {
|
||||
out += '\n';
|
||||
pendingBlockBoundary = false;
|
||||
}
|
||||
out += `[${name}](file://${path})`;
|
||||
first = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.dataset.codeToken !== undefined) {
|
||||
const isBlock = el.dataset.codeToken === 'block';
|
||||
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
|
||||
pendingBlockBoundary = false;
|
||||
walk(el);
|
||||
first = false;
|
||||
if (isBlock) pendingBlockBoundary = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.tagName === 'BR') {
|
||||
const isHatch =
|
||||
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
|
||||
if (!isHatch && el.nextSibling) {
|
||||
if (pendingBlockBoundary) {
|
||||
out += '\n';
|
||||
pendingBlockBoundary = false;
|
||||
}
|
||||
out += '\n';
|
||||
first = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BLOCK_TAG_NAMES.has(el.tagName)) {
|
||||
if (pendingBlockBoundary || !first) out += '\n';
|
||||
pendingBlockBoundary = false;
|
||||
walk(el);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
walk(el);
|
||||
if (pendingBlockBoundary) first = false;
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the live DOM's non-text structure against a token stream.
|
||||
* Only element contributions are compared (badges by name/path, code
|
||||
* spans by kind and source segment): text nodes are owned by the
|
||||
* browser between rebuilds, so their split/merge state is irrelevant.
|
||||
* A mismatch means token boundaries shifted (a code span was just
|
||||
* completed or broken) and the DOM needs a rebuild to restyle.
|
||||
*/
|
||||
export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
|
||||
const expected = tokens.filter((token) => token.kind !== 'text');
|
||||
let index = 0;
|
||||
|
||||
const walk = (parent: Node): boolean => {
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
const isBadge = el.dataset.mentionBadge === 'true';
|
||||
const isCode = el.dataset.codeToken !== undefined;
|
||||
|
||||
if (!isBadge && !isCode) {
|
||||
if (!walk(el)) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = expected[index++];
|
||||
if (!token) return false;
|
||||
|
||||
if (isBadge) {
|
||||
if (token.kind !== 'badge') return false;
|
||||
if (token.name !== (el.dataset.mentionName ?? '')) return false;
|
||||
if (token.path !== (el.dataset.mentionPath ?? '')) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
|
||||
if (token.kind !== codeKind) return false;
|
||||
if (
|
||||
(token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
|
||||
token.text !== (el.textContent ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return walk(root) && index === expected.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text offset of a `Range` in the root; null range (selection
|
||||
* lost) falls back to buffer length. Walked against the live DOM (not
|
||||
* a clone) so a `<br>` keeps its trailing/not-trailing context. Code
|
||||
* spans count their full textContent (fences included) and the caret
|
||||
* may land inside them; synthesized block boundaries count one `\n`
|
||||
* once the caret is past them.
|
||||
*/
|
||||
export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
|
||||
if (!range) return serializeContent(root).length;
|
||||
|
||||
// A point is at/before the caret iff it falls inside [root start, caret].
|
||||
const pre = range.cloneRange();
|
||||
pre.selectNodeContents(root);
|
||||
pre.setEnd(range.endContainer, range.endOffset);
|
||||
const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
|
||||
|
||||
let total = 0;
|
||||
let done = false;
|
||||
// DOM position of a code block's synthesized after-boundary, set
|
||||
// when walking past a block and consumed by the next contributing
|
||||
// sibling (counts one `\n` once the caret is past it).
|
||||
let pendingPoint: { node: Node; index: number } | null = null;
|
||||
|
||||
const walk = (parent: Node) => {
|
||||
let first = true;
|
||||
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (done) return;
|
||||
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent ?? '';
|
||||
if (text.length === 0) continue;
|
||||
if (pendingPoint) {
|
||||
const { node, index } = pendingPoint;
|
||||
pendingPoint = null;
|
||||
if (!atOrBeforeCaret(node, index)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += 1;
|
||||
}
|
||||
if (!atOrBeforeCaret(child, 0)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
if (range.endContainer === child) {
|
||||
total += range.endOffset;
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += text.length;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
const parentNode = el.parentNode as Node;
|
||||
const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
|
||||
|
||||
if (pendingPoint) {
|
||||
const { node, index } = pendingPoint;
|
||||
pendingPoint = null;
|
||||
if (!atOrBeforeCaret(node, index)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += 1;
|
||||
}
|
||||
|
||||
if (el.dataset.mentionBadge === 'true') {
|
||||
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
|
||||
if (len === 0) continue;
|
||||
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += len;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.dataset.codeToken !== undefined) {
|
||||
const isBlock = el.dataset.codeToken === 'block';
|
||||
if (isBlock && !first) {
|
||||
if (!atOrBeforeCaret(el, 0)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += 1;
|
||||
}
|
||||
walk(el);
|
||||
first = false;
|
||||
if (isBlock) pendingPoint = { node: parentNode, index: elIndex + 1 };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.tagName === 'BR') {
|
||||
const isHatch =
|
||||
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
|
||||
if (isHatch || !el.nextSibling) continue;
|
||||
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += 1;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BLOCK_TAG_NAMES.has(el.tagName)) {
|
||||
if (!first) {
|
||||
if (!atOrBeforeCaret(el, 0)) {
|
||||
done = true;
|
||||
return;
|
||||
}
|
||||
total += 1;
|
||||
}
|
||||
walk(el);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = total;
|
||||
walk(el);
|
||||
if (total > before) first = false;
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize a token stream into a DOM subtree: text nodes for text
|
||||
* tokens, `<span data-mention-badge="true">` elements for badges,
|
||||
* `<code data-code-token>` elements for code spans. The badge's class
|
||||
* string + inline SVG are shared with the rehype plugin via
|
||||
* `$lib/constants/mention-badge`.
|
||||
*/
|
||||
export function buildFragment(tokens: ContentToken[]): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (let index = 0; index < tokens.length; index++) {
|
||||
const token = tokens[index];
|
||||
|
||||
if (token.kind === 'text') {
|
||||
let text = token.text;
|
||||
|
||||
// The separator \n at a fenced-block boundary is synthesized
|
||||
// at serialization time; keeping it in the DOM would render a
|
||||
// phantom empty line next to the block.
|
||||
if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
|
||||
text = text.slice(1);
|
||||
}
|
||||
if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
if (text.length === 0) continue;
|
||||
|
||||
fragment.appendChild(document.createTextNode(text));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
|
||||
const code = document.createElement('code');
|
||||
code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
|
||||
code.textContent = token.text;
|
||||
fragment.appendChild(code);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A leading badge gets an empty text node prepended: without a real
|
||||
// text position at the buffer start, the spot before the badge is
|
||||
// unreachable via keyboard (ArrowLeft/Home).
|
||||
if (!fragment.lastChild) {
|
||||
fragment.appendChild(document.createTextNode(''));
|
||||
}
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.dataset.mentionBadge = 'true';
|
||||
badge.dataset.mentionName = token.name;
|
||||
badge.dataset.mentionPath = token.path;
|
||||
badge.title = decodeFileLinkPath(token.path);
|
||||
badge.className = MENTION_BADGE_CLASSNAME;
|
||||
badge.contentEditable = 'false';
|
||||
|
||||
const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
|
||||
for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
|
||||
svg.setAttribute(attr, value);
|
||||
}
|
||||
for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
|
||||
svg.classList.add(cls);
|
||||
}
|
||||
|
||||
for (const d of getMentionBadgeIconPaths(token.path)) {
|
||||
const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
|
||||
path.setAttribute('d', d);
|
||||
svg.appendChild(path);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.classList.add('shrink-0', 'truncate');
|
||||
label.textContent = getMentionBadgeLabel(
|
||||
token.name,
|
||||
decodeFileLinkPath(token.path),
|
||||
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
|
||||
toolsStore.serverHome
|
||||
);
|
||||
|
||||
badge.appendChild(svg);
|
||||
badge.appendChild(label);
|
||||
fragment.appendChild(badge);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
// A sibling provides a reachable caret line when it is an element
|
||||
// (badge, another block, an existing hatch) or a non-empty text node.
|
||||
function hasLineBeside(node: Node | null): boolean {
|
||||
if (!node) return false;
|
||||
if (node.nodeType === Node.ELEMENT_NODE) return true;
|
||||
return (node.textContent ?? '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A code block at the END of the buffer needs an editable line after
|
||||
* it: without one the caret cannot leave the block with
|
||||
* ArrowDown/ArrowRight. A trailing `<br>` provides that line while
|
||||
* staying transparent to serialization (skipped as a hatch), and is
|
||||
* removed again once real content takes its place.
|
||||
*
|
||||
* No hatch is added BEFORE a leading block: the empty line above it
|
||||
* is transient and managed by the component (created when the caret
|
||||
* arrows onto it, removed when the caret leaves). A transient
|
||||
* leading hatch found here is kept; the browser's lone placeholder
|
||||
* `<br>` in an empty root is left untouched.
|
||||
*/
|
||||
export function syncCodeBlockHatches(root: HTMLElement) {
|
||||
for (const child of Array.from(root.childNodes)) {
|
||||
if (child.nodeName !== 'BR') continue;
|
||||
|
||||
const isPlaceholder = root.childNodes.length === 1;
|
||||
const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
|
||||
const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
|
||||
|
||||
// A hatch goes stale once real content takes over its line:
|
||||
// content before a leading hatch, content after a trailing one,
|
||||
// or a text node after the block already providing the line.
|
||||
// A `<br>` with no code block around is a real newline (browser
|
||||
// Shift+Enter shape) and stays.
|
||||
let prevElement = child.previousSibling;
|
||||
while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
|
||||
prevElement = prevElement.previousSibling;
|
||||
}
|
||||
const nearBlock =
|
||||
isCodeBlockElement(child.nextSibling) ||
|
||||
isCodeBlockElement(child.previousSibling) ||
|
||||
isCodeBlockElement(prevElement);
|
||||
|
||||
if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
|
||||
child.remove();
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of Array.from(root.childNodes)) {
|
||||
if (!isCodeBlockElement(child)) continue;
|
||||
|
||||
if (!hasLineBeside(child.nextSibling)) {
|
||||
child.after(document.createElement('br'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the separator and artificial newlines from an all-newline text
|
||||
* node directly after a fenced block. Chromium's line break at the
|
||||
* buffer end inserts an extra artificial `\n` so the new line has
|
||||
* height, and the first `\n` after a block doubles as the fence's
|
||||
* separator line (synthesized at serialization time). Removing both
|
||||
* makes Shift+Enter after a block land the caret on the line directly
|
||||
* below the block, like a plain textarea would.
|
||||
*
|
||||
* Only all-newline text nodes are touched: a node with real content
|
||||
* carries intentional blank lines and is left alone. Returns true when
|
||||
* the DOM changed.
|
||||
*/
|
||||
export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
|
||||
let changed = false;
|
||||
|
||||
for (const child of Array.from(root.childNodes)) {
|
||||
if (child.nodeType !== Node.TEXT_NODE) continue;
|
||||
if (!isCodeBlockElement(child.previousSibling)) continue;
|
||||
|
||||
let text = child.textContent ?? '';
|
||||
if (!/^\n{2,}$/.test(text)) continue;
|
||||
|
||||
text = text.slice(1);
|
||||
|
||||
const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
|
||||
if (atBufferEnd) {
|
||||
text = text.slice(0, -1);
|
||||
}
|
||||
|
||||
child.textContent = text;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
|
||||
|
||||
/**
|
||||
* Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
|
||||
* when the jump crosses no badge and native word movement should handle
|
||||
* it. Badge spans are masked to word characters, so a badge counts as
|
||||
* exactly one word.
|
||||
*/
|
||||
export function badgeAwareWordJump(
|
||||
source: string,
|
||||
offset: number,
|
||||
direction: 'forward' | 'backward'
|
||||
): number | null {
|
||||
let masked = '';
|
||||
const badgeSpans: Array<[number, number]> = [];
|
||||
|
||||
for (const token of tokenizeContent(source)) {
|
||||
const len =
|
||||
token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
|
||||
if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
|
||||
masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
|
||||
}
|
||||
|
||||
if (badgeSpans.length === 0) return null;
|
||||
|
||||
const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
|
||||
const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
|
||||
const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
|
||||
const n = masked.length;
|
||||
let i = offset;
|
||||
|
||||
if (direction === 'forward') {
|
||||
// Entering a badge completes the word phase at the badge's end edge.
|
||||
if (!(i < n && isWord(i))) {
|
||||
while (i < n && !isWord(i)) i++;
|
||||
}
|
||||
while (i < n && isWord(i)) {
|
||||
const span = spanStartingAt(i);
|
||||
if (span) {
|
||||
i = span[1];
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (!(i > 0 && isWord(i - 1))) {
|
||||
while (i > 0 && !isWord(i - 1)) i--;
|
||||
}
|
||||
while (i > 0 && isWord(i - 1)) {
|
||||
const span = spanEndingAt(i);
|
||||
if (span) {
|
||||
i = span[0];
|
||||
break;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
if (i === offset) return null;
|
||||
|
||||
const lo = Math.min(offset, i);
|
||||
const hi = Math.max(offset, i);
|
||||
return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 when `caret` sits exactly at a leading badge's end edge, null
|
||||
* otherwise. Plain ArrowLeft there has no native previous position, so
|
||||
* the host snaps the caret to the buffer start manually.
|
||||
*/
|
||||
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
|
||||
const [first] = tokenizeContent(source);
|
||||
if (!first || first.kind !== 'badge') return null;
|
||||
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a plain-text offset into a degenerate `Range` at that
|
||||
* position in the DOM; out-of-range offsets clamp to buffer end (before
|
||||
* a trailing escape hatch, not after it). Zero offset lands BEFORE a
|
||||
* badge or code span, and an offset exactly at a code span's end lands
|
||||
* AFTER it, so typing at a code span's edge extends the surrounding
|
||||
* text. Interior code-span offsets land in the element's text.
|
||||
* Understands the same block/`<br>` newline shapes as
|
||||
* `serializeContent`.
|
||||
*/
|
||||
export function textOffsetToRange(root: HTMLElement, offset: number): Range {
|
||||
const range = document.createRange();
|
||||
let remaining = offset;
|
||||
let landed = false;
|
||||
let pendingBlockBoundary = false;
|
||||
|
||||
const land = (node: Node, nodeOffset: number) => {
|
||||
range.setStart(node, nodeOffset);
|
||||
range.setEnd(node, nodeOffset);
|
||||
landed = true;
|
||||
};
|
||||
|
||||
const walk = (parent: Node) => {
|
||||
let first = true;
|
||||
|
||||
for (const child of Array.from(parent.childNodes)) {
|
||||
if (landed) return;
|
||||
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent ?? '';
|
||||
if (text.length === 0) continue;
|
||||
if (pendingBlockBoundary) {
|
||||
// The synthesized separator maps to the near edge of the
|
||||
// content that follows the block.
|
||||
pendingBlockBoundary = false;
|
||||
if (remaining === 0) {
|
||||
land(child, 0);
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
if (remaining <= text.length) {
|
||||
land(child, remaining);
|
||||
return;
|
||||
}
|
||||
remaining -= text.length;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
|
||||
if (el.dataset.mentionBadge === 'true') {
|
||||
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
|
||||
if (len === 0) continue;
|
||||
if (pendingBlockBoundary) {
|
||||
pendingBlockBoundary = false;
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
if (remaining <= len) {
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
} else {
|
||||
range.setStartAfter(el);
|
||||
range.setEndAfter(el);
|
||||
}
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
remaining -= len;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.dataset.codeToken !== undefined) {
|
||||
const isBlock = el.dataset.codeToken === 'block';
|
||||
if (isBlock && (pendingBlockBoundary || !first)) {
|
||||
pendingBlockBoundary = false;
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
const len = (el.textContent ?? '').length;
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
if (remaining === len) {
|
||||
range.setStartAfter(el);
|
||||
range.setEndAfter(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
if (remaining < len) {
|
||||
walk(el);
|
||||
return;
|
||||
}
|
||||
remaining -= len;
|
||||
if (isBlock) remaining -= 1;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.tagName === 'BR') {
|
||||
const isHatch =
|
||||
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
|
||||
if (isHatch) {
|
||||
// Escape hatch: no source length; offset 0 lands before it
|
||||
// so text typed there takes its place.
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!el.nextSibling) continue;
|
||||
if (pendingBlockBoundary) {
|
||||
pendingBlockBoundary = false;
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
if (remaining === 0) {
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (BLOCK_TAG_NAMES.has(el.tagName)) {
|
||||
if (pendingBlockBoundary || !first) {
|
||||
pendingBlockBoundary = false;
|
||||
if (remaining === 0) {
|
||||
// The boundary newline belongs to the previous line.
|
||||
range.setStartBefore(el);
|
||||
range.setEndBefore(el);
|
||||
landed = true;
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
}
|
||||
walk(el);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = remaining;
|
||||
walk(el);
|
||||
if (remaining < before) first = false;
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
|
||||
if (!landed) {
|
||||
const last = root.lastChild;
|
||||
if (last && last.nodeName === 'BR') {
|
||||
range.setStartBefore(last);
|
||||
range.setEndBefore(last);
|
||||
} else {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
}
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Shared `file_glob_search` runners with a short-lived result cache, so a
|
||||
* repeated query for the same (type, path, glob, depth) reuses the last
|
||||
* result instead of re-walking the tree.
|
||||
*/
|
||||
|
||||
import { BuiltInTool, GlobSearchType } from '$lib/enums';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import {
|
||||
GLOB_WILDCARD,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
PATH_SEPARATOR,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { lastPathSegment } from './path-display';
|
||||
import {
|
||||
buildGlobSearchArgs,
|
||||
joinPath,
|
||||
rankEntries,
|
||||
type GlobEntry,
|
||||
type GlobSearchArgs
|
||||
} from './working-directory';
|
||||
|
||||
const SEARCH_CACHE_TTL_MS = 2000;
|
||||
|
||||
interface CacheEntry {
|
||||
results: GlobEntry[];
|
||||
base: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const searchCache = new Map<string, CacheEntry>();
|
||||
|
||||
export interface GlobSearchResult {
|
||||
base: string;
|
||||
entries: GlobEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function runGlobSearch(
|
||||
args: GlobSearchArgs,
|
||||
type: GlobSearchType,
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
): Promise<GlobSearchResult> {
|
||||
const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
|
||||
const cached = searchCache.get(key);
|
||||
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
|
||||
return { base: cached.base, entries: cached.results };
|
||||
}
|
||||
|
||||
const res = await ToolsService.executeToolRaw(
|
||||
BuiltInTool.FILE_GLOB_SEARCH,
|
||||
{ path: args.path, type, include: args.include, max_depth: args.maxDepth, limit },
|
||||
signal
|
||||
);
|
||||
|
||||
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
|
||||
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const now = Date.now();
|
||||
// prune stale entries so the short-lived cache cannot grow unbounded
|
||||
for (const [k, v] of searchCache) {
|
||||
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
|
||||
}
|
||||
searchCache.set(key, { results: entries, base, at: now });
|
||||
return { base, entries };
|
||||
}
|
||||
|
||||
export interface GlobEntryResult {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface GlobSearchChildOptions {
|
||||
type?: GlobSearchType;
|
||||
/** Descend only on a trailing path separator (mention picker); off for
|
||||
* the WD picker, which descends on any exact match. */
|
||||
descendOnTrailingSeparator?: boolean;
|
||||
childMaxDepth?: number;
|
||||
}
|
||||
|
||||
export interface GlobSearchChildResult {
|
||||
base: string;
|
||||
args: GlobSearchArgs;
|
||||
/** Outer ranked entries plus the walked directory's children (absolute). */
|
||||
entries: GlobEntryResult[];
|
||||
/** Absolute path of the directory whose children were appended. */
|
||||
exactDir?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
|
||||
return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
|
||||
}
|
||||
|
||||
/**
|
||||
* One ranked glob search that may also list the matched directory's
|
||||
* children, shared by the WD picker (descend on exact match) and the
|
||||
* mention picker (descend on a trailing `/` or `\`).
|
||||
*/
|
||||
export async function runGlobSearchWithChildren(
|
||||
query: string,
|
||||
scopePath: string,
|
||||
searchDepth: number,
|
||||
limit: number,
|
||||
signal: AbortSignal,
|
||||
options: GlobSearchChildOptions = {}
|
||||
): Promise<GlobSearchChildResult> {
|
||||
const {
|
||||
type = GlobSearchType.ALL,
|
||||
descendOnTrailingSeparator = false,
|
||||
childMaxDepth = PATH_NAV_MAX_DEPTH
|
||||
} = options;
|
||||
|
||||
const args = buildGlobSearchArgs(query, scopePath, searchDepth);
|
||||
const res = await runGlobSearch(args, type, limit, signal);
|
||||
if (res.error) return { base: res.base, args, entries: [], error: res.error };
|
||||
|
||||
const ranked = rankEntries(res.entries, args.rankQuery);
|
||||
const entries = ranked.map((e) => toEntryResult(e, res.base));
|
||||
|
||||
const last = args.last;
|
||||
if (last) {
|
||||
const wantsDescend = descendOnTrailingSeparator
|
||||
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
|
||||
: true;
|
||||
const exact = ranked.find(
|
||||
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
|
||||
);
|
||||
if (wantsDescend && exact) {
|
||||
const exactDir = joinPath(res.base, exact.path);
|
||||
const childRes = await runGlobSearch(
|
||||
{ path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
|
||||
type,
|
||||
limit,
|
||||
signal
|
||||
);
|
||||
if (!childRes.error) {
|
||||
const children = childRes.entries
|
||||
.map((e) => toEntryResult(e, childRes.base))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return { base: res.base, args, entries: [...entries, ...children], exactDir };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { base: res.base, args, entries };
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
export {
|
||||
highlightCode,
|
||||
detectIncompleteCodeBlock,
|
||||
splitGluedClosingCodeFences,
|
||||
trimCodePadding,
|
||||
type IncompleteCodeBlock
|
||||
} from './code';
|
||||
@@ -174,13 +175,74 @@ export {
|
||||
export {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch,
|
||||
type GlobEntry,
|
||||
type GlobSearchArgs,
|
||||
type PathQuery
|
||||
} from './working-directory';
|
||||
|
||||
// Shared `file_glob_search` runner with a short-lived result cache
|
||||
export {
|
||||
runGlobSearch,
|
||||
runGlobSearchWithChildren,
|
||||
type GlobEntryResult,
|
||||
type GlobSearchResult
|
||||
} from './glob-search';
|
||||
|
||||
// Mention-token detection (for the `@`-triggered file/folder mention picker)
|
||||
export {
|
||||
findMentionToken,
|
||||
takeMentionDismissSnapshot,
|
||||
type MentionDismissSnapshot
|
||||
} from './mention-token';
|
||||
|
||||
// Slash-command token detection (for the `/`-triggered command picker)
|
||||
export {
|
||||
findCommandToken,
|
||||
takeCommandDismissSnapshot,
|
||||
type CommandDismissSnapshot
|
||||
} from './command-token';
|
||||
|
||||
// Tokenization for the chat-form contenteditable (mention links + code spans <-> chip DOM)
|
||||
export {
|
||||
tokenizeContent,
|
||||
containsCodeSpan,
|
||||
isOffsetInCodeBlock,
|
||||
domMatchesTokens,
|
||||
syncCodeBlockHatches,
|
||||
stripBlockBoundaryLineBreaks,
|
||||
serializeContent,
|
||||
buildFragment,
|
||||
rangeToTextOffset,
|
||||
textOffsetToRange,
|
||||
badgeAwareWordJump,
|
||||
leadingBadgeEdgeOffset,
|
||||
type ContentToken
|
||||
} from './contenteditable-tokenizer';
|
||||
|
||||
// Source-space undo/redo history for the chat-form contenteditable
|
||||
export { SourceHistory, type SourceHistoryEntry } from './source-history';
|
||||
|
||||
// Mention-badge visual contract (used by the contenteditable / rehype
|
||||
// DOM paths that build the same chip without a Svelte mount)
|
||||
export {
|
||||
containsFileMentionLink,
|
||||
fileMentionLinkRe,
|
||||
encodeFileLinkPath,
|
||||
decodeFileLinkPath,
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel,
|
||||
buildMentionInsertion
|
||||
} from './mention-badge';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { abbreviateHome, lastPathSegment } from './path-display';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/constants/mention-badge';
|
||||
import { FILE_URI_PREFIX } from '$lib/constants';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
import type { FileMentionEntry } from '$lib/types';
|
||||
|
||||
export {
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/constants/mention-badge';
|
||||
|
||||
// `)` is allowed in a path only when not followed by whitespace or `[`,
|
||||
// so macOS paths parse while adjacent badges still terminate the match.
|
||||
const FILE_MENTION_LINK_SOURCE = String.raw`\[([^\]\n]+?)\]\(file:\/\/((?:[^)\n]|\)(?![\s[]))+)\)`;
|
||||
|
||||
export function fileMentionLinkRe(flags = ''): RegExp {
|
||||
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
|
||||
}
|
||||
|
||||
export function containsFileMentionLink(value: string): boolean {
|
||||
return fileMentionLinkRe().test(value);
|
||||
}
|
||||
|
||||
// Escape each path segment for a markdown link destination (spaces/parens
|
||||
// break CommonMark); keeps the trailing slash that marks a directory.
|
||||
export function encodeFileLinkPath(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
// Malformed escape sequences fall back to the input unchanged.
|
||||
export function decodeFileLinkPath(path: string): string {
|
||||
try {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => decodeURIComponent(segment))
|
||||
.join('/');
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMentionBadgeIconPaths(path: string): readonly string[] {
|
||||
return path.endsWith('/') ? MENTION_BADGE_FOLDER_ICON_PATHS : MENTION_BADGE_FILE_ICON_PATHS;
|
||||
}
|
||||
|
||||
export function getMentionBadgeLabel(
|
||||
name: string,
|
||||
path: string,
|
||||
showFullPath: boolean,
|
||||
home?: string | null
|
||||
): string {
|
||||
if (!showFullPath) return name;
|
||||
const decoded = decodeFileLinkPath(path.replace(/\/+$/, ''));
|
||||
if (!decoded) return name;
|
||||
return abbreviateHome(decoded, home);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the markdown link that replaces a mention token. Entry `path` is
|
||||
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
|
||||
* Null when the token is invalid.
|
||||
*/
|
||||
export function buildMentionInsertion(
|
||||
entry: FileMentionEntry,
|
||||
value: string,
|
||||
token: { start: number; end: number }
|
||||
): { newValue: string; caretOffset: number } | null {
|
||||
if (token.start < 0 || token.end > value.length || token.start > token.end) return null;
|
||||
// Strip the entry's directory marker so it is not doubled below.
|
||||
const cleanedPath = entry.path.replace(/\/+$/, '');
|
||||
const pathWithSeparator =
|
||||
entry.type === FileMentionEntryType.DIRECTORY ? `${cleanedPath}/` : cleanedPath;
|
||||
const basename = lastPathSegment(cleanedPath) || entry.name;
|
||||
const insertion = `[${basename}](${FILE_URI_PREFIX}${encodeFileLinkPath(pathWithSeparator)}) `;
|
||||
const newValue = value.slice(0, token.start) + insertion + value.slice(token.end);
|
||||
return { newValue, caretOffset: token.start + insertion.length };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// An `@` starts a mention only when preceded by start-of-string or one of
|
||||
// these; identifier chars are not delimiters, so a mid-word `@` does not.
|
||||
const TOKEN_BOUNDARY_CHARS = new Set([
|
||||
' ',
|
||||
'\t',
|
||||
'\n',
|
||||
'\r',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
',',
|
||||
';',
|
||||
':',
|
||||
'"',
|
||||
"'"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the most-recent `@`-mention token whose extent includes `cursor`;
|
||||
* the query covers the whole `@...` token regardless of caret position.
|
||||
*/
|
||||
export function findMentionToken(
|
||||
value: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; query: string } | null {
|
||||
if (cursor <= 0 || cursor > value.length) return null;
|
||||
|
||||
let atIndex = -1;
|
||||
for (let i = cursor - 1; i >= 0; i--) {
|
||||
const ch = value[i];
|
||||
if (ch === '@') {
|
||||
const prev = i > 0 ? value[i - 1] : '';
|
||||
if (i === 0 || TOKEN_BOUNDARY_CHARS.has(prev)) {
|
||||
atIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (TOKEN_BOUNDARY_CHARS.has(ch)) break;
|
||||
}
|
||||
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
let end = atIndex + 1;
|
||||
while (end < value.length && !TOKEN_BOUNDARY_CHARS.has(value[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
return {
|
||||
start: atIndex,
|
||||
end,
|
||||
query: value.slice(atIndex + 1, end)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of a mention token for use as a "dismissed" marker:
|
||||
* while the picker is closed and this exact token is still intact, the
|
||||
* picker does not silently re-open on in-token edits.
|
||||
*/
|
||||
export interface MentionDismissSnapshot {
|
||||
start: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function takeMentionDismissSnapshot(
|
||||
value: string,
|
||||
cursor: number
|
||||
): MentionDismissSnapshot | null {
|
||||
const token = findMentionToken(value, cursor);
|
||||
if (!token) return null;
|
||||
return { start: token.start, query: token.query };
|
||||
}
|
||||
@@ -9,22 +9,14 @@ import {
|
||||
HOME_TILDE_PREFIX
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Last non-empty slash-delimited segment of `path`, with trailing
|
||||
* slashes stripped. Returns the input unchanged when no `/` is present.
|
||||
*/
|
||||
export function lastPathSegment(p: string): string {
|
||||
const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
|
||||
const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
|
||||
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbreviate `path` to `~/...` when it sits under `home`, or to `~` when
|
||||
* it equals `home`. Falls back to `lastPathSegment(path)` when home is
|
||||
* unknown or the path is outside it. `~` semantics are reserved for the
|
||||
* home directory, mirroring how shells render it.
|
||||
*/
|
||||
// `~/...` under `home`; falls back to the basename when home is unknown
|
||||
// or the path is outside it.
|
||||
export function abbreviateWorkingDir(
|
||||
path: string | null | undefined,
|
||||
home: string | null | undefined
|
||||
@@ -37,12 +29,8 @@ export function abbreviateWorkingDir(
|
||||
return lastPathSegment(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a leading `home` prefix in `path` with `~`. Unlike
|
||||
* abbreviateWorkingDir, paths outside `home` (or an unknown home) are
|
||||
* returned unchanged - used for tool-call path displays where the full
|
||||
* path matters.
|
||||
*/
|
||||
// Unlike abbreviateWorkingDir, paths outside `home` are returned
|
||||
// unchanged - used where the full path matters.
|
||||
export function abbreviateHome(path: string, home: string | null | undefined): string {
|
||||
if (!home) return path;
|
||||
if (path === home) return HOME_TILDE;
|
||||
@@ -61,10 +49,9 @@ export interface CwdMessageInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a synthetic cwd-change message. The text mirrors what the UI
|
||||
* renders for it; the path travels as `[file:///abs/path](display)` so
|
||||
* both the absolute and the short form are visible to the model and
|
||||
* parseable back by the UI.
|
||||
* Format a synthetic cwd-change message. The path travels as
|
||||
* `[file:///abs/path](display)` so both the absolute and short form are
|
||||
* visible to the model and parseable back by the UI.
|
||||
*/
|
||||
export function formatCwdMessage(cwd: string, home: string | null): string {
|
||||
const display = abbreviateWorkingDir(cwd, home);
|
||||
@@ -72,10 +59,9 @@ export function formatCwdMessage(cwd: string, home: string | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a synthetic cwd message back into its parts. The caller must already
|
||||
* know the message is synthetic (via the persisted `isSynthetic` flag); this
|
||||
* only extracts the path from the message text. Returns null when `content`
|
||||
* is not a cwd message.
|
||||
* Parse a synthetic cwd message back into its parts. The caller must
|
||||
* already know the message is synthetic (via the persisted `isSynthetic`
|
||||
* flag); this only extracts the path.
|
||||
*/
|
||||
export function parseCwdMessage(content: string): CwdMessageInfo | null {
|
||||
const trimmed = content.trim();
|
||||
|
||||
@@ -19,7 +19,9 @@ export function modelLoadStageLabel(stage: ApiModelLoadStage): string {
|
||||
export function modelLoadFraction(progress: ModelLoadProgress | null): number {
|
||||
if (!progress) return 0;
|
||||
|
||||
const { stages, current, value } = progress;
|
||||
// The server may emit a progress event before the stage plan is known, so
|
||||
// `stages` can be absent. Fall back to the raw value in that case.
|
||||
const { stages = [], current, value } = progress;
|
||||
const tailCount = Math.max(stages.length - 1, 0);
|
||||
const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
|
||||
const idx = stages.indexOf(current);
|
||||
@@ -39,5 +41,7 @@ export function modelLoadProgressText(progress: ModelLoadProgress | null): strin
|
||||
if (!progress) return null;
|
||||
|
||||
const label = modelLoadStageLabel(progress.current);
|
||||
if (!label) return null;
|
||||
|
||||
return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Source-space undo/redo history for the chat-form contenteditable, whose
|
||||
* imperative DOM rebuilds destroy the browser's native undo stack.
|
||||
* Entries record the state BEFORE an edit; edits within `groupWindowMs`
|
||||
* extend the open group so a typing burst undoes as a unit, while
|
||||
* structural edits (paste, mention insert, clear) pass `newGroup`.
|
||||
*/
|
||||
|
||||
export interface SourceHistoryEntry {
|
||||
value: string;
|
||||
caret: number;
|
||||
}
|
||||
|
||||
export class SourceHistory {
|
||||
private undoStack: SourceHistoryEntry[] = [];
|
||||
private redoStack: SourceHistoryEntry[] = [];
|
||||
private lastPush = 0;
|
||||
|
||||
constructor(
|
||||
private limit = 100,
|
||||
private groupWindowMs = 800
|
||||
) {}
|
||||
|
||||
push(entry: SourceHistoryEntry, now: number, newGroup = false): void {
|
||||
if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) {
|
||||
this.undoStack.push(entry);
|
||||
if (this.undoStack.length > this.limit) this.undoStack.shift();
|
||||
}
|
||||
this.lastPush = now;
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
|
||||
const entry = this.undoStack.pop();
|
||||
if (!entry) return null;
|
||||
this.redoStack.push(current);
|
||||
this.lastPush = 0; // the next edit after an undo starts a new group
|
||||
return entry;
|
||||
}
|
||||
|
||||
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
|
||||
const entry = this.redoStack.pop();
|
||||
if (!entry) return null;
|
||||
this.undoStack.push(current);
|
||||
this.lastPush = 0;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Pure helpers for the working-directory picker search.
|
||||
*
|
||||
* The picker is backed by the server's `file_glob_search` built-in tool.
|
||||
* Queries that start from a root (`/`, `C:\`, `\\host\share`) or from `~`
|
||||
* navigate the directory tree (search the parent for the last segment);
|
||||
* anything else glob-matches home-relative entries. Paths are carried with
|
||||
* `/` separators, which is what the server returns and what Windows accepts.
|
||||
* These helpers build the glob, normalize results and rank them
|
||||
* client-side; the component owns the network/state plumbing.
|
||||
* Pure helpers for the working-directory picker search, backed by the
|
||||
* server's `file_glob_search` tool. Queries starting from a root (`/`,
|
||||
* `C:\`, `\\host\share`) or `~` navigate the tree (search the parent for
|
||||
* the last segment); anything else glob-matches home-relative entries.
|
||||
*/
|
||||
|
||||
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
|
||||
@@ -21,6 +16,7 @@ import {
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
LEADING_SLASHES_REGEX,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
UNC_ROOT_REGEX,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
@@ -45,10 +41,6 @@ function toPosixSeparators(query: string): string {
|
||||
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the root prefix of `path`, or 0 when it has none. Covers the
|
||||
* POSIX root, a Windows drive (`C:/`) and a UNC share (`//host/share/`).
|
||||
*/
|
||||
export function rootPrefixLength(path: string): number {
|
||||
const unc = path.match(UNC_ROOT_REGEX);
|
||||
if (unc) return unc[0].length;
|
||||
@@ -84,7 +76,6 @@ export function splitPathQuery(query: string): PathQuery | null {
|
||||
return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
|
||||
}
|
||||
|
||||
/** Build a case-insensitive glob that matches `query` anywhere within a name. */
|
||||
export function buildCaseInsensitiveGlob(query: string): string {
|
||||
let out = GLOB_WILDCARD;
|
||||
for (const c of query) {
|
||||
@@ -99,7 +90,33 @@ export function buildCaseInsensitiveGlob(query: string): string {
|
||||
return out + GLOB_WILDCARD;
|
||||
}
|
||||
|
||||
/** Exact basename first, then prefix, then substring; lower is better. */
|
||||
export interface GlobSearchArgs {
|
||||
path: string;
|
||||
include: string;
|
||||
maxDepth: number;
|
||||
rankQuery: string;
|
||||
/** Last segment of a path-navigation query (`~/dir/sub`), undefined for
|
||||
* a plain home-relative glob. Lets callers act on the exact targeted
|
||||
* segment (e.g. the WD picker "entering" a directory). */
|
||||
last?: string;
|
||||
}
|
||||
|
||||
export function buildGlobSearchArgs(
|
||||
query: string,
|
||||
scopePath: string,
|
||||
searchDepth: number
|
||||
): GlobSearchArgs {
|
||||
const pathQuery = splitPathQuery(query);
|
||||
const path = pathQuery ? pathQuery.parent : scopePath;
|
||||
const include = pathQuery
|
||||
? pathQuery.last
|
||||
? buildCaseInsensitiveGlob(pathQuery.last)
|
||||
: GLOB_WILDCARD
|
||||
: buildCaseInsensitiveGlob(query);
|
||||
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
|
||||
return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
|
||||
}
|
||||
|
||||
const RANK_EXACT = 0;
|
||||
const RANK_PREFIX = 1;
|
||||
const RANK_SUBSTRING = 2;
|
||||
@@ -114,7 +131,6 @@ function rankScore(path: string, query: string): number {
|
||||
return RANK_OTHER;
|
||||
}
|
||||
|
||||
/** Sort entries by relevance, then shorter path, then alphabetically. */
|
||||
export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
|
||||
return [...entries].sort(
|
||||
(a, b) =>
|
||||
@@ -124,13 +140,11 @@ export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
|
||||
);
|
||||
}
|
||||
|
||||
/** Join a base path and a relative segment, avoiding duplicate slashes. */
|
||||
export function joinPath(base: string, rel: string): string {
|
||||
if (!base) return rel;
|
||||
return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
|
||||
}
|
||||
|
||||
/** Split `text` into alternating segments at each case-insensitive `query` match. */
|
||||
export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
|
||||
if (!query) return [{ text, match: false }];
|
||||
const segments: { text: string; match: boolean }[] = [];
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { APP_NAME, NEW_CHAT_PARAM } from '$lib/constants';
|
||||
import { APP_NAME, URL_PARAMS } from '$lib/constants';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get('q'));
|
||||
let modelParam = $derived(page.url.searchParams.get('model'));
|
||||
let newChatParam = $derived(page.url.searchParams.get(NEW_CHAT_PARAM));
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
|
||||
let newChatParam = $derived(page.url.searchParams.get(URL_PARAMS.NEW_CHAT));
|
||||
let loadParam = $derived(page.url.searchParams.get(URL_PARAMS.LOAD));
|
||||
|
||||
// Dialog state for model not available error
|
||||
let showModelNotAvailable = $state(false);
|
||||
@@ -23,9 +25,10 @@
|
||||
function clearUrlParams() {
|
||||
const url = new URL(page.url);
|
||||
|
||||
url.searchParams.delete('q');
|
||||
url.searchParams.delete('model');
|
||||
url.searchParams.delete(NEW_CHAT_PARAM);
|
||||
url.searchParams.delete(URL_PARAMS.QUERY);
|
||||
url.searchParams.delete(URL_PARAMS.MODEL);
|
||||
url.searchParams.delete(URL_PARAMS.LOAD);
|
||||
url.searchParams.delete(URL_PARAMS.NEW_CHAT);
|
||||
|
||||
replaceState(url.toString(), {});
|
||||
}
|
||||
@@ -39,6 +42,14 @@
|
||||
if (model) {
|
||||
try {
|
||||
await modelsStore.selectModelById(model.id);
|
||||
|
||||
// with ?load=true, start loading right away so the model is ready sooner;
|
||||
// not awaited, so the UI stays usable during the load
|
||||
if (loadParam === 'true' && isRouterMode() && !modelsStore.isModelLoaded(model.id)) {
|
||||
modelsStore
|
||||
.loadModel(model.id)
|
||||
.catch((error) => console.error('Failed to load model:', error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select model:', error);
|
||||
requestedModelName = modelParam;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { APP_NAME, ROUTES } from '$lib/constants';
|
||||
import { APP_NAME, ROUTES, URL_PARAMS } from '$lib/constants';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
@@ -12,8 +12,8 @@
|
||||
let currentChatId: string | undefined = undefined;
|
||||
|
||||
// URL parameters for prompt and model selection
|
||||
let qParam = $derived(page.url.searchParams.get('q'));
|
||||
let modelParam = $derived(page.url.searchParams.get('model'));
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
|
||||
|
||||
// Dialog state for model not available error
|
||||
let showModelNotAvailable = $state(false);
|
||||
@@ -28,8 +28,8 @@
|
||||
*/
|
||||
function clearUrlParams() {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('q');
|
||||
url.searchParams.delete('model');
|
||||
url.searchParams.delete(URL_PARAMS.QUERY);
|
||||
url.searchParams.delete(URL_PARAMS.MODEL);
|
||||
replaceState(url.toString(), {});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Guards the newline contract of the chat-form contenteditable: browsers
|
||||
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
|
||||
// serialization must fold those back into `\n` so the emitted value never
|
||||
// diverges from what is on screen.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b) here';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
function fireInput(root: HTMLElement) {
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function setCaret(node: Node, offset: number) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error('no selection');
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'second line';
|
||||
root.appendChild(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const first = document.createElement('div');
|
||||
while (root.firstChild) first.appendChild(root.firstChild);
|
||||
const second = document.createElement('div');
|
||||
second.textContent = 'second line';
|
||||
root.appendChild(first);
|
||||
root.appendChild(second);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a <br> as a newline', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
root.appendChild(document.createElement('br'));
|
||||
root.appendChild(document.createTextNode('second line'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('here\nsecond line');
|
||||
});
|
||||
|
||||
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
root.appendChild(document.createElement('br'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.appendChild(div);
|
||||
}
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc\n\n');
|
||||
});
|
||||
|
||||
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.replaceChildren(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(root.dataset.empty).toBe('true');
|
||||
});
|
||||
|
||||
it('maps the caret across block boundaries in both directions', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
|
||||
await tick();
|
||||
|
||||
// Rebuild into the Chromium block shape; the source is unchanged,
|
||||
// so no re-render fires.
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = 'def';
|
||||
root.replaceChildren(document.createTextNode('abc'), div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc\ndef');
|
||||
|
||||
const divText = div.firstChild;
|
||||
if (!divText) throw new Error('div text missing');
|
||||
|
||||
setCaret(divText, 2);
|
||||
expect(screen.component.getCaretOffset()).toBe(6);
|
||||
|
||||
screen.component.setCaretOffset(6);
|
||||
const selection = window.getSelection();
|
||||
expect(selection?.anchorNode).toBe(divText);
|
||||
expect(selection?.anchorOffset).toBe(2);
|
||||
|
||||
// The boundary newline itself: offset 3 is the end of "abc", offset
|
||||
// 4 the start of the "def" line.
|
||||
screen.component.setCaretOffset(4);
|
||||
expect(window.getSelection()?.anchorNode).toBe(divText);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(0);
|
||||
|
||||
screen.component.setCaretOffset(3);
|
||||
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// Guards the editing-key contract of the chat-form contenteditable:
|
||||
// undo/redo is replayed from source snapshots (the token rebuilds destroy
|
||||
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||
// keyboard trap), matching the plain textarea.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b)';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
|
||||
return el;
|
||||
}
|
||||
|
||||
function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
||||
root.appendChild(document.createTextNode(text));
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
|
||||
}
|
||||
|
||||
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
root.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable undo/redo', () => {
|
||||
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
|
||||
const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(undoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
await tick();
|
||||
expect(redoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('redoes with Ctrl+Y as well', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', metaKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
keydown(root, { key: 'y', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('coalesces a typing burst into one undo step', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, 'd');
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcde');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('keeps a newline as its own undo step', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, 'd');
|
||||
type(root, '\n', 'insertLineBreak');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd\n');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('is a no-op when there is nothing to undo', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('abandons the redo branch after a fresh edit', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
type(root, 'd');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abce');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormContenteditable Tab key', () => {
|
||||
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'Tab' });
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user