mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-05 03:08:02 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea215d171 | ||
|
|
4308a4f035 | ||
|
|
474c92e722 | ||
|
|
a6aa6f5450 | ||
|
|
76c956c137 | ||
|
|
2f56fc3431 |
+4
-4
@@ -2008,9 +2008,9 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
|
||||
).set_sampling());
|
||||
add_opt(common_arg(
|
||||
{"--repeat-last-n"}, "N",
|
||||
string_format("last n tokens to consider for penalize (default: %d, 0 = disabled, -1 = ctx_size)", params.sampling.penalty_last_n),
|
||||
string_format("last n tokens to consider for penalize (default: %d, 0 = disabled)", params.sampling.penalty_last_n),
|
||||
[](common_params & params, int value) {
|
||||
if (value < -1) {
|
||||
if (value < 0) {
|
||||
throw std::runtime_error(string_format("error: invalid repeat-last-n = %d\n", value));
|
||||
}
|
||||
params.sampling.penalty_last_n = value;
|
||||
@@ -2081,9 +2081,9 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
|
||||
).set_sampling());
|
||||
add_opt(common_arg(
|
||||
{"--dry-penalty-last-n"}, "N",
|
||||
string_format("set DRY penalty for the last n tokens (default: %d, 0 = disable, -1 = context size)", params.sampling.dry_penalty_last_n),
|
||||
string_format("set DRY penalty for the last n tokens (default: %d, 0 = disable)", params.sampling.dry_penalty_last_n),
|
||||
[](common_params & params, int value) {
|
||||
if (value < -1) {
|
||||
if (value < 0) {
|
||||
throw std::runtime_error(string_format("error: invalid dry-penalty-last-n = %d\n", value));
|
||||
}
|
||||
params.sampling.dry_penalty_last_n = value;
|
||||
|
||||
+1
-12
@@ -1302,23 +1302,12 @@ common_init_result::common_init_result(common_params & params, bool model_only)
|
||||
params.sampling.logit_bias_eog.begin(), params.sampling.logit_bias_eog.end());
|
||||
}
|
||||
|
||||
//if (params.sampling.penalty_last_n == -1) {
|
||||
// LOG_TRC("%s: setting penalty_last_n to ctx_size = %d\n", __func__, llama_n_ctx(lctx));
|
||||
// params.sampling.penalty_last_n = llama_n_ctx(lctx);
|
||||
//}
|
||||
|
||||
//if (params.sampling.dry_penalty_last_n == -1) {
|
||||
// LOG_TRC("%s: setting dry_penalty_last_n to ctx_size = %d\n", __func__, llama_n_ctx(lctx));
|
||||
// params.sampling.dry_penalty_last_n = llama_n_ctx(lctx);
|
||||
//}
|
||||
|
||||
// init the backend samplers as part of the context creation
|
||||
pimpl->samplers.resize(cparams.n_seq_max);
|
||||
pimpl->samplers_seq_config.resize(cparams.n_seq_max);
|
||||
|
||||
const int32_t n_ctx = cparams.n_ctx > 0 ? (int32_t) cparams.n_ctx : llama_model_n_ctx_train(model);
|
||||
for (int i = 0; i < (int) cparams.n_seq_max; ++i) {
|
||||
pimpl->samplers[i].reset(common_sampler_init(model, params.sampling, n_ctx));
|
||||
pimpl->samplers[i].reset(common_sampler_init(model, params.sampling));
|
||||
pimpl->samplers_seq_config[i] = { i, common_sampler_get(pimpl->samplers[i].get()) };
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -235,14 +235,14 @@ struct common_params_sampling {
|
||||
float temp = 0.80f; // <= 0.0 to sample greedily, 0.0 to not output probabilities
|
||||
float dynatemp_range = 0.00f; // 0.0 = disabled
|
||||
float dynatemp_exponent = 1.00f; // controls how entropy maps to temperature in dynamic temperature sampler
|
||||
int32_t penalty_last_n = 64; // last n tokens to penalize (0 = disable penalty, -1 = context size)
|
||||
int32_t penalty_last_n = 64; // last n tokens to penalize (0 = disable penalty)
|
||||
float penalty_repeat = 1.00f; // 1.0 = disabled
|
||||
float penalty_freq = 0.00f; // 0.0 = disabled
|
||||
float penalty_present = 0.00f; // 0.0 = disabled
|
||||
float dry_multiplier = 0.0f; // 0.0 = disabled; DRY repetition penalty for tokens extending repetition:
|
||||
float dry_base = 1.75f; // 0.0 = disabled; multiplier * base ^ (length of sequence before token - allowed length)
|
||||
int32_t dry_allowed_length = 2; // tokens extending repetitions beyond this receive penalty
|
||||
int32_t dry_penalty_last_n = -1; // how many tokens to scan for repetitions (0 = disable penalty, -1 = context size)
|
||||
int32_t dry_penalty_last_n = 64; // how many tokens to scan for repetitions (0 = disable penalty)
|
||||
float adaptive_target = -1.0f; // select tokens near this probability (valid range 0.0 to 1.0; negative = disabled)
|
||||
float adaptive_decay = 0.90f; // EMA decay for adaptation; history ≈ 1/(1-decay) tokens (0.0 - 0.99)
|
||||
int32_t mirostat = 0; // 0 = disabled, 1 = mirostat, 2 = mirostat 2.0
|
||||
|
||||
+2
-7
@@ -186,8 +186,7 @@ std::string common_params_sampling::print() const {
|
||||
|
||||
struct common_sampler * common_sampler_init(
|
||||
const struct llama_model * model,
|
||||
struct common_params_sampling & params,
|
||||
int32_t n_ctx) {
|
||||
struct common_params_sampling & params) {
|
||||
if (!std::isfinite(params.penalty_repeat) ||
|
||||
params.penalty_repeat <= 0.0f ||
|
||||
!std::isfinite(1.0f/params.penalty_repeat)) {
|
||||
@@ -199,10 +198,6 @@ struct common_sampler * common_sampler_init(
|
||||
if (!std::isfinite(params.penalty_present)) {
|
||||
throw std::invalid_argument("penalty_present must be finite");
|
||||
}
|
||||
if (params.penalty_last_n == -1) {
|
||||
params.penalty_last_n = n_ctx > 0 ? n_ctx : llama_model_n_ctx_train(model);
|
||||
}
|
||||
|
||||
const llama_vocab * vocab = llama_model_get_vocab(model);
|
||||
llama_sampler_chain_params lparams = llama_sampler_chain_default_params();
|
||||
|
||||
@@ -355,7 +350,7 @@ struct common_sampler * common_sampler_init(
|
||||
for (const auto & str : params.dry_sequence_breakers) {
|
||||
c_breakers.push_back(str.c_str());
|
||||
}
|
||||
samplers.push_back(llama_sampler_init_dry(vocab, llama_model_n_ctx_train(model), params.dry_multiplier, params.dry_base, params.dry_allowed_length, params.dry_penalty_last_n, c_breakers.data(), c_breakers.size()));
|
||||
samplers.push_back(llama_sampler_init_dry(vocab, params.dry_multiplier, params.dry_base, params.dry_allowed_length, params.dry_penalty_last_n, c_breakers.data(), c_breakers.size()));
|
||||
}
|
||||
break;
|
||||
case COMMON_SAMPLER_TYPE_TOP_K:
|
||||
|
||||
+1
-2
@@ -39,8 +39,7 @@ struct common_sampler;
|
||||
// note: can mutate params in some cases
|
||||
struct common_sampler * common_sampler_init(
|
||||
const struct llama_model * model,
|
||||
struct common_params_sampling & params,
|
||||
int32_t n_ctx = 0);
|
||||
struct common_params_sampling & params);
|
||||
|
||||
void common_sampler_free(struct common_sampler * gsmpl);
|
||||
|
||||
|
||||
+2
-3
@@ -1425,7 +1425,7 @@ extern "C" {
|
||||
/// NOTE: Avoid using on the full vocabulary as searching for repeated tokens can become slow. For example, apply top-k or top-p sampling first.
|
||||
LLAMA_API struct llama_sampler * llama_sampler_init_penalties(
|
||||
int32_t n_vocab,
|
||||
int32_t penalty_last_n, // last n tokens to penalize (0 = disable penalty, -1 = context size)
|
||||
int32_t penalty_last_n, // last n tokens to penalize (0 = disable penalty)
|
||||
float penalty_repeat, // must be > 0.0, 1.0 = disabled
|
||||
float penalty_freq, // must be finite, 0.0 = disabled
|
||||
float penalty_present); // must be finite, 0.0 = disabled
|
||||
@@ -1433,11 +1433,10 @@ extern "C" {
|
||||
/// @details DRY sampler, designed by p-e-w, as described in: https://github.com/oobabooga/text-generation-webui/pull/5677, porting Koboldcpp implementation authored by pi6am: https://github.com/LostRuins/koboldcpp/pull/982
|
||||
LLAMA_API struct llama_sampler * llama_sampler_init_dry(
|
||||
const struct llama_vocab * vocab,
|
||||
int32_t n_ctx_train,
|
||||
float dry_multiplier,
|
||||
float dry_base,
|
||||
int32_t dry_allowed_length,
|
||||
int32_t dry_penalty_last_n,
|
||||
int32_t dry_penalty_last_n, // last n tokens to penalize (0 = disable penalty)
|
||||
const char ** seq_breakers,
|
||||
size_t num_breakers);
|
||||
|
||||
|
||||
@@ -123,15 +123,15 @@ function(npm_build out_var)
|
||||
endif()
|
||||
|
||||
if(need_install)
|
||||
message(STATUS "UI: running npm install")
|
||||
message(STATUS "UI: running npm ci")
|
||||
execute_process(
|
||||
COMMAND ${NPM_EXECUTABLE} install
|
||||
COMMAND ${NPM_EXECUTABLE} ci
|
||||
WORKING_DIRECTORY "${WORK_DIR}"
|
||||
RESULT_VARIABLE rc
|
||||
ERROR_VARIABLE err
|
||||
)
|
||||
if(NOT rc EQUAL 0)
|
||||
message(STATUS "UI: npm install failed (${rc})")
|
||||
message(STATUS "UI: npm ci failed (${rc})")
|
||||
message(STATUS " stderr: ${err}")
|
||||
return()
|
||||
endif()
|
||||
|
||||
+8
-12
@@ -3078,8 +3078,6 @@ struct llama_sampler * llama_sampler_init_top_n_sigma(float n) {
|
||||
// DRY
|
||||
|
||||
struct llama_sampler_dry {
|
||||
int32_t total_context_size;
|
||||
|
||||
const float dry_multiplier;
|
||||
const float dry_base;
|
||||
const int32_t dry_allowed_length;
|
||||
@@ -3155,8 +3153,7 @@ static void llama_sampler_dry_apply(struct llama_sampler * smpl, llama_token_dat
|
||||
return;
|
||||
}
|
||||
|
||||
int32_t effective_dry_penalty_last_n = (ctx->dry_penalty_last_n == -1) ? ctx->total_context_size : std::max(ctx->dry_penalty_last_n, 0);
|
||||
int last_n_repeat = std::min(std::min((int)ctx->last_tokens.size(), effective_dry_penalty_last_n), ctx->total_context_size);
|
||||
int last_n_repeat = std::min((int) ctx->last_tokens.size(), ctx->dry_penalty_last_n);
|
||||
|
||||
if (last_n_repeat <= ctx->dry_allowed_length) {
|
||||
return;
|
||||
@@ -3369,7 +3366,7 @@ static struct llama_sampler * llama_sampler_dry_clone(const struct llama_sampler
|
||||
llama_vocab dummy_vocab;
|
||||
|
||||
// dummy vocab is passed because it is only needed for raw sequence breaker processing, which we have already done and will simply be copying
|
||||
auto * result = llama_sampler_init_dry(&dummy_vocab, ctx->total_context_size, ctx->dry_multiplier, ctx->dry_base, ctx->dry_allowed_length, ctx->dry_penalty_last_n, NULL, 0);
|
||||
auto * result = llama_sampler_init_dry(&dummy_vocab, ctx->dry_multiplier, ctx->dry_base, ctx->dry_allowed_length, ctx->dry_penalty_last_n, NULL, 0);
|
||||
|
||||
// Copy the state, including the processed breakers
|
||||
{
|
||||
@@ -3400,8 +3397,8 @@ static struct llama_sampler_i llama_sampler_dry_i = {
|
||||
/* .backend_set_input = */ nullptr,
|
||||
};
|
||||
|
||||
struct llama_sampler * llama_sampler_init_dry(const struct llama_vocab * vocab, int32_t n_ctx_train, float dry_multiplier, float dry_base, int32_t dry_allowed_length, int32_t dry_penalty_last_n, const char** seq_breakers, size_t num_breakers) {
|
||||
int32_t effective_dry_penalty_last_n = (dry_penalty_last_n == -1) ? n_ctx_train : std::max(dry_penalty_last_n, 0);
|
||||
struct llama_sampler * llama_sampler_init_dry(const struct llama_vocab * vocab, float dry_multiplier, float dry_base, int32_t dry_allowed_length, int32_t dry_penalty_last_n, const char** seq_breakers, size_t num_breakers) {
|
||||
dry_penalty_last_n = std::max(dry_penalty_last_n, 0);
|
||||
std::unordered_multimap<llama_token, std::vector<llama_token>> processed_breakers;
|
||||
const int MAX_CHAR_LEN = 40;
|
||||
const int MAX_SEQ_LEN = 20;
|
||||
@@ -3438,23 +3435,22 @@ struct llama_sampler * llama_sampler_init_dry(const struct llama_vocab * vocab,
|
||||
return llama_sampler_init(
|
||||
/* .iface = */ &llama_sampler_dry_i,
|
||||
/* .ctx = */ new llama_sampler_dry {
|
||||
/* .total_context_size = */ n_ctx_train,
|
||||
/* .dry_multiplier = */ dry_multiplier,
|
||||
/* .dry_base = */ dry_base,
|
||||
/* .dry_allowed_length = */ dry_allowed_length,
|
||||
/* .dry_penalty_last_n = */ dry_penalty_last_n,
|
||||
/* .dry_processed_breakers = */ std::move(processed_breakers),
|
||||
/* .dry_repeat_count = */ dry_enabled ? std::vector<int>(effective_dry_penalty_last_n, 0) : std::vector<int>{},
|
||||
/* .dry_repeat_count = */ dry_enabled ? std::vector<int>(dry_penalty_last_n, 0) : std::vector<int>{},
|
||||
/* .dry_max_token_repeat = */ {},
|
||||
/* .last_tokens = */ dry_enabled ? ring_buffer<llama_token>(effective_dry_penalty_last_n) : ring_buffer<llama_token>(0),
|
||||
/* .last_tokens = */ dry_enabled ? ring_buffer<llama_token>(dry_penalty_last_n) : ring_buffer<llama_token>(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// wrapper for test-sampling.cpp
|
||||
struct llama_sampler * llama_sampler_init_dry_testing(int32_t context_size, float dry_multiplier, float dry_base, int32_t dry_allowed_length, int32_t dry_penalty_last_n, const std::vector<std::vector<llama_token>>& seq_breakers) {
|
||||
struct llama_sampler * llama_sampler_init_dry_testing(float dry_multiplier, float dry_base, int32_t dry_allowed_length, int32_t dry_penalty_last_n, const std::vector<std::vector<llama_token>>& seq_breakers) {
|
||||
llama_vocab dummy_vocab;
|
||||
auto * result = llama_sampler_init_dry(&dummy_vocab, context_size, dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n, NULL, 0);
|
||||
auto * result = llama_sampler_init_dry(&dummy_vocab, dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n, NULL, 0);
|
||||
auto * ctx = (llama_sampler_dry *) result->ctx;
|
||||
|
||||
// Process the token-based sequence breakers
|
||||
|
||||
@@ -34,7 +34,6 @@ struct llama_sampler_chain {
|
||||
};
|
||||
|
||||
struct llama_sampler * llama_sampler_init_dry_testing(
|
||||
int32_t context_size,
|
||||
float dry_multiplier,
|
||||
float dry_base,
|
||||
int32_t dry_allowed_length,
|
||||
|
||||
@@ -101,6 +101,14 @@ static void test(void) {
|
||||
|
||||
{
|
||||
common_params penalty_params;
|
||||
assert(penalty_params.sampling.penalty_last_n == 64);
|
||||
assert(penalty_params.sampling.dry_penalty_last_n == 64);
|
||||
|
||||
argv = {"binary_name", "--repeat-last-n", "-1"};
|
||||
assert(false == common_params_parse(argv.size(), list_str_to_char(argv).data(), penalty_params, LLAMA_EXAMPLE_COMMON));
|
||||
|
||||
argv = {"binary_name", "--dry-penalty-last-n", "-1"};
|
||||
assert(false == common_params_parse(argv.size(), list_str_to_char(argv).data(), penalty_params, LLAMA_EXAMPLE_COMMON));
|
||||
|
||||
argv = {"binary_name", "--repeat-penalty", "0"};
|
||||
assert(false == common_params_parse(argv.size(), list_str_to_char(argv).data(), penalty_params, LLAMA_EXAMPLE_COMMON));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
extern struct llama_sampler * llama_sampler_init_dry_testing(int32_t context_size, float dry_multiplier, float dry_base, int32_t dry_allowed_length, int32_t dry_penalty_last_n, const std::vector<std::vector<llama_token>>& seq_breakers);
|
||||
extern struct llama_sampler * llama_sampler_init_dry_testing(float dry_multiplier, float dry_base, int32_t dry_allowed_length, int32_t dry_penalty_last_n, const std::vector<std::vector<llama_token>>& seq_breakers);
|
||||
|
||||
static void dump(const llama_token_data_array * cur_p) {
|
||||
for (size_t i = 0; i < cur_p->size; i++) {
|
||||
@@ -168,7 +168,7 @@ static void test_dry(
|
||||
|
||||
sampler_tester tester(probs, expected_probs);
|
||||
|
||||
auto * sampler = llama_sampler_init_dry_testing(1024, dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n, seq_breakers);
|
||||
auto * sampler = llama_sampler_init_dry_testing(dry_multiplier, dry_base, dry_allowed_length, dry_penalty_last_n, seq_breakers);
|
||||
|
||||
for (size_t i = 0; i < last_tokens.size(); i++) {
|
||||
llama_sampler_accept(sampler, last_tokens[i]);
|
||||
|
||||
+2
-2
@@ -116,14 +116,14 @@
|
||||
| `--xtc-probability N` | xtc probability (default: 0.00, 0.0 = disabled) |
|
||||
| `--xtc-threshold N` | xtc threshold (default: 0.10, 1.0 = disabled) |
|
||||
| `--typical, --typical-p N` | locally typical sampling, parameter p (default: 1.00, 1.0 = disabled) |
|
||||
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled, -1 = ctx_size) |
|
||||
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled) |
|
||||
| `--repeat-penalty N` | penalize repeat sequence of tokens (default: 1.00, 1.0 = disabled) |
|
||||
| `--presence-penalty N` | repeat alpha presence penalty (default: 0.00, 0.0 = disabled) |
|
||||
| `--frequency-penalty N` | repeat alpha frequency penalty (default: 0.00, 0.0 = disabled) |
|
||||
| `--dry-multiplier N` | set DRY sampling multiplier (default: 0.00, 0.0 = disabled) |
|
||||
| `--dry-base N` | set DRY sampling base value (default: 1.75) |
|
||||
| `--dry-allowed-length N` | set allowed length for DRY sampling (default: 2) |
|
||||
| `--dry-penalty-last-n N` | set DRY penalty for the last n tokens (default: -1, 0 = disable, -1 = context size) |
|
||||
| `--dry-penalty-last-n N` | set DRY penalty for the last n tokens (default: 64, 0 = disable) |
|
||||
| `--dry-sequence-breaker STRING` | add sequence breaker for DRY sampling, clearing out default breakers ('\n', ':', '"', '*') in the process; use "none" to not use any sequence breakers |
|
||||
| `--adaptive-target N` | adaptive-p: select tokens near this probability (valid range 0.0 to 1.0; negative = disabled) (default: -1.00)<br/>[(more info)](https://github.com/ggml-org/llama.cpp/pull/17927) |
|
||||
| `--adaptive-decay N` | adaptive-p: decay rate for target adaptation over time. lower values are more reactive, higher values are more stable.<br/>(valid range 0.0 to 0.99) (default: 0.90) |
|
||||
|
||||
@@ -199,14 +199,14 @@ llama-completion.exe -m models\gemma-1.1-7b-it.Q4_K_M.gguf --ignore-eos -n -1
|
||||
| `--xtc-probability N` | xtc probability (default: 0.00, 0.0 = disabled) |
|
||||
| `--xtc-threshold N` | xtc threshold (default: 0.10, 1.0 = disabled) |
|
||||
| `--typical, --typical-p N` | locally typical sampling, parameter p (default: 1.00, 1.0 = disabled) |
|
||||
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled, -1 = ctx_size) |
|
||||
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled) |
|
||||
| `--repeat-penalty N` | penalize repeat sequence of tokens (default: 1.00, 1.0 = disabled) |
|
||||
| `--presence-penalty N` | repeat alpha presence penalty (default: 0.00, 0.0 = disabled) |
|
||||
| `--frequency-penalty N` | repeat alpha frequency penalty (default: 0.00, 0.0 = disabled) |
|
||||
| `--dry-multiplier N` | set DRY sampling multiplier (default: 0.00, 0.0 = disabled) |
|
||||
| `--dry-base N` | set DRY sampling base value (default: 1.75) |
|
||||
| `--dry-allowed-length N` | set allowed length for DRY sampling (default: 2) |
|
||||
| `--dry-penalty-last-n N` | set DRY penalty for the last n tokens (default: -1, 0 = disable, -1 = context size) |
|
||||
| `--dry-penalty-last-n N` | set DRY penalty for the last n tokens (default: 64, 0 = disable) |
|
||||
| `--dry-sequence-breaker STRING` | add sequence breaker for DRY sampling, clearing out default breakers ('\n', ':', '"', '*') in the process; use "none" to not use any sequence breakers |
|
||||
| `--adaptive-target N` | adaptive-p: select tokens near this probability (valid range 0.0 to 1.0; negative = disabled) (default: -1.00)<br/>[(more info)](https://github.com/ggml-org/llama.cpp/pull/17927) |
|
||||
| `--adaptive-decay N` | adaptive-p: decay rate for target adaptation over time. lower values are more reactive, higher values are more stable.<br/>(valid range 0.0 to 0.99) (default: 0.90) |
|
||||
@@ -388,11 +388,11 @@ Example usage: `--temp 0`
|
||||
### Repeat Penalty
|
||||
|
||||
- `--repeat-penalty N`: Control the repetition of token sequences in the generated text default: 1.0, 1.0 = disabled).
|
||||
- `--repeat-last-n N`: Last n tokens to consider for penalizing repetition (default: 64, 0 = disabled, -1 = ctx-size).
|
||||
- `--repeat-last-n N`: Last n tokens to consider for penalizing repetition (default: 64, 0 = disabled).
|
||||
|
||||
The `repeat-penalty` option helps prevent the model from generating repetitive or monotonous text. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. The default value is 1.
|
||||
|
||||
The `repeat-last-n` option controls the number of tokens in the history to consider for penalizing repetition. A larger value will look further back in the generated text to prevent repetitions, while a smaller value will only consider recent tokens. A value of 0 disables the penalty, and a value of -1 sets the number of tokens considered equal to the context size (`ctx-size`).
|
||||
The `repeat-last-n` option controls the number of tokens in the history to consider for penalizing repetition. A larger value will look further back in the generated text to prevent repetitions, while a smaller value will only consider recent tokens. A value of 0 disables the penalty.
|
||||
|
||||
### DRY Repetition Penalty
|
||||
|
||||
@@ -401,7 +401,7 @@ DRY (Don't Repeat Yourself) sampling is an effective technique for reducing repe
|
||||
- `--dry-multiplier N`: Set the DRY sampling multiplier (default: 0.0, 0.0 = disabled).
|
||||
- `--dry-base N`: Set the DRY sampling base value (default: 1.75).
|
||||
- `--dry-allowed-length N`: Set the allowed length for DRY sampling (default: 2).
|
||||
- `--dry-penalty-last-n N`: Set DRY penalty for the last n tokens (default: -1, 0 = disable, -1 = context size).
|
||||
- `--dry-penalty-last-n N`: Set DRY penalty for the last n tokens (default: 64, 0 = disable).
|
||||
- `--dry-sequence-breaker STRING`: Add a sequence breaker for DRY sampling. Can be used more than once to add multiple sequence breakers. Using this clears out the default breakers, which consist of: `['\n', ':', '"', '*']`. If the string `"none"` is supplied, no sequence breakers are used.
|
||||
|
||||
The `dry-multiplier` option controls the strength of the DRY sampling effect. A value of 0.0 disables DRY sampling, while higher values increase its influence. A typical recommended value is 0.8.
|
||||
@@ -410,13 +410,13 @@ The `dry-base` option sets the base value for the exponential penalty calculatio
|
||||
|
||||
The `dry-allowed-length` option sets the maximum length of repeated sequences that will not be penalized. Repetitions shorter than or equal to this length are not penalized, allowing for natural repetitions of short phrases or common words.
|
||||
|
||||
The `dry-penalty-last-n` option controls how many recent tokens to consider when applying the DRY penalty. A value of -1 considers the entire context. Use a positive value to limit the consideration to a specific number of recent tokens.
|
||||
The `dry-penalty-last-n` option controls how many recent tokens to consider when applying the DRY penalty. A value of 0 disables the penalty. Use a positive value to limit the consideration to a specific number of recent tokens.
|
||||
|
||||
The `dry-sequence-breaker` option adds a single sequence breaker and can be used more than once to specify multiple sequence breakers. Sequence breakers interrupt sequence matching and break the input into parts where matching can be applied.
|
||||
|
||||
DRY sampling provides more nuanced control over text generation, particularly for reducing long-range repetitions and maintaining global coherence.
|
||||
|
||||
Example usage: `--dry-multiplier 0.8 --dry-base 1.75 --dry-allowed-length 2 --dry-penalty-last-n -1 --dry-sequence-breaker "—" --dry-sequence-breaker "##"`
|
||||
Example usage: `--dry-multiplier 0.8 --dry-base 1.75 --dry-allowed-length 2 --dry-penalty-last-n 64 --dry-sequence-breaker "—" --dry-sequence-breaker "##"`
|
||||
|
||||
### Top-K Sampling
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ struct split_params {
|
||||
std::string output;
|
||||
bool no_tensor_first_split = false;
|
||||
bool dry_run = false;
|
||||
bool delete_splits = false;
|
||||
};
|
||||
|
||||
static void split_print_usage(const char * executable) {
|
||||
@@ -65,6 +66,7 @@ static void split_print_usage(const char * executable) {
|
||||
printf(" --split-max-size N(M|G) max size per split\n");
|
||||
printf(" --no-tensor-first-split do not add tensors to the first split (disabled by default)\n");
|
||||
printf(" --dry-run only print out a split plan and exit, without writing any new files\n");
|
||||
printf(" --delete-splits delete the split files during merge to free up disk space WARNING: this option is unsafe and will leave you in an unrecoverable state if something fails during the merge\n");
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
@@ -147,6 +149,9 @@ static void split_params_parse_ex(int argc, const char ** argv, split_params & p
|
||||
}
|
||||
params.mode = MODE_SIZE;
|
||||
params.n_bytes_split = split_str_to_n_bytes(argv[arg_idx]);
|
||||
} else if (arg == "--delete-splits") {
|
||||
arg_found = true;
|
||||
params.delete_splits = true;
|
||||
}
|
||||
|
||||
if (!arg_found) {
|
||||
@@ -509,6 +514,7 @@ static void gguf_merge(const split_params & split_params) {
|
||||
}
|
||||
|
||||
// Write tensors data
|
||||
bool merge_error = false;
|
||||
for (int i_split = 0; i_split < n_split; i_split++) {
|
||||
llama_split_path(split_path, sizeof(split_path), split_prefix, i_split, n_split);
|
||||
std::ifstream f_input(split_path, std::ios::binary);
|
||||
@@ -554,6 +560,16 @@ static void gguf_merge(const split_params & split_params) {
|
||||
ggml_free(ctx_meta);
|
||||
f_input.close();
|
||||
fprintf(stderr, "\033[3Ddone\n");
|
||||
|
||||
if (!split_params.dry_run && split_params.delete_splits) {
|
||||
int delete_result = std::remove(split_path);
|
||||
if (delete_result != 0) {
|
||||
merge_error = true;
|
||||
fprintf(stderr, "error: failed to delete %s\n", split_path);
|
||||
} else {
|
||||
fprintf(stderr, "%s: deleted file %s\n", __func__, split_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!split_params.dry_run) {
|
||||
@@ -568,6 +584,10 @@ static void gguf_merge(const split_params & split_params) {
|
||||
|
||||
fprintf(stderr, "%s: %s merged from %d split with %d tensors.\n",
|
||||
__func__, split_params.output.c_str(), n_split, total_tensors);
|
||||
|
||||
if (merge_error) {
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, const char ** argv) {
|
||||
|
||||
@@ -66,12 +66,12 @@ echo PASS
|
||||
echo
|
||||
|
||||
# 5. Merge
|
||||
#$SPLIT --merge $WORK_PATH/ggml-model-split-32-tensors-00001-of-00012.gguf $WORK_PATH/ggml-model-merge-2.gguf
|
||||
#$SPLIT --merge $WORK_PATH/ggml-model-split-32-tensors-00001-of-00011.gguf $WORK_PATH/ggml-model-merge-2.gguf
|
||||
#echo PASS
|
||||
#echo
|
||||
|
||||
# 5b. Test the merged model is loading properly
|
||||
#$MAIN -no-cnv --model $WORK_PATH/ggml-model-merge-2.gguf --n-predict 32
|
||||
#$MAIN -no-cnv --model $WORK_PATH/ggml-model-merge-2.gguf -p "I believe the meaning of life is" --n-predict 32
|
||||
#echo PASS
|
||||
#echo
|
||||
|
||||
@@ -85,5 +85,25 @@ $MAIN -no-cnv --model $WORK_PATH/ggml-model-split-500M-00001-of-00002.gguf -p "I
|
||||
echo PASS
|
||||
echo
|
||||
|
||||
# 7. Merge with delete splits
|
||||
#for i in $(seq -w 1 11); do
|
||||
# cp "$WORK_PATH/ggml-model-split-32-tensors-000${i}-of-00011.gguf" "$WORK_PATH/ggml-model-split-32-tensors-copy-000${i}-of-00011.gguf"
|
||||
#done
|
||||
#$SPLIT --merge --delete-splits $WORK_PATH/ggml-model-split-32-tensors-copy-00001-of-00011.gguf $WORK_PATH/ggml-model-merge-3.gguf
|
||||
#echo PASS
|
||||
#echo
|
||||
|
||||
# 7b. Test the merged model is loading properly
|
||||
#$MAIN -no-cnv --model $WORK_PATH/ggml-model-merge-3.gguf -p "I believe the meaning of life is" --n-predict 32
|
||||
#echo PASS
|
||||
#echo
|
||||
|
||||
# 7c. Test the files were deleted
|
||||
#for i in $(seq -w 1 11); do
|
||||
# test ! -f "$WORK_PATH/ggml-model-split-32-tensors-copy-000${i}-of-00011.gguf"
|
||||
#done
|
||||
#echo PASS
|
||||
#echo
|
||||
|
||||
# Clean up
|
||||
rm -f $WORK_PATH/ggml-model-split*.gguf $WORK_PATH/ggml-model-merge*.gguf
|
||||
|
||||
@@ -556,10 +556,8 @@ bool mtmd_audio_preprocessor_whisper::preprocess(const float * s
|
||||
}
|
||||
|
||||
std::vector<float> smpl;
|
||||
// if input is too short, pad with zeros
|
||||
// this is to avoid potential issues with stage1/2 padding in log_mel_spectrogram
|
||||
// TODO: maybe handle this better
|
||||
size_t min_samples = (size_t) hparams.audio_sample_rate * (hparams.audio_chunk_len + 1); // +1 second margin
|
||||
// reflection padding needs one sample plus half an FFT window
|
||||
size_t min_samples = (size_t) hparams.audio_n_fft / 2 + 1;
|
||||
if (n_samples < min_samples) {
|
||||
smpl.resize(min_samples, 0.0f);
|
||||
std::memcpy(smpl.data(), samples, n_samples * sizeof(float));
|
||||
|
||||
@@ -215,7 +215,7 @@ def run_mtmd_cli(spec: "ModelSpec", model_path, mmproj_path, image_path, bin_pat
|
||||
"--dry-multiplier", "0.8",
|
||||
"--dry-base", "1.75",
|
||||
"--dry-allowed-length", "2",
|
||||
"--dry-penalty-last-n", "-1",
|
||||
"--dry-penalty-last-n", "64",
|
||||
"--dry-sequence-breaker", "none",
|
||||
]
|
||||
if spec.n_ctx is not None:
|
||||
|
||||
@@ -133,14 +133,14 @@ For the full list of features, please refer to [server's changelog](https://gith
|
||||
| `--xtc-probability N` | xtc probability (default: 0.00, 0.0 = disabled) |
|
||||
| `--xtc-threshold N` | xtc threshold (default: 0.10, 1.0 = disabled) |
|
||||
| `--typical, --typical-p N` | locally typical sampling, parameter p (default: 1.00, 1.0 = disabled) |
|
||||
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled, -1 = ctx_size) |
|
||||
| `--repeat-last-n N` | last n tokens to consider for penalize (default: 64, 0 = disabled) |
|
||||
| `--repeat-penalty N` | penalize repeat sequence of tokens (default: 1.00, 1.0 = disabled) |
|
||||
| `--presence-penalty N` | repeat alpha presence penalty (default: 0.00, 0.0 = disabled) |
|
||||
| `--frequency-penalty N` | repeat alpha frequency penalty (default: 0.00, 0.0 = disabled) |
|
||||
| `--dry-multiplier N` | set DRY sampling multiplier (default: 0.00, 0.0 = disabled) |
|
||||
| `--dry-base N` | set DRY sampling base value (default: 1.75) |
|
||||
| `--dry-allowed-length N` | set allowed length for DRY sampling (default: 2) |
|
||||
| `--dry-penalty-last-n N` | set DRY penalty for the last n tokens (default: -1, 0 = disable, -1 = context size) |
|
||||
| `--dry-penalty-last-n N` | set DRY penalty for the last n tokens (default: 64, 0 = disable) |
|
||||
| `--dry-sequence-breaker STRING` | add sequence breaker for DRY sampling, clearing out default breakers ('\n', ':', '"', '*') in the process; use "none" to not use any sequence breakers |
|
||||
| `--adaptive-target N` | adaptive-p: select tokens near this probability (valid range 0.0 to 1.0; negative = disabled) (default: -1.00)<br/>[(more info)](https://github.com/ggml-org/llama.cpp/pull/17927) |
|
||||
| `--adaptive-decay N` | adaptive-p: decay rate for target adaptation over time. lower values are more reactive, higher values are more stable.<br/>(valid range 0.0 to 0.99) (default: 0.90) |
|
||||
@@ -476,7 +476,7 @@ These words will not be included in the completion, so make sure to add them to
|
||||
|
||||
`repeat_penalty`: Control the repetition of token sequences in the generated text. Default: `1.1`
|
||||
|
||||
`repeat_last_n`: Last n tokens to consider for penalizing repetition. Default: `64`, where `0` is disabled and `-1` is ctx-size.
|
||||
`repeat_last_n`: Last n tokens to consider for penalizing repetition. Default: `64`, where `0` is disabled.
|
||||
|
||||
`presence_penalty`: Repeat alpha presence penalty. Default: `0.0`, which is disabled.
|
||||
|
||||
@@ -488,7 +488,7 @@ These words will not be included in the completion, so make sure to add them to
|
||||
|
||||
`dry_allowed_length`: Tokens that extend repetition beyond this receive exponentially increasing penalty: multiplier * base ^ (length of repeating sequence before token - allowed length). Default: `2`
|
||||
|
||||
`dry_penalty_last_n`: How many tokens to scan for repetitions. Default: `-1`, where `0` is disabled and `-1` is context size.
|
||||
`dry_penalty_last_n`: How many tokens to scan for repetitions. Default: `64`, where `0` is disabled.
|
||||
|
||||
`dry_sequence_breakers`: Specify an array of sequence breakers for DRY sampling. Only a JSON array of strings is accepted. Default: `['\n', ':', '"', '*']`
|
||||
|
||||
@@ -796,7 +796,7 @@ By default, it is read-only. To make POST request to change global properties, y
|
||||
"dry_multiplier": 0.0,
|
||||
"dry_base": 1.75,
|
||||
"dry_allowed_length": 2,
|
||||
"dry_penalty_last_n": -1,
|
||||
"dry_penalty_last_n": 64,
|
||||
"dry_sequence_breakers": [
|
||||
"\n",
|
||||
":",
|
||||
|
||||
@@ -1807,8 +1807,7 @@ private:
|
||||
// initialize samplers
|
||||
if (task.need_sampling()) {
|
||||
try {
|
||||
slot.smpl.reset(common_sampler_init(
|
||||
model_tgt, task.params.sampling, (int32_t) llama_n_ctx(ctx_tgt)));
|
||||
slot.smpl.reset(common_sampler_init(model_tgt, task.params.sampling));
|
||||
} catch (std::exception & e) {
|
||||
std::string err_msg = std::string("Failed to initialize samplers: ") + e.what();
|
||||
send_error(task, err_msg, ERROR_TYPE_INVALID_REQUEST);
|
||||
@@ -4148,7 +4147,6 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
|
||||
task.params = server_schema::eval_llama_cmpl_schema(
|
||||
ctx_server.vocab,
|
||||
params,
|
||||
meta->slot_n_ctx,
|
||||
meta->logit_bias_eog,
|
||||
data);
|
||||
|
||||
|
||||
@@ -124,8 +124,8 @@ std::vector<std::unique_ptr<field>> make_llama_cmpl_schema(const common_params &
|
||||
->set_desc("Dynamic temperature exponent, controls how entropy maps to temperature"));
|
||||
|
||||
add((new field_num("repeat_last_n", params.sampling.penalty_last_n))
|
||||
->set_hard_limits(-1, INT32_MAX)
|
||||
->set_desc("Last n tokens to consider for penalizing repetition (0 = disabled, -1 = ctx-size)"));
|
||||
->set_hard_limits(0, INT32_MAX)
|
||||
->set_desc("Last n tokens to consider for penalizing repetition (0 = disabled)"));
|
||||
|
||||
add((new field_num("repeat_penalty", params.sampling.penalty_repeat))
|
||||
->set_desc("Control the repetition of token sequences in the generated text (1.0 = disabled)"));
|
||||
@@ -151,8 +151,8 @@ std::vector<std::unique_ptr<field>> make_llama_cmpl_schema(const common_params &
|
||||
->set_desc("Tokens that extend repetition beyond this length receive exponentially increasing penalty: multiplier * base ^ (sequence_length - allowed_length)"));
|
||||
|
||||
add((new field_num("dry_penalty_last_n", params.sampling.dry_penalty_last_n))
|
||||
->set_hard_limits(-1, INT32_MAX)
|
||||
->set_desc("How many tokens to scan for repetitions (0 = disabled, -1 = context size)"));
|
||||
->set_hard_limits(0, INT32_MAX)
|
||||
->set_desc("How many tokens to scan for repetitions (0 = disabled)"));
|
||||
|
||||
add((new field_num("mirostat", params.sampling.mirostat))
|
||||
->set_limits(0, 2)
|
||||
@@ -515,7 +515,6 @@ std::vector<std::unique_ptr<field>> make_llama_cmpl_schema(const common_params &
|
||||
task_params eval_llama_cmpl_schema(
|
||||
const llama_vocab * vocab,
|
||||
const common_params & params_base,
|
||||
const int n_ctx_slot,
|
||||
const std::vector<llama_logit_bias> & logit_bias_eog,
|
||||
const json & data) {
|
||||
task_params params;
|
||||
@@ -549,15 +548,6 @@ task_params eval_llama_cmpl_schema(
|
||||
|
||||
// post-processing
|
||||
{
|
||||
if (params.sampling.penalty_last_n == -1) {
|
||||
// note: should be the slot's context and not the full context, but it's ok
|
||||
params.sampling.penalty_last_n = n_ctx_slot;
|
||||
}
|
||||
|
||||
if (params.sampling.dry_penalty_last_n == -1) {
|
||||
params.sampling.dry_penalty_last_n = n_ctx_slot;
|
||||
}
|
||||
|
||||
// if "reasoning_format" is not provided, its handler will not be called, we will need to handle it here
|
||||
auto reasoning_format = params.chat_parser_params.reasoning_format;
|
||||
params.chat_parser_params.reasoning_in_content = params.stream && (reasoning_format == COMMON_REASONING_FORMAT_DEEPSEEK_LEGACY);
|
||||
|
||||
@@ -98,7 +98,6 @@ std::vector<std::unique_ptr<field>> make_llama_cmpl_schema(
|
||||
task_params eval_llama_cmpl_schema(
|
||||
const llama_vocab * vocab,
|
||||
const common_params & params_base,
|
||||
const int n_ctx_slot,
|
||||
const std::vector<llama_logit_bias> & logit_bias_eog,
|
||||
const json & data);
|
||||
|
||||
|
||||
+244
-68
@@ -10,17 +10,67 @@
|
||||
#include <ctime>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
#include <unordered_set>
|
||||
#include <tuple>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
#if defined(_WIN32)
|
||||
# ifndef NOMINMAX
|
||||
# define NOMINMAX
|
||||
# endif
|
||||
# include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
//
|
||||
// internal helpers
|
||||
//
|
||||
|
||||
#if defined(_WIN32)
|
||||
// A chunk can end in the middle of a multi-byte sequence, so the incomplete
|
||||
// tail is dropped before validating what precedes it.
|
||||
static bool is_utf8_text(const std::string & text) {
|
||||
return is_valid_utf8(text.substr(0, validate_utf8(text)));
|
||||
}
|
||||
|
||||
// A child process writes its output in the OEM code page, which is not UTF-8
|
||||
// on a western Windows install, so accented text reaches the JSON layer as
|
||||
// invalid bytes and is replaced there. Text that already decodes as UTF-8 is
|
||||
// returned untouched, so a child that emits UTF-8 is never decoded twice.
|
||||
// run() spawns without a console, so the console code page does not apply.
|
||||
static std::string console_output_to_utf8(const std::string & text) {
|
||||
if (text.empty() || is_utf8_text(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const UINT cp = GetOEMCP();
|
||||
|
||||
// fail rather than emit replacement characters when the code page is wrong
|
||||
const int wide_len = MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, text.data(), (int) text.size(), nullptr, 0);
|
||||
if (wide_len <= 0) {
|
||||
return text;
|
||||
}
|
||||
std::wstring wide(wide_len, L'\0');
|
||||
MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, text.data(), (int) text.size(), wide.data(), wide_len);
|
||||
|
||||
const int utf8_len = WideCharToMultiByte(CP_UTF8, 0, wide.data(), wide_len, nullptr, 0, nullptr, nullptr);
|
||||
if (utf8_len <= 0) {
|
||||
return text;
|
||||
}
|
||||
std::string utf8(utf8_len, '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, wide.data(), wide_len, utf8.data(), utf8_len, nullptr, nullptr);
|
||||
return utf8;
|
||||
}
|
||||
#else
|
||||
static std::string console_output_to_utf8(const std::string & text) {
|
||||
return text;
|
||||
}
|
||||
#endif
|
||||
|
||||
json server_tool::to_json() const {
|
||||
return {
|
||||
{"display_name", display_name},
|
||||
@@ -34,7 +84,40 @@ json server_tool::to_json() const {
|
||||
}
|
||||
|
||||
static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
|
||||
static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds
|
||||
// budget for one listing call, shared by the git and walker paths
|
||||
static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15; // seconds
|
||||
|
||||
// entry kinds a directory listing may return
|
||||
enum class list_kind {
|
||||
files, // regular files only
|
||||
dirs, // directories only
|
||||
all, // both
|
||||
};
|
||||
|
||||
// home directory, read once at first use (getenv is not thread safe against setenv)
|
||||
static const std::string & home_dir() {
|
||||
static const std::string home = [] {
|
||||
const char * h = getenv("HOME");
|
||||
#ifdef _WIN32
|
||||
if (h == nullptr) h = getenv("USERPROFILE");
|
||||
#endif
|
||||
return h ? std::string(h) : std::string();
|
||||
}();
|
||||
return home;
|
||||
}
|
||||
|
||||
static std::string expand_home(const std::string & path) {
|
||||
if (path.empty() || path[0] != '~') return path;
|
||||
if (path.size() > 1 && path[1] != '/' && path[1] != '\\') return path;
|
||||
const std::string & home = home_dir();
|
||||
if (home.empty()) return path;
|
||||
return home + path.substr(1);
|
||||
}
|
||||
|
||||
// depth of a '/'-separated relative path: "a/b/c" is 3
|
||||
static int entry_depth(const std::string & rel) {
|
||||
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
|
||||
}
|
||||
|
||||
class tools_io {
|
||||
public:
|
||||
@@ -51,8 +134,17 @@ public:
|
||||
virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
|
||||
virtual bool read_file(const std::string & path, std::string & out) const = 0;
|
||||
virtual bool write_file(const std::string & path, const std::string & content) const = 0;
|
||||
// paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
|
||||
virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0;
|
||||
// resolve `path` against the IO's working directory; absolute paths are returned unchanged
|
||||
virtual std::string resolve(const std::string & path) const = 0;
|
||||
struct list_entry {
|
||||
std::string rel; // '/'-separated, relative to `base`
|
||||
bool is_dir = false;
|
||||
};
|
||||
// entries relative to `base`; sets `err` if `base` isn't a directory
|
||||
// max_depth == 0 means unlimited, 1 means direct children of `base` only
|
||||
// `base` must already be resolved (absolute); `caller_path` is the path the
|
||||
// caller passed, used only for error messages
|
||||
virtual std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const = 0;
|
||||
// on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
|
||||
// returning false terminates the process early (e.g. the client disconnected)
|
||||
virtual exec_result run(
|
||||
@@ -67,6 +159,22 @@ public:
|
||||
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
|
||||
explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
|
||||
|
||||
// expands a leading `~`, then resolves `path` against `cwd` (or the server
|
||||
// working directory when `cwd` is unset); the result is always absolute
|
||||
std::string resolve(const std::string & path) const override {
|
||||
std::string p = expand_home(path);
|
||||
if (fs::path(p).is_absolute()) {
|
||||
return p;
|
||||
}
|
||||
if (cwd.empty()) {
|
||||
std::error_code ec;
|
||||
fs::path cur = fs::current_path(ec);
|
||||
if (ec) return p;
|
||||
return (cur / p).string();
|
||||
}
|
||||
return (fs::path(cwd) / p).string();
|
||||
}
|
||||
|
||||
bool is_directory(const std::string & path) const override {
|
||||
std::error_code ec;
|
||||
return fs::is_directory(resolve(path), ec) && !ec;
|
||||
@@ -105,34 +213,41 @@ public:
|
||||
return (bool) f;
|
||||
}
|
||||
|
||||
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
|
||||
std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const override {
|
||||
err.clear();
|
||||
std::string abs_base = resolve(base);
|
||||
if (!is_directory(base)) {
|
||||
err = "path does not exist or is not a directory: " + base;
|
||||
truncated = false;
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(base, ec) || ec) {
|
||||
err = "path does not exist or is not a directory: " + caller_path;
|
||||
return {};
|
||||
}
|
||||
|
||||
auto res = run(
|
||||
{"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"},
|
||||
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
|
||||
|
||||
if (res.exit_code == 0 && !res.timed_out) {
|
||||
std::vector<std::string> result;
|
||||
std::istringstream iss(res.output);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) continue;
|
||||
std::replace(line.begin(), line.end(), '\\', '/');
|
||||
if (is_regular_file((fs::path(base) / line).string())) {
|
||||
result.push_back(line);
|
||||
// git ls-files cannot list directories; use the walker when they are requested
|
||||
if (kind == list_kind::files) {
|
||||
auto res = run(
|
||||
{"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
|
||||
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
|
||||
|
||||
if (res.exit_code == 0 && !res.timed_out) {
|
||||
std::vector<list_entry> result;
|
||||
std::istringstream iss(res.output);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) continue;
|
||||
std::replace(line.begin(), line.end(), '\\', '/');
|
||||
if (max_depth > 0 && entry_depth(line) > max_depth) continue;
|
||||
if (is_regular_file((fs::path(base) / line).string())) {
|
||||
result.push_back({line, false});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return list_files_fallback(abs_base);
|
||||
return list_entries_fallback(base, max_depth, kind, deadline, truncated);
|
||||
}
|
||||
|
||||
exec_result run(
|
||||
@@ -179,14 +294,14 @@ public:
|
||||
size_t len = strlen(buf);
|
||||
if (output.size() + len <= max_output) {
|
||||
output.append(buf, len);
|
||||
if (on_chunk && !on_chunk(std::string(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(std::string(buf, remaining));
|
||||
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
@@ -200,7 +315,7 @@ public:
|
||||
|
||||
res.exit_code = proc.join();
|
||||
|
||||
res.output = output;
|
||||
res.output = console_output_to_utf8(output);
|
||||
res.timed_out = timed_out.load();
|
||||
if (truncated) {
|
||||
res.output += "\n[output truncated]";
|
||||
@@ -211,14 +326,6 @@ public:
|
||||
private:
|
||||
std::string cwd;
|
||||
|
||||
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
|
||||
std::string resolve(const std::string & path) const {
|
||||
if (cwd.empty() || fs::path(path).is_absolute()) {
|
||||
return path;
|
||||
}
|
||||
return (fs::path(cwd) / path).string();
|
||||
}
|
||||
|
||||
static const std::unordered_set<std::string> & junk_dir_names() {
|
||||
static const std::unordered_set<std::string> names = {
|
||||
".git", ".svn", ".hg", "node_modules", "__pycache__",
|
||||
@@ -227,28 +334,50 @@ private:
|
||||
return names;
|
||||
}
|
||||
|
||||
std::vector<std::string> list_files_fallback(const std::string & base) const {
|
||||
std::vector<std::string> result;
|
||||
std::vector<list_entry> list_entries_fallback(const std::string & base, int max_depth, list_kind kind,
|
||||
std::chrono::steady_clock::time_point deadline, bool & truncated) const {
|
||||
std::vector<list_entry> result;
|
||||
std::error_code ec;
|
||||
|
||||
std::vector<std::pair<fs::path, fs::path>> stack;
|
||||
stack.emplace_back(fs::path(base), fs::path());
|
||||
std::vector<std::tuple<fs::path, fs::path, int>> stack;
|
||||
stack.emplace_back(fs::path(base), fs::path(), 0);
|
||||
|
||||
while (!stack.empty()) {
|
||||
auto [dir, rel_dir] = stack.back();
|
||||
auto [dir, rel_dir, depth] = stack.back();
|
||||
stack.pop_back();
|
||||
|
||||
for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
|
||||
// the throwing increment would escape the tool on a directory that
|
||||
// goes away mid walk, so step the iterator explicitly
|
||||
fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
|
||||
for (const fs::directory_iterator end; it != end; it.increment(ec)) {
|
||||
if (ec) break;
|
||||
if (std::chrono::steady_clock::now() >= deadline) {
|
||||
truncated = true;
|
||||
return result;
|
||||
}
|
||||
const fs::directory_entry & entry = *it;
|
||||
std::string fname = entry.path().filename().string();
|
||||
std::error_code tec;
|
||||
if (entry.is_directory(tec)) {
|
||||
std::string rel = (rel_dir / fname).string();
|
||||
std::replace(rel.begin(), rel.end(), '\\', '/');
|
||||
if (kind == list_kind::dirs || kind == list_kind::all) {
|
||||
result.push_back({rel, true});
|
||||
}
|
||||
// junk directories stay selectable but are never walked: they
|
||||
// hold nothing worth searching and can be enormous
|
||||
if (junk_dir_names().count(fname) > 0) continue;
|
||||
stack.emplace_back(entry.path(), rel_dir / fname);
|
||||
// do not descend into symlinks: a link can point back to an
|
||||
// ancestor and loop forever
|
||||
if (!entry.is_symlink(tec) && (max_depth == 0 || depth + 1 < max_depth)) {
|
||||
stack.emplace_back(entry.path(), rel_dir / fname, depth + 1);
|
||||
}
|
||||
} else if (entry.is_regular_file(tec)) {
|
||||
std::string rel = (rel_dir / fname).string();
|
||||
std::replace(rel.begin(), rel.end(), '\\', '/');
|
||||
result.push_back(rel);
|
||||
if (kind == list_kind::files || kind == list_kind::all) {
|
||||
result.push_back({rel, false});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,6 +492,9 @@ struct server_tool_read_file : server_tool {
|
||||
//
|
||||
|
||||
static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
|
||||
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file";
|
||||
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR = "dir";
|
||||
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL = "all";
|
||||
|
||||
struct server_tool_file_glob_search : server_tool {
|
||||
server_tool_file_glob_search() {
|
||||
@@ -382,13 +514,18 @@ struct server_tool_file_glob_search : server_tool {
|
||||
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
|
||||
"A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
|
||||
"A pattern containing '/' matches the full relative path; unless already anchored with "
|
||||
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."},
|
||||
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\". "
|
||||
"Use type=\"dir\" or \"all\" to also list directories; directory entries are suffixed with '/' in the output. "
|
||||
"Note: directory listings do not apply .gitignore filtering."},
|
||||
{"parameters", {
|
||||
{"type", "object"},
|
||||
{"properties", {
|
||||
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
|
||||
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
|
||||
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
|
||||
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
|
||||
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
|
||||
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
|
||||
{"type", {{"type", "string"}, {"description", "Entry type to return: \"file\" (default), \"dir\" or \"all\""}}},
|
||||
{"max_depth", {{"type", "integer"}, {"description", "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)"}}},
|
||||
{"limit", {{"type", "integer"}, {"description", string_format("Maximum number of results to return (default %zu; values below 1 fall back to the default)", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS)}}},
|
||||
}},
|
||||
{"required", json::array({"path"})},
|
||||
}},
|
||||
@@ -397,30 +534,56 @@ struct server_tool_file_glob_search : server_tool {
|
||||
}
|
||||
|
||||
json invoke(json params, server_tool::stream *) const override {
|
||||
std::string base = params.at("path").get<std::string>();
|
||||
std::string include = json_value(params, "include", std::string("**"));
|
||||
std::string exclude = json_value(params, "exclude", std::string(""));
|
||||
|
||||
auto io = make_tools_io(params);
|
||||
|
||||
std::string base = io->resolve(params.at("path").get<std::string>());
|
||||
// normalize to forward slashes so the web UI (which assumes '/') can
|
||||
// join the relative entries into absolute paths on Windows too
|
||||
std::replace(base.begin(), base.end(), '\\', '/');
|
||||
std::string include = json_value(params, "include", std::string("**"));
|
||||
std::string exclude = json_value(params, "exclude", std::string(""));
|
||||
std::string type = json_value(params, "type", std::string("file"));
|
||||
int max_depth = std::max(0, json_value(params, "max_depth", 0));
|
||||
int limit = json_value(params, "limit", (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
|
||||
if (limit < 1) limit = SERVER_TOOL_FILE_SEARCH_MAX_RESULTS;
|
||||
limit = std::min(limit, (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
|
||||
|
||||
list_kind kind;
|
||||
if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE) {
|
||||
kind = list_kind::files;
|
||||
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR) {
|
||||
kind = list_kind::dirs;
|
||||
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL) {
|
||||
kind = list_kind::all;
|
||||
} else {
|
||||
return {{"error", "invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")"}};
|
||||
}
|
||||
|
||||
std::string err;
|
||||
auto files = io->list_files(base, err);
|
||||
bool truncated = false;
|
||||
auto entries = io->list_entries(base, params.at("path").get<std::string>(), max_depth, kind, err, truncated);
|
||||
if (!err.empty()) {
|
||||
return {{"error", err}};
|
||||
}
|
||||
|
||||
std::vector<std::string> matches;
|
||||
for (const auto & rel : files) {
|
||||
if (!path_glob_match(include, rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
|
||||
matches.push_back(rel);
|
||||
std::vector<tools_io::list_entry> matches;
|
||||
for (const auto & entry : entries) {
|
||||
if (!path_glob_match(include, entry.rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
|
||||
matches.push_back(entry);
|
||||
}
|
||||
|
||||
size_t total = matches.size();
|
||||
size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
|
||||
size_t shown = std::min(total, (size_t) limit);
|
||||
|
||||
std::ostringstream output_text;
|
||||
json entries_json = json::array();
|
||||
for (size_t i = 0; i < shown; i++) {
|
||||
output_text << matches[i] << "\n";
|
||||
output_text << matches[i].rel << (matches[i].is_dir ? "/" : "") << "\n";
|
||||
entries_json.push_back({
|
||||
{"path", matches[i].rel},
|
||||
{"type", matches[i].is_dir ? "dir" : "file"},
|
||||
});
|
||||
}
|
||||
|
||||
output_text << "\n---\nTotal matches: " << total << "\n";
|
||||
@@ -429,8 +592,16 @@ struct server_tool_file_glob_search : server_tool {
|
||||
"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
|
||||
shown, total);
|
||||
}
|
||||
if (truncated) {
|
||||
output_text << "[search timed out, results truncated]\n";
|
||||
}
|
||||
|
||||
return {{"plain_text_response", output_text.str()}};
|
||||
// `base` is always absolute (resolve falls back to the server cwd), so
|
||||
// API clients (e.g. the web UI picker) can join the relative entries
|
||||
// into absolute paths. `plain_text_response` is what the model sees;
|
||||
// `entries` is the same data as structured JSON for the UI picker,
|
||||
// which reads `entries`/`base` instead of re-parsing the text.
|
||||
return {{"plain_text_response", output_text.str()}, {"entries", entries_json}, {"base", base}};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -513,18 +684,20 @@ struct server_tool_grep_search : server_tool {
|
||||
// collect (absolute_path, display_path) pairs to search
|
||||
std::vector<std::pair<std::string, std::string>> files;
|
||||
|
||||
if (io->is_regular_file(path)) {
|
||||
files.emplace_back(path, path);
|
||||
} else if (io->is_directory(path)) {
|
||||
const std::string abs_path = io->resolve(path);
|
||||
if (io->is_regular_file(abs_path)) {
|
||||
files.emplace_back(abs_path, path);
|
||||
} else if (io->is_directory(abs_path)) {
|
||||
std::string err;
|
||||
auto candidates = io->list_files(path, err);
|
||||
bool truncated = false;
|
||||
auto candidates = io->list_entries(abs_path, path, 0, list_kind::files, err, truncated);
|
||||
if (!err.empty()) {
|
||||
return {{"error", err}};
|
||||
}
|
||||
for (const auto & rel : candidates) {
|
||||
if (!path_glob_match(include, rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
|
||||
files.emplace_back((fs::path(path) / rel).string(), rel);
|
||||
for (const auto & entry : candidates) {
|
||||
if (!path_glob_match(include, entry.rel)) continue;
|
||||
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
|
||||
files.emplace_back((fs::path(abs_path) / entry.rel).string(), entry.rel);
|
||||
}
|
||||
} else {
|
||||
return {{"error", "path does not exist: " + path}};
|
||||
@@ -1094,6 +1267,9 @@ struct server_tool_get_datetime : server_tool {
|
||||
// get_info: returns runtime info (OS name/version and cwd)
|
||||
//
|
||||
|
||||
static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096;
|
||||
static constexpr int SERVER_TOOL_GET_INFO_TIMEOUT = 5; // seconds
|
||||
|
||||
struct server_tool_get_info : server_tool {
|
||||
server_tool_get_info() {
|
||||
name = "get_info";
|
||||
@@ -1119,9 +1295,9 @@ struct server_tool_get_info : server_tool {
|
||||
auto io = make_tools_io(params);
|
||||
|
||||
#ifdef _WIN32
|
||||
auto res = io->run({"cmd", "/c", "ver"}, 4096, 5);
|
||||
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
#else
|
||||
auto res = io->run({"uname", "-a"}, 4096, 5);
|
||||
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
|
||||
#endif
|
||||
// "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
|
||||
|
||||
@@ -164,3 +164,101 @@ def test_tools_builtin_edit_file_rejects_overlapping_edits():
|
||||
finally:
|
||||
if os.path.exists(log_path):
|
||||
os.remove(log_path)
|
||||
|
||||
|
||||
def test_tools_builtin_file_glob_search_type_dir(tmp_path):
|
||||
global server
|
||||
server.start()
|
||||
|
||||
(tmp_path / "project-alpha" / "src").mkdir(parents=True)
|
||||
(tmp_path / "project-alpha" / "README.md").write_text("alpha")
|
||||
(tmp_path / "project-alpha" / "src" / "main.cpp").write_text("int main() {}")
|
||||
(tmp_path / "project-beta").mkdir()
|
||||
(tmp_path / "project-beta" / "notes.txt").write_text("beta")
|
||||
|
||||
res = call_tool("file_glob_search", {"path": str(tmp_path), "type": "dir"})
|
||||
text = res["plain_text_response"]
|
||||
assert "project-alpha/" in text
|
||||
assert "project-beta/" in text
|
||||
assert "project-alpha/src/" in text
|
||||
assert "README.md" not in text
|
||||
types = {e["path"]: e["type"] for e in res["entries"]}
|
||||
assert types["project-alpha"] == "dir"
|
||||
assert types["project-alpha/src"] == "dir"
|
||||
|
||||
res_all = call_tool("file_glob_search", {"path": str(tmp_path), "type": "all", "include": "*proj*"})
|
||||
paths = [e["path"] for e in res_all["entries"]]
|
||||
assert "project-alpha" in paths
|
||||
assert "project-beta" in paths
|
||||
|
||||
|
||||
def test_tools_builtin_file_glob_search_max_depth_and_limit(tmp_path):
|
||||
global server
|
||||
server.start()
|
||||
|
||||
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
|
||||
(tmp_path / "top.txt").write_text("top")
|
||||
(tmp_path / "a" / "mid.txt").write_text("mid")
|
||||
(tmp_path / "a" / "b" / "deep.txt").write_text("deep")
|
||||
|
||||
res = call_tool("file_glob_search", {"path": str(tmp_path), "max_depth": 1})
|
||||
assert "top.txt" in res["plain_text_response"]
|
||||
assert "mid.txt" not in res["plain_text_response"]
|
||||
|
||||
res = call_tool("file_glob_search", {"path": str(tmp_path), "max_depth": 2})
|
||||
assert "mid.txt" in res["plain_text_response"]
|
||||
assert "deep.txt" not in res["plain_text_response"]
|
||||
|
||||
res = call_tool("file_glob_search", {"path": str(tmp_path), "limit": 1})
|
||||
assert len(res["entries"]) == 1
|
||||
assert "Total matches: 3" in res["plain_text_response"]
|
||||
|
||||
|
||||
def test_tools_builtin_file_glob_search_rejects_invalid_type(tmp_path):
|
||||
global server
|
||||
server.start()
|
||||
|
||||
err = call_tool_expect_error("file_glob_search", {"path": str(tmp_path), "type": "bogus"})
|
||||
assert "invalid type" in err
|
||||
|
||||
|
||||
def test_tools_builtin_cwd_header_overrides_model_param(tmp_path):
|
||||
global server
|
||||
server.start()
|
||||
|
||||
workdir = tmp_path / "workdir"
|
||||
workdir.mkdir()
|
||||
(workdir / "marker.txt").write_text("marker")
|
||||
|
||||
# a model-provided "cwd" in the params is overridden by the x-tool-cwd header
|
||||
res = call_tool("read_file", {"path": "marker.txt", "cwd": "/definitely/not/a/real/path"},
|
||||
headers={"x-tool-cwd": str(workdir)})
|
||||
assert "marker" in res["plain_text_response"]
|
||||
|
||||
|
||||
def test_tools_builtin_cwd_relative_paths(tmp_path):
|
||||
global server
|
||||
server.start()
|
||||
|
||||
workdir = tmp_path / "workdir"
|
||||
workdir.mkdir()
|
||||
(workdir / "rel.txt").write_text("relative-content")
|
||||
|
||||
headers = {"x-tool-cwd": str(workdir)}
|
||||
|
||||
# relative paths in file tools resolve against the header cwd
|
||||
res = call_tool("read_file", {"path": "rel.txt"}, headers=headers)
|
||||
assert "relative-content" in res["plain_text_response"]
|
||||
|
||||
res = call_tool("write_file", {"path": "sub/out.txt", "content": "written"}, headers=headers)
|
||||
assert (workdir / "sub" / "out.txt").read_text() == "written"
|
||||
|
||||
res = call_tool("file_glob_search", {"path": ".", "include": "*.txt"}, headers=headers)
|
||||
assert "rel.txt" in res["plain_text_response"]
|
||||
|
||||
# absolute paths are unaffected by the cwd
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
(other / "abs.txt").write_text("absolute-content")
|
||||
res = call_tool("read_file", {"path": str(other / "abs.txt")}, headers=headers)
|
||||
assert "absolute-content" in res["plain_text_response"]
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ Llama UI supports two server operation modes:
|
||||
|
||||
```bash
|
||||
cd tools/ui
|
||||
npm install
|
||||
npm ci
|
||||
```
|
||||
|
||||
### 2. Start llama-server
|
||||
|
||||
@@ -14,7 +14,7 @@ cd ../../
|
||||
# Ensure node_modules are installed
|
||||
if [ ! -d "tools/ui/node_modules" ]; then
|
||||
echo "📦 Installing npm dependencies..."
|
||||
cd tools/ui && npm install && cd ../../
|
||||
cd tools/ui && npm ci && cd ../../
|
||||
fi
|
||||
|
||||
# Check and install git hooks if missing
|
||||
|
||||
@@ -14,7 +14,7 @@ cd "$REPO_ROOT/tools/ui"
|
||||
|
||||
# Check that node_modules exists
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "❌ node_modules not found. Run 'npm install' first."
|
||||
echo "❌ node_modules not found. Run 'npm ci' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ cd "$REPO_ROOT/tools/ui"
|
||||
|
||||
# Check that node_modules exists
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "❌ node_modules not found. Run 'npm install' first."
|
||||
echo "❌ node_modules not found. Run 'npm ci' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Vendored
+9
@@ -142,5 +142,14 @@ declare global {
|
||||
interface Window {
|
||||
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?: {
|
||||
id?: string;
|
||||
mode?: 'read' | 'readwrite';
|
||||
startIn?: FileSystemHandle | string;
|
||||
}) => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
ChatFormTextarea,
|
||||
ChatFormWorkingDirectory,
|
||||
DialogMcpResourcesBrowser
|
||||
} from '$lib/components/app';
|
||||
import {
|
||||
@@ -31,7 +32,13 @@
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import {
|
||||
conversationsStore,
|
||||
activeMessages,
|
||||
activeConversation,
|
||||
pendingCwd
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
|
||||
import {
|
||||
@@ -107,6 +114,15 @@
|
||||
let isInlineResourcePickerOpen = $state(false);
|
||||
let resourceSearchQuery = $state('');
|
||||
|
||||
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
|
||||
|
||||
async function handleWorkingDirectoryChange(value: string | null) {
|
||||
await conversationsStore.setCwd(value);
|
||||
if (conversationsStore.activeConversation) {
|
||||
await chatStore.recordCwdChange(value?.trim() || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Dialog State
|
||||
let isResourceDialogOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
@@ -155,6 +171,12 @@
|
||||
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();
|
||||
}
|
||||
@@ -470,7 +492,7 @@
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<form
|
||||
class="relative {className}"
|
||||
class="relative grid {className}"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -559,6 +581,15 @@
|
||||
</div>
|
||||
|
||||
<ContextGaugePopup />
|
||||
|
||||
{#if toolsStore.builtinTools.length > 0}
|
||||
<ChatFormWorkingDirectory
|
||||
directory={cwd}
|
||||
onChange={handleWorkingDirectoryChange}
|
||||
onClose={refocusInput}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<DialogMcpResourcesBrowser
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
<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';
|
||||
import {
|
||||
abbreviateHome,
|
||||
buildCaseInsensitiveGlob,
|
||||
joinPath,
|
||||
lastPathSegment,
|
||||
rankEntries,
|
||||
splitPathQuery,
|
||||
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 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
|
||||
} from '$lib/constants';
|
||||
|
||||
// Microtask delay so the popover's focus scope tears down first.
|
||||
const FOCUS_DELAY_MS = 0;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
directory?: string | 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.
|
||||
*/
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
directory = $bindable(null),
|
||||
onChange,
|
||||
onClose
|
||||
}: 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.
|
||||
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.
|
||||
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. Entries
|
||||
// expire after a short TTL.
|
||||
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.
|
||||
$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.
|
||||
$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' });
|
||||
});
|
||||
});
|
||||
|
||||
function cancelSearch() {
|
||||
searchController?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
// 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[]) : [];
|
||||
searchCache.set(key, { results: entries, base, at: Date.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) {
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
searchError = res.error;
|
||||
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
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
function closePicker() {
|
||||
isOpen = false;
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function commit(path: string) {
|
||||
directory = path;
|
||||
onChange?.(path);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
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.
|
||||
async function resolveNativeName(name: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
|
||||
path: homeBase ?? HOME_TILDE,
|
||||
type: GlobSearchType.DIR,
|
||||
include: buildCaseInsensitiveGlob(name),
|
||||
max_depth: NATIVE_MAX_DEPTH,
|
||||
limit: NATIVE_LIMIT
|
||||
});
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const match = entries.find(
|
||||
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return match ? joinPath(base, match.path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function browseNative() {
|
||||
if (disabled || !window.showDirectoryPicker) return;
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
const path = await resolveNativeName(handle.name);
|
||||
if (path) {
|
||||
setDirectory(path);
|
||||
closePicker();
|
||||
} else {
|
||||
// keep the previous cwd and fail visibly instead of committing a
|
||||
// bare leaf name that would resolve against the server cwd
|
||||
searchError = `Could not resolve "${handle.name}" to a server path`;
|
||||
}
|
||||
} catch (err) {
|
||||
// user cancelled - silently ignore; other errors are logged
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const value = inputValue.trim();
|
||||
if (!value) {
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
setDirectory(value);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
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]);
|
||||
} 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++;
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
|
||||
scrollTrigger++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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();
|
||||
if (directory) {
|
||||
clearDirectory(event);
|
||||
}
|
||||
}
|
||||
|
||||
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().
|
||||
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
|
||||
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'
|
||||
]}
|
||||
>
|
||||
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger {disabled} class="flex justify-start">
|
||||
<ChatFormWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
onClear={handleDismiss}
|
||||
/>
|
||||
</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 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 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>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { Folder, X } from '@lucide/svelte';
|
||||
import { abbreviateWorkingDir } from '$lib/utils';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIcon } from '$lib/components/app/actions';
|
||||
|
||||
interface Props {
|
||||
directory?: string | null;
|
||||
homeBase?: string | null;
|
||||
disabled?: boolean;
|
||||
showTooltip?: boolean;
|
||||
onClear?: (event?: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
directory = null,
|
||||
homeBase = null,
|
||||
disabled = false,
|
||||
showTooltip = false,
|
||||
onClear
|
||||
}: Props = $props();
|
||||
|
||||
const displayLabel = $derived(
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
|
||||
);
|
||||
// Full path surface: hover the abbreviated label to recall the exact directory.
|
||||
const displayLabelTitle = $derived(directory ?? '');
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
|
||||
class:text-foreground={directory}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
|
||||
<Folder class="w-3.5 h-3.5" />
|
||||
|
||||
{#if showTooltip && displayLabelTitle}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{displayLabelTitle}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if directory}
|
||||
<div
|
||||
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
tooltip="Reset working directory"
|
||||
ariaLabel="Reset working directory"
|
||||
{disabled}
|
||||
onclick={onClear}
|
||||
iconSize="h-3 w-3"
|
||||
stopPropagationOnClick
|
||||
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { Folder } from '@lucide/svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { highlightMatch } from '$lib/utils';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
|
||||
// Fly-in transition for the results list.
|
||||
const FLY_Y_PX = -4;
|
||||
const FLY_DURATION_MS = 100;
|
||||
|
||||
interface Props {
|
||||
results: string[];
|
||||
hoveredIndex: number;
|
||||
isSearching: boolean;
|
||||
error: string | null;
|
||||
rawQuery: string;
|
||||
container?: HTMLDivElement | null;
|
||||
onCommit?: (path: string) => void;
|
||||
onHover?: (index: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
results,
|
||||
hoveredIndex,
|
||||
isSearching,
|
||||
error,
|
||||
rawQuery,
|
||||
container = $bindable(null),
|
||||
onCommit,
|
||||
onHover
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="max-h-48 overflow-y-auto py-2"
|
||||
transition:fly={{ y: FLY_Y_PX, duration: FLY_DURATION_MS }}
|
||||
>
|
||||
{#if isSearching && results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
|
||||
{:else if error}
|
||||
<div class="px-2 py-1.5 text-sm text-destructive">{error}</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">No matching folders</div>
|
||||
{:else}
|
||||
{#each results as path, index (path)}
|
||||
<button
|
||||
type="button"
|
||||
data-result-index={index}
|
||||
data-highlighted={index === hoveredIndex ? '' : undefined}
|
||||
class={cn(
|
||||
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
|
||||
)}
|
||||
onclick={() => onCommit?.(path)}
|
||||
onmouseenter={() => onHover?.(index)}
|
||||
>
|
||||
<Folder class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left">
|
||||
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
|
||||
{#if seg.match}
|
||||
<mark class="rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30"
|
||||
>{seg.text}</mark
|
||||
>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -12,6 +12,7 @@
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
ChatMessageSystem,
|
||||
ChatMessageSynthetic,
|
||||
ChatMessageMcpPrompt
|
||||
} from '$lib/components/app/chat';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
@@ -56,6 +57,10 @@
|
||||
: message.content
|
||||
);
|
||||
|
||||
// Synthetic cwd-change messages render with the folder-row UI instead
|
||||
// of a user bubble. The persisted flag is the single source of truth.
|
||||
let isSynthetic = $derived(Boolean(message.isSynthetic));
|
||||
|
||||
let rawEditContent = $derived.by(() => {
|
||||
if (message.role !== MessageRole.ASSISTANT) return undefined;
|
||||
|
||||
@@ -344,7 +349,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-message">
|
||||
<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem
|
||||
bind:textareaElement
|
||||
@@ -375,6 +380,8 @@
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if isSynthetic}
|
||||
<ChatMessageSynthetic {message} class={className} />
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser
|
||||
class={className}
|
||||
@@ -422,7 +429,17 @@
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
--chat-message-intrinsic-size: 500px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 500px;
|
||||
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
|
||||
}
|
||||
|
||||
/*
|
||||
* Synthetic rows (e.g. the working-directory change) are small, so an
|
||||
* accurate placeholder keeps the injected row from inflating the
|
||||
* auto-scroll offset; the 500px default is for ordinary bubbles.
|
||||
*/
|
||||
.chat-message--synthetic {
|
||||
--chat-message-intrinsic-size: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Folder, FolderX } from '@lucide/svelte';
|
||||
import { parseCwdMessage } from '$lib/utils';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
}
|
||||
|
||||
let { class: className = '', message }: Props = $props();
|
||||
|
||||
// Parse the synthetic message content in the UI so the row reuses the
|
||||
// exact same text the model saw, including any guidance suffix.
|
||||
let info = $derived(parseCwdMessage(message.content));
|
||||
</script>
|
||||
|
||||
{#if info}
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
|
||||
{#if info.path === null}
|
||||
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
|
||||
{:else}
|
||||
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
<span class="text-foreground/80 text-sm font-medium">Set working directory to </span>
|
||||
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
|
||||
{info.display}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { parseCwdMessage } from '$lib/utils';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
}
|
||||
|
||||
let { class: className = '', message }: Props = $props();
|
||||
|
||||
// Synthetic messages render a dedicated UI, never a user bubble. The only
|
||||
// kind today is the working-directory change; parse the content so the
|
||||
// row reuses the exact synthetic text (and future kinds slot in here).
|
||||
let isCwdChange = $derived(parseCwdMessage(message.content) !== null);
|
||||
</script>
|
||||
|
||||
{#if isCwdChange}
|
||||
<ChatMessageCwdChange {message} class={className} />
|
||||
{:else}
|
||||
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
|
||||
{/if}
|
||||
+3
@@ -12,6 +12,7 @@
|
||||
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
|
||||
import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
|
||||
import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
|
||||
import ChatMessageToolCallBlockGetInfo from './ChatMessageToolCallBlockGetInfo.svelte';
|
||||
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
|
||||
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
|
||||
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
|
||||
@@ -40,6 +41,8 @@
|
||||
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.GET_DATETIME}
|
||||
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
|
||||
{:else if section.toolName === BuiltInTool.GET_INFO}
|
||||
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
|
||||
{:else if section.toolName === BuiltInTool.READ_FILE}
|
||||
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.EDIT_FILE}
|
||||
|
||||
+6
-2
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
import { computeLineDiff, prefixFor, type AgenticSection } from '$lib/utils';
|
||||
import { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseEditFileMeta } from './parsers/edit-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -15,6 +16,7 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const editFileMeta = $derived(parseEditFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const editDiffs = $derived(
|
||||
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
);
|
||||
@@ -23,7 +25,9 @@
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Edit file </span>
|
||||
<span class="font-mono">{editFileMeta?.filePath}</span>
|
||||
<span class="font-mono" title={editFileMeta?.filePath}
|
||||
>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
|
||||
>
|
||||
{#if editFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
|
||||
+32
@@ -12,6 +12,7 @@
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||
import {
|
||||
abbreviateHome,
|
||||
highlightCode,
|
||||
isExitCodeSummaryLine,
|
||||
parseExecShellCommandError,
|
||||
@@ -21,6 +22,7 @@
|
||||
type ExecShellExitStatus,
|
||||
type ToolResultLine
|
||||
} from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
@@ -75,6 +77,14 @@
|
||||
execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
|
||||
);
|
||||
|
||||
// The working directory the command ran with, persisted per call on the
|
||||
// tool result message (it travels via the x-tool-cwd header, not the tool
|
||||
// args). Reading it from the section keeps it accurate even if the
|
||||
// conversation cwd changes later.
|
||||
const cwd = $derived(section.toolCwd);
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
|
||||
|
||||
const exitBadgeClass = $derived(
|
||||
execShellExitStatus?.timedOut
|
||||
? 'exit-badge warning'
|
||||
@@ -159,6 +169,11 @@
|
||||
</script>
|
||||
|
||||
{#snippet execShellTitle()}
|
||||
{#if cwd}
|
||||
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
|
||||
<span class="exec-prompt">$</span>
|
||||
{/if}
|
||||
|
||||
{#if highlightedCommandHtml}
|
||||
<span class="font-mono">{@html highlightedCommandHtml}</span>
|
||||
{:else}
|
||||
@@ -232,6 +247,23 @@
|
||||
</ToolCallBlock>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--exec-wd-margin: 0.4rem;
|
||||
}
|
||||
|
||||
.exec-wd {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted-foreground);
|
||||
margin-right: var(--exec-wd-margin);
|
||||
}
|
||||
|
||||
.exec-prompt {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.55;
|
||||
margin-right: var(--exec-wd-margin);
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
+6
-2
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
|
||||
@@ -26,7 +28,9 @@
|
||||
<span class="font-mono">{fileGlobMeta.include}</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
<span class="font-mono">{fileGlobMeta.path}</span>
|
||||
<span class="font-mono" title={fileGlobMeta.path}
|
||||
>{abbreviateHome(fileGlobMeta.path, home)}</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { Info, Loader2 } from '@lucide/svelte';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
let { section, isStreaming = false }: Props = $props();
|
||||
|
||||
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
|
||||
|
||||
type GetInfoMeta = {
|
||||
os?: string;
|
||||
cwd?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function parseGetInfoMeta(toolResultString: string | undefined): GetInfoMeta {
|
||||
if (!toolResultString) return {};
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.error === 'string') return { errorMessage: obj.error };
|
||||
return {
|
||||
os: typeof obj.os === 'string' ? obj.os : undefined,
|
||||
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not JSON - nothing to show
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
const infoMeta = $derived(parseGetInfoMeta(section.toolResult));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const cwdDisplay = $derived(abbreviateHome(infoMeta.cwd ?? '', home));
|
||||
</script>
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
|
||||
<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if infoMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
<span class="text-red-600 text-xs italic dark:text-red-400">- {infoMeta.errorMessage}</span
|
||||
>
|
||||
{:else if infoMeta.os || infoMeta.cwd}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
{#if infoMeta.os}
|
||||
<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
|
||||
{/if}
|
||||
{#if infoMeta.cwd}
|
||||
<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
|
||||
{/if}
|
||||
</div>
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseGrepSearchMeta } from './parsers/grep-search';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const grepMeta = $derived(parseGrepSearchMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
|
||||
@@ -22,7 +24,7 @@
|
||||
<span class="text-muted-foreground">Search for </span>
|
||||
<span class="font-mono">{grepMeta.pattern}</span>
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
<span class="font-mono">{grepMeta.path}</span>
|
||||
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
+6
-2
@@ -2,7 +2,8 @@
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseWriteFileMeta } from './parsers/write-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -16,12 +17,15 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const writeFileMeta = $derived(parseWriteFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Write file </span>
|
||||
<span class="font-mono">{writeFileMeta?.filePath}</span>
|
||||
<span class="font-mono" title={writeFileMeta?.filePath}
|
||||
>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
|
||||
>
|
||||
{#if writeFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
|
||||
@@ -272,6 +272,16 @@ export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResou
|
||||
*/
|
||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
/**
|
||||
* Working directory selector for agent mode. Renders a chip below the chat
|
||||
* form; clicking it opens a popover with a directory picker backed by the
|
||||
* server's `file_glob_search` built-in tool (POST /tools). The picked
|
||||
* directory is exposed via `bind:directory`; changing it records a
|
||||
* synthetic "Set working directory to ..." user message into chat history
|
||||
* and is enforced on tool calls via the `x-tool-cwd` request header.
|
||||
*/
|
||||
export { default as ChatFormWorkingDirectory } from './ChatForm/ChatFormWorkingDirectory.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickerMcpPrompts** - MCP prompt selection interface
|
||||
*
|
||||
@@ -557,6 +567,22 @@ export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessag
|
||||
*/
|
||||
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte';
|
||||
|
||||
/**
|
||||
* Synthetic working-directory-change message. Rendered in place of a user
|
||||
* bubble when the message content parses as a cwd message (see
|
||||
* parseCwdMessage); shows the new cwd with the same folder-row treatment
|
||||
* the tool-call UI used.
|
||||
*/
|
||||
export { default as ChatMessageCwdChange } from './ChatMessages/ChatMessage/ChatMessageCwdChange.svelte';
|
||||
|
||||
/**
|
||||
* Generic wrapper for UI-generated (synthetic) messages. Routes the
|
||||
* working-directory change to ChatMessageCwdChange and renders a muted
|
||||
* fallback for any other synthetic text, so no synthetic message ever
|
||||
* surfaces as a user bubble.
|
||||
*/
|
||||
export { default as ChatMessageSynthetic } from './ChatMessages/ChatMessage/ChatMessageSynthetic.svelte';
|
||||
|
||||
/**
|
||||
* Formatted content display for MCP prompt messages. Renders the full prompt
|
||||
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FilePlus,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Info,
|
||||
SearchCode,
|
||||
Terminal
|
||||
} from '@lucide/svelte';
|
||||
@@ -41,6 +42,7 @@ export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>>
|
||||
source: ToolSource.BUILTIN
|
||||
},
|
||||
[BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
|
||||
[BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
|
||||
[BuiltInTool.EXEC_SHELL_COMMAND]: {
|
||||
icon: Terminal,
|
||||
label: 'Run command',
|
||||
|
||||
@@ -40,6 +40,7 @@ export * from './mcp';
|
||||
export * from './mcp-form';
|
||||
export * from './mcp-resource';
|
||||
export * from './message-export';
|
||||
export * from './path-display';
|
||||
export * from './model-id';
|
||||
export * from './model-loading';
|
||||
export * from './sse';
|
||||
@@ -60,3 +61,4 @@ export * from './ui';
|
||||
export * from './uri-template';
|
||||
export * from './url';
|
||||
export * from './viewport';
|
||||
export * from './working-directory';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Constants for synthetic working-directory messages.
|
||||
*
|
||||
* The synthetic cwd-change message is text the UI renders as a folder row
|
||||
* and the model sees as a turn reminder. The prefix and cleared marker keep
|
||||
* the human-readable wording; the file-link regexes parse the
|
||||
* `[file:///abs/path](display)` payload back out on the UI side.
|
||||
*/
|
||||
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
|
||||
export const CWD_CHANGED_PREFIX = 'Set working directory to ';
|
||||
export const CWD_CLEARED_TEXT = 'Working directory cleared';
|
||||
|
||||
export const HOME_TILDE = '~';
|
||||
export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
|
||||
|
||||
/** Scheme prefix of the file link embedded in a synthetic cwd message. */
|
||||
export const FILE_URI_PREFIX = `${UrlProtocol.FILE}//`;
|
||||
|
||||
/** Matches the leading `[file:///abs/path](display)` link; not anchored to the end so trailing guidance may follow. */
|
||||
export const CWD_LINK_REGEX = /^\[file:\/\/([\s\S]*?)\]\(([\s\S]*?)\)/;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ToolSource } from '$lib/enums/tools.enums';
|
||||
|
||||
/** HTTP header carrying the working directory a tool call runs in. The server resolves relative paths against it; the model cannot override it. */
|
||||
export const X_TOOL_CWD_HEADER = 'x-tool-cwd';
|
||||
|
||||
export const TOOL_GROUP_LABELS = {
|
||||
[ToolSource.BUILTIN]: 'Built-in',
|
||||
[ToolSource.CUSTOM]: 'JSON Schema',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Constants for the working-directory picker's glob search.
|
||||
*
|
||||
* The picker glob-matches home-relative names client-side. Character classes
|
||||
* are built case-insensitively and the reserved glob metacharacters are
|
||||
* escaped (passed through literally) so a query never changes matching.
|
||||
*/
|
||||
|
||||
export const GLOB_WILDCARD = '*';
|
||||
|
||||
/** Character that starts and ends a glob character-class fragment. */
|
||||
export const GLOB_RANGE_OPEN = '[';
|
||||
export const GLOB_RANGE_CLOSE = ']';
|
||||
|
||||
/** Query characters that carry glob meaning and are passed through literally. */
|
||||
export const GLOB_SPECIAL_CHARS = '*?[]';
|
||||
|
||||
/** Separator Windows accepts alongside `/`, and a legal POSIX filename character. */
|
||||
export const WINDOWS_SEPARATOR = '\\';
|
||||
|
||||
/** `C:`, the drive part of a Windows absolute path. */
|
||||
export const DRIVE_PREFIX_REGEX = /^[A-Za-z]:/;
|
||||
|
||||
/** `C:` or `C:/`, the root of a Windows drive-absolute path. */
|
||||
export const DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?/;
|
||||
|
||||
/** `//host/share` or `//host/share/`, the root of a UNC path. */
|
||||
export const UNC_ROOT_REGEX = /^\/\/[^/]+\/[^/]+\/?/;
|
||||
|
||||
// Search tuning for the picker's file_glob_search calls.
|
||||
export const SEARCH_DEBOUNCE_MS = 180;
|
||||
export const SEARCH_LIMIT = 100;
|
||||
export const MAX_RESULTS_SHOWN = 20;
|
||||
// Home-relative globs descend deeper than path navigation, which only
|
||||
// needs the direct children of the parent.
|
||||
export const SEARCH_MAX_DEPTH = 6;
|
||||
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;
|
||||
@@ -72,6 +72,12 @@ export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol }
|
||||
|
||||
export { KeyboardKey } from './keyboard.enums';
|
||||
|
||||
export { BuiltInTool, ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
|
||||
export {
|
||||
BuiltInTool,
|
||||
GlobSearchType,
|
||||
ToolSource,
|
||||
ToolPermissionDecision,
|
||||
ToolResponseField
|
||||
} from './tools.enums';
|
||||
|
||||
export { SplashOrientation } from './splash.enums';
|
||||
|
||||
@@ -17,6 +17,16 @@ export enum ToolResponseField {
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry types accepted by the `file_glob_search` tool's `type` parameter.
|
||||
* Mirrors the server-side validation in server-tools.cpp.
|
||||
*/
|
||||
export enum GlobSearchType {
|
||||
FILE = 'file',
|
||||
DIR = 'dir',
|
||||
ALL = 'all'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-format identifiers for built-in and frontend tools. The string
|
||||
* value matches what the model emits in tool call names, so comparing
|
||||
@@ -30,6 +40,7 @@ export enum BuiltInTool {
|
||||
EDIT_FILE = 'edit_file',
|
||||
WRITE_FILE = 'write_file',
|
||||
GET_DATETIME = 'get_datetime',
|
||||
GET_INFO = 'get_info',
|
||||
FILE_GLOB_SEARCH = 'file_glob_search',
|
||||
GREP_SEARCH = 'grep_search',
|
||||
EXEC_SHELL_COMMAND = 'exec_shell_command',
|
||||
|
||||
@@ -24,6 +24,7 @@ export enum McpPromptVariant {
|
||||
*/
|
||||
export enum UrlProtocol {
|
||||
DATA = 'data:',
|
||||
FILE = 'file:',
|
||||
HTTP = 'http:',
|
||||
HTTPS = 'https:',
|
||||
WEBSOCKET = 'ws:',
|
||||
|
||||
@@ -674,7 +674,8 @@ export class DatabaseService {
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}))
|
||||
: undefined
|
||||
: undefined,
|
||||
cwd: sourceConv.cwd
|
||||
};
|
||||
|
||||
await db[IDXDB_TABLES.conversations].add(newConv);
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('ParameterSyncService', () => {
|
||||
dry_multiplier: 0.0,
|
||||
dry_base: 1.75,
|
||||
dry_allowed_length: 2,
|
||||
dry_penalty_last_n: -1,
|
||||
dry_penalty_last_n: 64,
|
||||
mirostat: 0,
|
||||
mirostat_tau: 5.0,
|
||||
mirostat_eta: 0.1,
|
||||
@@ -96,7 +96,7 @@ describe('ParameterSyncService', () => {
|
||||
dry_multiplier: 0.0,
|
||||
dry_base: 1.75,
|
||||
dry_allowed_length: 2,
|
||||
dry_penalty_last_n: -1,
|
||||
dry_penalty_last_n: 64,
|
||||
mirostat: 0,
|
||||
mirostat_tau: 5.0,
|
||||
mirostat_eta: 0.1,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { base } from '$app/paths';
|
||||
import { getJsonHeaders } from '$lib/utils/api-headers';
|
||||
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
|
||||
import { apiFetch } from '$lib/utils';
|
||||
import { API_TOOLS } from '$lib/constants';
|
||||
import { API_TOOLS, X_TOOL_CWD_HEADER } from '$lib/constants';
|
||||
import { ToolResponseField } from '$lib/enums';
|
||||
import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
|
||||
|
||||
@@ -18,15 +18,21 @@ export class ToolsService {
|
||||
|
||||
/**
|
||||
* Execute a built-in tool on the server.
|
||||
*
|
||||
* @param cwd - Working directory for the tool call, sent as the
|
||||
* x-tool-cwd request header. The server resolves relative paths
|
||||
* against it; the model cannot override it.
|
||||
*/
|
||||
static async executeTool(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
cwd?: string
|
||||
): Promise<ToolExecutionResult> {
|
||||
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool: toolName, params }),
|
||||
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
|
||||
signal
|
||||
});
|
||||
|
||||
@@ -41,6 +47,25 @@ export class ToolsService {
|
||||
return { content: JSON.stringify(result), isError: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a built-in tool and return the raw JSON response. Unlike
|
||||
* executeTool, this preserves structured fields (e.g. file_glob_search's
|
||||
* `entries` and `base`) that the flattened ToolExecutionResult drops.
|
||||
*/
|
||||
static async executeToolRaw(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
cwd?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool: toolName, params }),
|
||||
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
|
||||
signal
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a built-in tool's output chunks from the server. The server
|
||||
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
|
||||
@@ -59,9 +84,11 @@ export class ToolsService {
|
||||
static async *streamTool(
|
||||
toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
cwd?: string
|
||||
): AsyncGenerator<ToolStreamEvent> {
|
||||
const headers = getJsonHeaders();
|
||||
if (cwd) headers[X_TOOL_CWD_HEADER] = cwd;
|
||||
const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import { ChatService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
@@ -812,11 +813,12 @@ class AgenticStore {
|
||||
updateToolResultMessage
|
||||
) {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
const msg = await createToolResultMessage(toolCall.id, '');
|
||||
const cwd = conversationsStore.activeConversation?.cwd;
|
||||
const msg = await createToolResultMessage(toolCall.id, '', undefined, cwd);
|
||||
createdToolResultMessageId = msg.id;
|
||||
|
||||
let accumulated = '';
|
||||
for await (const ev of ToolsService.streamTool(toolName, args, signal)) {
|
||||
for await (const ev of ToolsService.streamTool(toolName, args, signal, cwd)) {
|
||||
if (ev.chunk !== null) {
|
||||
accumulated += ev.chunk;
|
||||
await updateToolResultMessage(msg.id, accumulated);
|
||||
@@ -835,7 +837,8 @@ class AgenticStore {
|
||||
result = accumulated;
|
||||
} else if (toolSource === ToolSource.BUILTIN) {
|
||||
const args = this.parseToolArguments(toolCall.function.arguments);
|
||||
const executionResult = await ToolsService.executeTool(toolName, args, signal);
|
||||
const cwd = conversationsStore.activeConversation?.cwd;
|
||||
const executionResult = await ToolsService.executeTool(toolName, args, signal, cwd);
|
||||
|
||||
result = executionResult.content;
|
||||
|
||||
|
||||
@@ -35,9 +35,12 @@ import {
|
||||
findDescendantMessages,
|
||||
findLeafNode,
|
||||
findMessageById,
|
||||
formatCwdMessage,
|
||||
isAbortError,
|
||||
generateConversationTitle
|
||||
generateConversationTitle,
|
||||
CWD_CLEARED_TEXT
|
||||
} from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { classifyContinueIntent } from '$lib/utils/agentic';
|
||||
import {
|
||||
MAX_INACTIVE_CONVERSATION_STATES,
|
||||
@@ -870,7 +873,8 @@ class ChatStore {
|
||||
content: string,
|
||||
type: MessageType = MessageType.TEXT,
|
||||
parent: string = '-1',
|
||||
extras?: DatabaseMessageExtra[]
|
||||
extras?: DatabaseMessageExtra[],
|
||||
isSynthetic?: boolean
|
||||
): Promise<DatabaseMessage> {
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
if (!activeConv) throw new Error('No active conversation');
|
||||
@@ -893,7 +897,8 @@ class ChatStore {
|
||||
timestamp: Date.now(),
|
||||
toolCalls: '',
|
||||
children: [],
|
||||
extra: extras
|
||||
extra: extras,
|
||||
isSynthetic
|
||||
},
|
||||
parentId
|
||||
);
|
||||
@@ -903,6 +908,33 @@ class ChatStore {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a working-directory change into chat history as a synthetic
|
||||
* user message, so the model sees it on its next turn (the client
|
||||
* sends the cwd itself via the x-tool-cwd header on tool calls).
|
||||
* A plain user message is used because some chat templates reject
|
||||
* tool messages without a preceding tool call.
|
||||
*/
|
||||
async recordCwdChange(cwd: string | null): Promise<void> {
|
||||
const content = cwd
|
||||
? formatCwdMessage(cwd, await toolsStore.resolveServerHome())
|
||||
: CWD_CLEARED_TEXT;
|
||||
|
||||
// Reuse the trailing cwd row when it is already the last message, so
|
||||
// repeated picks update it in place instead of stacking another row.
|
||||
const last = conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
|
||||
if (last && last.role === MessageRole.USER && last.isSynthetic === true) {
|
||||
await DatabaseService.updateMessage(last.id, { content, isSynthetic: true });
|
||||
conversationsStore.updateMessageAtIndex(conversationsStore.activeMessages.length - 1, {
|
||||
content,
|
||||
isSynthetic: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
|
||||
}
|
||||
|
||||
async addSystemPrompt(): Promise<void> {
|
||||
let activeConv = conversationsStore.activeConversation;
|
||||
if (!activeConv) {
|
||||
@@ -1055,6 +1087,7 @@ class ChatStore {
|
||||
const rootId = await DatabaseService.createRootMessage(currentConv.id);
|
||||
const currentConfig = config();
|
||||
const systemPrompt = currentConfig.systemMessage?.toString().trim();
|
||||
let sysOrRootId = rootId;
|
||||
if (systemPrompt) {
|
||||
const systemMessage = await DatabaseService.createSystemMessage(
|
||||
currentConv.id,
|
||||
@@ -1062,8 +1095,25 @@ class ChatStore {
|
||||
rootId
|
||||
);
|
||||
conversationsStore.addMessageToActive(systemMessage);
|
||||
parentIdForUserMessage = systemMessage.id;
|
||||
} else parentIdForUserMessage = rootId;
|
||||
sysOrRootId = systemMessage.id;
|
||||
}
|
||||
// Reflect a working directory picked on the new-chat screen into
|
||||
// chat history before the first user message, so the model sees
|
||||
// it on its first turn. createConversation() has already threaded
|
||||
// the pending pick onto the conversation.
|
||||
if (currentConv.cwd) {
|
||||
const cwdMessage = await this.addMessage(
|
||||
MessageRole.USER,
|
||||
formatCwdMessage(currentConv.cwd, await toolsStore.resolveServerHome()),
|
||||
MessageType.TEXT,
|
||||
sysOrRootId,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
parentIdForUserMessage = cwdMessage.id;
|
||||
} else {
|
||||
parentIdForUserMessage = sysOrRootId;
|
||||
}
|
||||
}
|
||||
const userMessage = await this.addMessage(
|
||||
MessageRole.USER,
|
||||
@@ -1282,7 +1332,8 @@ class ChatStore {
|
||||
createToolResultMessage: async (
|
||||
toolCallId: string,
|
||||
content: string,
|
||||
extras?: DatabaseMessageExtra[]
|
||||
extras?: DatabaseMessageExtra[],
|
||||
toolCwd?: string
|
||||
) => {
|
||||
const msg = await DatabaseService.createMessageBranch(
|
||||
{
|
||||
@@ -1291,6 +1342,7 @@ class ChatStore {
|
||||
role: MessageRole.TOOL,
|
||||
content,
|
||||
toolCallId,
|
||||
toolCwd,
|
||||
timestamp: Date.now(),
|
||||
toolCalls: '',
|
||||
children: [],
|
||||
|
||||
@@ -86,6 +86,15 @@ class ConversationsStore {
|
||||
/** Global (non-conversation-specific) reasoning effort default */
|
||||
pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
|
||||
|
||||
/**
|
||||
* Working directory picked on the empty new-chat screen, before any
|
||||
* conversation exists. Consumed by `chatStore.sendMessage()`, which
|
||||
* records it into chat history as a synthetic message on first send.
|
||||
* Cleared by `loadConversation` and `clearActiveConversation` so a
|
||||
* stale pick can't bleed onto an unrelated chat.
|
||||
*/
|
||||
pendingCwd = $state<string | null>(null);
|
||||
|
||||
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
|
||||
private static loadReasoningEffortDefault(): ReasoningEffort {
|
||||
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
|
||||
@@ -250,9 +259,13 @@ class ConversationsStore {
|
||||
// No MCP override list is seeded: getAllMcpServerOverrides resolves
|
||||
// servers without a per-conversation override to `mcpServers[i].enabled`,
|
||||
// and only explicit toggles are stored on the conversation.
|
||||
// Working directory picked on the new-chat screen gets threaded in
|
||||
// here too, then cleared so it doesn't bleed onto subsequent new chats.
|
||||
const conversation = await DatabaseService.createConversation(conversationName, {
|
||||
reasoningEffort: this.pendingReasoningEffort
|
||||
reasoningEffort: this.pendingReasoningEffort,
|
||||
cwd: this.pendingCwd ?? undefined
|
||||
});
|
||||
this.pendingCwd = null;
|
||||
|
||||
this.conversations = [conversation, ...this.conversations];
|
||||
this.activeConversation = conversation;
|
||||
@@ -276,6 +289,10 @@ class ConversationsStore {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop any cwd the user drafted on the empty new-chat screen -
|
||||
// it doesn't belong to this conversation.
|
||||
this.pendingCwd = null;
|
||||
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
@@ -306,6 +323,7 @@ class ConversationsStore {
|
||||
this.activeMessages = [];
|
||||
// reload defaults so new chats inherit persisted state
|
||||
this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
|
||||
this.pendingCwd = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -855,6 +873,42 @@ class ConversationsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the working directory for the active conversation. Pass `null` or
|
||||
* an empty string to clear it, which restores the picker's empty state.
|
||||
*
|
||||
* On the empty new-chat screen (no active conversation yet), the value
|
||||
* is buffered into `pendingCwd` so the user can pick before
|
||||
* sending the first message; `createConversation()` consumes it.
|
||||
*
|
||||
* @param value - Absolute server-side path to the working directory, or null to clear
|
||||
*/
|
||||
async setCwd(value: string | null): Promise<void> {
|
||||
const trimmed = value?.trim() || undefined;
|
||||
|
||||
// No chat yet - buffer for the first chat the user creates.
|
||||
if (!this.activeConversation) {
|
||||
this.pendingCwd = trimmed ?? null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeConversation = {
|
||||
...this.activeConversation,
|
||||
cwd: trimmed
|
||||
};
|
||||
|
||||
await DatabaseService.updateConversation(this.activeConversation.id, {
|
||||
cwd: trimmed
|
||||
});
|
||||
|
||||
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||
if (convIndex !== -1) {
|
||||
this.conversations[convIndex].cwd = trimmed;
|
||||
this.conversations = [...this.conversations];
|
||||
}
|
||||
this.pendingCwd = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks a conversation at a specific message, creating a new conversation
|
||||
* containing messages from root up to the target message, then navigates to it.
|
||||
@@ -1169,6 +1223,7 @@ if (browser) {
|
||||
export const conversations = () => conversationsStore.conversations;
|
||||
export const activeConversation = () => conversationsStore.activeConversation;
|
||||
export const activeMessages = () => conversationsStore.activeMessages;
|
||||
export const pendingCwd = () => conversationsStore.pendingCwd;
|
||||
export const isConversationsInitialized = () => conversationsStore.isInitialized;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
|
||||
import {
|
||||
BuiltInTool,
|
||||
GlobSearchType,
|
||||
HealthCheckStatus,
|
||||
JsonSchemaType,
|
||||
ToolCallType,
|
||||
ToolSource
|
||||
} from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
|
||||
buildSandboxToolDefinition,
|
||||
HOME_TILDE,
|
||||
TOOL_GROUP_LABELS,
|
||||
TOOL_SERVER_LABELS
|
||||
} from '$lib/constants';
|
||||
@@ -20,6 +28,7 @@ class ToolsStore {
|
||||
private _error = $state<string | null>(null);
|
||||
private _disabledTools = $state(new SvelteSet<string>());
|
||||
private _toolsEndpointUnreachable = $state(false);
|
||||
private _serverHome = $state<string | null | undefined>(undefined);
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
@@ -138,6 +147,10 @@ class ToolsStore {
|
||||
return this._builtinTools;
|
||||
}
|
||||
|
||||
get serverHome(): string | null {
|
||||
return this._serverHome ?? null;
|
||||
}
|
||||
|
||||
get mcpTools(): OpenAIToolDefinition[] {
|
||||
return this.mcpEntries().map((e) => e.definition);
|
||||
}
|
||||
@@ -488,6 +501,29 @@ class ToolsStore {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute home directory on the server, resolved once per session via
|
||||
* file_glob_search's `base` field (the server expands `~`). Anchors the
|
||||
* directory picker's search scope and the `~` abbreviation of cwd
|
||||
* displays. Returns null when tools are unavailable.
|
||||
*/
|
||||
async resolveServerHome(): Promise<string | null> {
|
||||
if (this._serverHome !== undefined) return this._serverHome;
|
||||
try {
|
||||
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
|
||||
path: HOME_TILDE,
|
||||
type: GlobSearchType.DIR,
|
||||
max_depth: 1,
|
||||
limit: 1
|
||||
});
|
||||
this._serverHome = typeof res.base === 'string' ? res.base : null;
|
||||
} catch {
|
||||
// searches still work via a literal `~`, only `~` abbreviation degrades
|
||||
this._serverHome = null;
|
||||
}
|
||||
return this._serverHome;
|
||||
}
|
||||
}
|
||||
|
||||
export const toolsStore = new ToolsStore();
|
||||
|
||||
Vendored
+2
-1
@@ -109,7 +109,8 @@ export interface AgenticFlowCallbacks {
|
||||
createToolResultMessage?: (
|
||||
toolCallId: string,
|
||||
content: string,
|
||||
extras?: DatabaseMessageExtra[]
|
||||
extras?: DatabaseMessageExtra[],
|
||||
toolCwd?: string
|
||||
) => Promise<DatabaseMessage>;
|
||||
/** Update an already-created tool result message. Used while a streaming
|
||||
* tool (e.g. exec_shell_command) accumulates output chunks before its
|
||||
|
||||
Vendored
+2
-1
@@ -108,7 +108,8 @@ export interface ChatStreamCallbacks {
|
||||
createToolResultMessage?: (
|
||||
toolCallId: string,
|
||||
content: string,
|
||||
extras?: DatabaseMessageExtra[]
|
||||
extras?: DatabaseMessageExtra[],
|
||||
toolCwd?: string
|
||||
) => Promise<DatabaseMessage>;
|
||||
updateToolResultMessage?: (
|
||||
messageId: string,
|
||||
|
||||
Vendored
+5
@@ -14,6 +14,7 @@ export interface DatabaseConversation {
|
||||
mcpServerOverrides?: McpServerOverride[];
|
||||
thinkingEnabled?: boolean;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
cwd?: string;
|
||||
forkedFromConversationId?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
@@ -119,6 +120,10 @@ export interface DatabaseMessage {
|
||||
completionId?: string;
|
||||
/** Tool call ID for tool result messages (role: 'tool') */
|
||||
toolCallId?: string;
|
||||
/** Working directory the tool call ran with (sent via the x-tool-cwd header), stored per call so the UI can show it accurately even after the conversation cwd changes */
|
||||
toolCwd?: string;
|
||||
/** Internal flag marking a UI-generated message (e.g. a cwd change). The row is sent to the model as a "user" turn so chat templates accept it; the flag is only read by the renderer. */
|
||||
isSynthetic?: boolean;
|
||||
children: string[];
|
||||
extra?: DatabaseMessageExtra[];
|
||||
timings?: ChatMessageTimings;
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface AgenticSection {
|
||||
toolArgs?: string;
|
||||
toolResult?: string;
|
||||
toolResultExtras?: DatabaseMessageExtra[];
|
||||
/** Working directory the tool call ran with (from the tool result
|
||||
* message), shown by the exec_shell_command renderer. */
|
||||
toolCwd?: string;
|
||||
/** ID of the model-side tool call (matches tool_calls[i].id). Lets
|
||||
* downstream consumers correlate a section with the agentic loop's
|
||||
* currently-executing tool, e.g. to drive live-streaming UI state
|
||||
@@ -116,6 +119,7 @@ function deriveSingleTurnSections(
|
||||
toolArgs: tc.function?.arguments,
|
||||
toolResult: resultMsg?.content,
|
||||
toolResultExtras: resultMsg?.extra,
|
||||
toolCwd: resultMsg?.toolCwd,
|
||||
toolCallId: tc.id
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,6 +158,29 @@ export { createBase64DataUrl } from './data-url';
|
||||
// Header utilities
|
||||
export { parseHeadersToArray, serializeHeaders } from './headers';
|
||||
|
||||
// Working-directory display helpers (HOME-style tilde abbreviation)
|
||||
export {
|
||||
abbreviateWorkingDir,
|
||||
abbreviateHome,
|
||||
lastPathSegment,
|
||||
formatCwdMessage,
|
||||
parseCwdMessage,
|
||||
CWD_CHANGED_PREFIX,
|
||||
CWD_CLEARED_TEXT,
|
||||
type CwdMessageInfo
|
||||
} from './path-display';
|
||||
|
||||
// Working-directory picker search helpers
|
||||
export {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch,
|
||||
type GlobEntry,
|
||||
type PathQuery
|
||||
} from './working-directory';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
|
||||
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
|
||||
import {
|
||||
CWD_CHANGED_PREFIX,
|
||||
CWD_CLEARED_TEXT,
|
||||
CWD_LINK_REGEX,
|
||||
FILE_URI_PREFIX,
|
||||
HOME_TILDE,
|
||||
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.
|
||||
*/
|
||||
export function abbreviateWorkingDir(
|
||||
path: string | null | undefined,
|
||||
home: string | null | undefined
|
||||
): string {
|
||||
if (!path) return '';
|
||||
if (!home) return lastPathSegment(path);
|
||||
if (path === home) return HOME_TILDE;
|
||||
if (path.startsWith(home + PATH_SEPARATOR))
|
||||
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
|
||||
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.
|
||||
*/
|
||||
export function abbreviateHome(path: string, home: string | null | undefined): string {
|
||||
if (!home) return path;
|
||||
if (path === home) return HOME_TILDE;
|
||||
if (path.startsWith(home + PATH_SEPARATOR))
|
||||
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
|
||||
return path;
|
||||
}
|
||||
|
||||
export { CWD_CHANGED_PREFIX, CWD_CLEARED_TEXT } from '$lib/constants';
|
||||
|
||||
export interface CwdMessageInfo {
|
||||
// absolute server-side path, null when the cwd was cleared
|
||||
path: string | null;
|
||||
// display form shown in the UI (e.g. ~/Documents)
|
||||
display: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function formatCwdMessage(cwd: string, home: string | null): string {
|
||||
const display = abbreviateWorkingDir(cwd, home);
|
||||
return `${CWD_CHANGED_PREFIX}[${FILE_URI_PREFIX}${cwd}](${display}).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function parseCwdMessage(content: string): CwdMessageInfo | null {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed === CWD_CLEARED_TEXT) {
|
||||
return { path: null, display: '' };
|
||||
}
|
||||
if (trimmed.startsWith(CWD_CHANGED_PREFIX)) {
|
||||
const rest = trimmed.slice(CWD_CHANGED_PREFIX.length);
|
||||
// not anchored to the end: guidance may follow the link
|
||||
const link = rest.match(CWD_LINK_REGEX);
|
||||
if (link) return { path: link[1], display: link[2] };
|
||||
return { path: rest, display: rest };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
|
||||
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
|
||||
import {
|
||||
DRIVE_PREFIX_REGEX,
|
||||
DRIVE_ROOT_REGEX,
|
||||
GLOB_RANGE_CLOSE,
|
||||
GLOB_RANGE_OPEN,
|
||||
GLOB_SPECIAL_CHARS,
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
LEADING_SLASHES_REGEX,
|
||||
UNC_ROOT_REGEX,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { lastPathSegment } from './path-display';
|
||||
|
||||
export interface GlobEntry {
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PathQuery {
|
||||
parent: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `\` into `/` when the query carries a Windows root. Elsewhere the
|
||||
* backslash is left alone: it is a legal filename character on POSIX.
|
||||
*/
|
||||
function toPosixSeparators(query: string): string {
|
||||
if (!DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(WINDOWS_SEPARATOR)) return query;
|
||||
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;
|
||||
const drive = path.match(DRIVE_ROOT_REGEX);
|
||||
if (drive) return drive[0].length;
|
||||
return path.startsWith(PATH_SEPARATOR) ? PATH_SEPARATOR.length : 0;
|
||||
}
|
||||
|
||||
/** A query starting from a root or from `~` is path navigation, not a home-relative glob. */
|
||||
export function splitPathQuery(query: string): PathQuery | null {
|
||||
const normalized = toPosixSeparators(query);
|
||||
const rootLength = rootPrefixLength(normalized);
|
||||
if (rootLength === 0 && !normalized.startsWith(HOME_TILDE)) return null;
|
||||
|
||||
// a root keeps its trailing separator so it stays absolute on its own
|
||||
const root =
|
||||
rootLength > 0
|
||||
? normalized.slice(0, rootLength).replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR
|
||||
: HOME_TILDE;
|
||||
|
||||
const rest = normalized
|
||||
.slice(rootLength > 0 ? rootLength : HOME_TILDE.length)
|
||||
.replace(LEADING_SLASHES_REGEX, '')
|
||||
.replace(TRAILING_SLASHES_REGEX, '');
|
||||
|
||||
const parentOf = (dirs: string) =>
|
||||
rootLength > 0 ? root + dirs : HOME_TILDE + PATH_SEPARATOR + dirs;
|
||||
|
||||
if (!rest) return { parent: root, last: '' };
|
||||
|
||||
const idx = rest.lastIndexOf(PATH_SEPARATOR);
|
||||
if (idx === -1) return { parent: root, last: rest };
|
||||
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) {
|
||||
const lo = c.toLowerCase();
|
||||
const up = c.toUpperCase();
|
||||
if (lo !== up) out += GLOB_RANGE_OPEN + lo + up + GLOB_RANGE_CLOSE;
|
||||
// glob metacharacters are escaped into a literal character class so a
|
||||
// query like "a*b" matches a literal '*' instead of becoming "ab"
|
||||
else if (GLOB_SPECIAL_CHARS.includes(c)) out += GLOB_RANGE_OPEN + c + GLOB_RANGE_CLOSE;
|
||||
else out += c;
|
||||
}
|
||||
return out + GLOB_WILDCARD;
|
||||
}
|
||||
|
||||
/** Exact basename first, then prefix, then substring; lower is better. */
|
||||
const RANK_EXACT = 0;
|
||||
const RANK_PREFIX = 1;
|
||||
const RANK_SUBSTRING = 2;
|
||||
const RANK_OTHER = 3;
|
||||
|
||||
function rankScore(path: string, query: string): number {
|
||||
const name = lastPathSegment(path).toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
if (name === q) return RANK_EXACT;
|
||||
if (name.startsWith(q)) return RANK_PREFIX;
|
||||
if (name.includes(q)) return RANK_SUBSTRING;
|
||||
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) =>
|
||||
rankScore(a.path, query) - rankScore(b.path, query) ||
|
||||
a.path.length - b.path.length ||
|
||||
a.path.localeCompare(b.path)
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const idx = lowerText.indexOf(lowerQuery, i);
|
||||
if (idx < 0) {
|
||||
segments.push({ text: text.slice(i), match: false });
|
||||
break;
|
||||
}
|
||||
if (idx > i) segments.push({ text: text.slice(i, idx), match: false });
|
||||
segments.push({ text: text.slice(idx, idx + query.length), match: true });
|
||||
i = idx + query.length;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import { lastPathSegment, abbreviateHome, formatCwdMessage, parseCwdMessage } from '$lib/utils';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
type WriteFileMeta
|
||||
@@ -27,6 +28,90 @@ function makeSection(
|
||||
};
|
||||
}
|
||||
|
||||
describe('lastPathSegment', () => {
|
||||
it('returns the last segment of an absolute path', () => {
|
||||
expect(lastPathSegment('/Users/me/code/my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('returns the last segment of a tilde-relative path', () => {
|
||||
expect(lastPathSegment('~/git/llama.brand')).toBe('llama.brand');
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(lastPathSegment('/foo/bar/')).toBe('bar');
|
||||
});
|
||||
|
||||
it('strips multiple trailing slashes', () => {
|
||||
expect(lastPathSegment('/foo/bar///')).toBe('bar');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when there is no slash', () => {
|
||||
expect(lastPathSegment('project')).toBe('project');
|
||||
});
|
||||
|
||||
it('returns tilde when only tilde is given', () => {
|
||||
expect(lastPathSegment('~/')).toBe('~');
|
||||
});
|
||||
});
|
||||
|
||||
describe('abbreviateHome', () => {
|
||||
it('abbreviates paths under home with a tilde', () => {
|
||||
expect(abbreviateHome('/Users/al/Documents/x.txt', '/Users/al')).toBe('~/Documents/x.txt');
|
||||
});
|
||||
|
||||
it('abbreviates home itself to a bare tilde', () => {
|
||||
expect(abbreviateHome('/Users/al', '/Users/al')).toBe('~');
|
||||
});
|
||||
|
||||
it('returns paths outside home unchanged', () => {
|
||||
expect(abbreviateHome('/opt/project', '/Users/al')).toBe('/opt/project');
|
||||
});
|
||||
|
||||
it('does not abbreviate a mere prefix match', () => {
|
||||
expect(abbreviateHome('/Users/alice/x', '/Users/al')).toBe('/Users/alice/x');
|
||||
});
|
||||
|
||||
it('returns the path unchanged when home is unknown', () => {
|
||||
expect(abbreviateHome('/Users/al/Documents', null)).toBe('/Users/al/Documents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCwdMessage / parseCwdMessage', () => {
|
||||
it('formats a cwd change matching the UI text, with a file link', () => {
|
||||
expect(formatCwdMessage('/Users/al/Documents', '/Users/al')).toBe(
|
||||
'Set working directory to [file:///Users/al/Documents](~/Documents).'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the basename display when home is unknown', () => {
|
||||
expect(formatCwdMessage('/opt/project', null)).toBe(
|
||||
'Set working directory to [file:///opt/project](project).'
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips through the parser', () => {
|
||||
const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
|
||||
expect(info?.path).toBe('/Users/al/Documents');
|
||||
expect(info?.display).toBe('~/Documents');
|
||||
});
|
||||
|
||||
it('parses a cwd message even when guidance follows the link', () => {
|
||||
expect(
|
||||
parseCwdMessage(
|
||||
'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
|
||||
)
|
||||
).toEqual({ path: '/a/b', display: '~/b' });
|
||||
});
|
||||
|
||||
it('parses the cleared marker', () => {
|
||||
expect(parseCwdMessage('Working directory cleared')).toEqual({ path: null, display: '' });
|
||||
});
|
||||
|
||||
it('returns null for non-cwd content', () => {
|
||||
expect(parseCwdMessage('hello there')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolArgs (shared)', () => {
|
||||
it('returns null when the section has no toolArgs', () => {
|
||||
const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch
|
||||
} from '$lib/utils';
|
||||
|
||||
describe('splitPathQuery', () => {
|
||||
it('treats a plain query as a home-relative glob (not navigation)', () => {
|
||||
expect(splitPathQuery('docs')).toBeNull();
|
||||
});
|
||||
|
||||
it('navigates the root for `/`', () => {
|
||||
expect(splitPathQuery('/')).toEqual({ parent: '/', last: '' });
|
||||
});
|
||||
|
||||
it('navigates home for `~`', () => {
|
||||
expect(splitPathQuery('~')).toEqual({ parent: '~', last: '' });
|
||||
});
|
||||
|
||||
it('splits an absolute path into parent and last segment', () => {
|
||||
expect(splitPathQuery('/Users/al/proj')).toEqual({ parent: '/Users/al', last: 'proj' });
|
||||
});
|
||||
|
||||
it('navigates a Windows drive path written with backslashes', () => {
|
||||
expect(splitPathQuery('C:\\repos\\llama.cpp')).toEqual({
|
||||
parent: 'C:/repos',
|
||||
last: 'llama.cpp'
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates a Windows drive path written with forward slashes', () => {
|
||||
expect(splitPathQuery('D:/repos')).toEqual({ parent: 'D:/', last: 'repos' });
|
||||
});
|
||||
|
||||
it('treats a bare drive as its root', () => {
|
||||
expect(splitPathQuery('D:')).toEqual({ parent: 'D:/', last: '' });
|
||||
expect(splitPathQuery('D:\\')).toEqual({ parent: 'D:/', last: '' });
|
||||
});
|
||||
|
||||
it('navigates a UNC share', () => {
|
||||
expect(splitPathQuery('\\\\host\\share\\proj')).toEqual({
|
||||
parent: '//host/share/',
|
||||
last: 'proj'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a backslash as a POSIX filename character', () => {
|
||||
expect(splitPathQuery('/tmp/a\\b')).toEqual({ parent: '/tmp', last: 'a\\b' });
|
||||
});
|
||||
|
||||
it('splits a home-relative path into parent and last segment', () => {
|
||||
expect(splitPathQuery('~/Documents')).toEqual({ parent: '~', last: 'Documents' });
|
||||
});
|
||||
|
||||
it('strips trailing slashes before splitting', () => {
|
||||
expect(splitPathQuery('/Users/al/')).toEqual({ parent: '/Users', last: 'al' });
|
||||
});
|
||||
|
||||
it('handles a single-segment absolute path', () => {
|
||||
expect(splitPathQuery('/opt')).toEqual({ parent: '/', last: 'opt' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCaseInsensitiveGlob', () => {
|
||||
it('wraps letters in case-insensitive character classes', () => {
|
||||
expect(buildCaseInsensitiveGlob('ab')).toBe('*[aA][bB]*');
|
||||
});
|
||||
|
||||
it('escapes glob metacharacters into literal fragments', () => {
|
||||
expect(buildCaseInsensitiveGlob('a*b')).toBe('*[aA][*][bB]*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankEntries', () => {
|
||||
const entries = [
|
||||
{ path: '/h/README', type: 'dir' },
|
||||
{ path: '/h/read', type: 'dir' },
|
||||
{ path: '/h/readme.txt', type: 'dir' }
|
||||
];
|
||||
|
||||
it('ranks exact basename match first', () => {
|
||||
const ranked = rankEntries(entries, 'read');
|
||||
expect(ranked[0].path).toBe('/h/read');
|
||||
});
|
||||
|
||||
it('breaks ties by shorter path, then alphabetically', () => {
|
||||
const ranked = rankEntries(entries, 'read');
|
||||
expect(ranked[ranked.length - 1].path).toBe('/h/readme.txt');
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const snapshot = [...entries];
|
||||
rankEntries(entries, 'read');
|
||||
expect(entries).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinPath', () => {
|
||||
it('joins base and relative avoiding a double slash', () => {
|
||||
expect(joinPath('/home/al/', 'docs')).toBe('/home/al/docs');
|
||||
});
|
||||
|
||||
it('returns the relative path when base is empty', () => {
|
||||
expect(joinPath('', 'docs')).toBe('docs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightMatch', () => {
|
||||
it('returns a single non-matching segment when query is empty', () => {
|
||||
expect(highlightMatch('abc', '')).toEqual([{ text: 'abc', match: false }]);
|
||||
});
|
||||
|
||||
it('marks every case-insensitive occurrence of the query', () => {
|
||||
expect(highlightMatch('aXa', 'ax')).toEqual([
|
||||
{ text: 'aX', match: true },
|
||||
{ text: 'a', match: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns non-matching text when the query is absent', () => {
|
||||
expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user