mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-05-19 08:00:25 +00:00
Merge commit '66001722aa' into concedo_experimental
# Conflicts: # README.md # docs/ops.md # docs/ops/SYCL.csv # examples/sycl/start-svr.sh # ggml/src/ggml-hexagon/ggml-hexagon.cpp # ggml/src/ggml-hexagon/htp/CMakeLists.txt # ggml/src/ggml-hexagon/htp/htp-ctx.h # ggml/src/ggml-hexagon/htp/htp-ops.h # ggml/src/ggml-hexagon/htp/main.c # ggml/src/ggml-hexagon/htp/unary-ops.c # ggml/src/ggml-opencl/CMakeLists.txt # ggml/src/ggml-opencl/ggml-opencl.cpp # ggml/src/ggml-opencl/kernels/cvt.cl # ggml/src/ggml-sycl/gated_delta_net.hpp # ggml/src/ggml-sycl/ggml-sycl.cpp # ggml/src/ggml-sycl/pad.cpp # ggml/src/ggml-sycl/ssm_conv.cpp # tests/test-backend-ops.cpp # tests/test-reasoning-budget.cpp # tools/server/README.md # tools/server/webui/src/lib/constants/settings-config.ts
This commit is contained in:
commit
9b0b36b5ef
97 changed files with 8192 additions and 4853 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -3926,22 +3926,7 @@ void server_routes::init_routes() {
|
|||
}},
|
||||
{"object", "list"},
|
||||
{"data", {
|
||||
{
|
||||
{"id", meta->model_name},
|
||||
{"aliases", meta->model_aliases},
|
||||
{"tags", meta->model_tags},
|
||||
{"object", "model"},
|
||||
{"created", std::time(0)},
|
||||
{"owned_by", "llamacpp"},
|
||||
{"meta", {
|
||||
{"vocab_type", meta->model_vocab_type},
|
||||
{"n_vocab", meta->model_vocab_n_tokens},
|
||||
{"n_ctx_train", meta->model_n_ctx_train},
|
||||
{"n_embd", meta->model_n_embd_inp},
|
||||
{"n_params", meta->model_n_params},
|
||||
{"size", meta->model_size},
|
||||
}},
|
||||
},
|
||||
get_model_info(),
|
||||
}}
|
||||
};
|
||||
|
||||
|
|
@ -4155,6 +4140,26 @@ void server_routes::init_routes() {
|
|||
};
|
||||
}
|
||||
|
||||
json server_routes::get_model_info() const {
|
||||
return json {
|
||||
{"id", meta->model_name},
|
||||
{"aliases", meta->model_aliases},
|
||||
{"tags", meta->model_tags},
|
||||
{"object", "model"},
|
||||
{"created", std::time(0)},
|
||||
{"owned_by", "llamacpp"},
|
||||
{"meta", {
|
||||
{"vocab_type", meta->model_vocab_type},
|
||||
{"n_vocab", meta->model_vocab_n_tokens},
|
||||
{"n_ctx", meta->slot_n_ctx},
|
||||
{"n_ctx_train", meta->model_n_ctx_train},
|
||||
{"n_embd", meta->model_n_embd_inp},
|
||||
{"n_params", meta->model_n_params},
|
||||
{"size", meta->model_size},
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
std::unique_ptr<server_res_generator> server_routes::handle_slots_save(const server_http_req & req, int id_slot) {
|
||||
auto res = create_response();
|
||||
const json request_data = json::parse(req.body);
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ struct server_routes {
|
|||
server_http_context::handler_t post_rerank;
|
||||
server_http_context::handler_t get_lora_adapters;
|
||||
server_http_context::handler_t post_lora_adapters;
|
||||
|
||||
// to be used in router mode
|
||||
json get_model_info() const;
|
||||
|
||||
private:
|
||||
std::unique_ptr<server_res_generator> handle_completions_impl(
|
||||
const server_http_req & req,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
#include <cpp-httplib/httplib.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
|
|
@ -51,11 +53,51 @@ static void log_server_request(const httplib::Request & req, const httplib::Resp
|
|||
SRV_DBG("response: %s\n", res.body.c_str());
|
||||
}
|
||||
|
||||
// For Google Cloud Platform deployment compatibility
|
||||
struct gcp_params {
|
||||
bool enabled;
|
||||
std::string path_health;
|
||||
std::string path_predict;
|
||||
int port;
|
||||
|
||||
// Ref: https://docs.cloud.google.com/vertex-ai/docs/predictions/custom-container-requirements#aip-variables
|
||||
gcp_params() {
|
||||
enabled = getenv("AIP_MODE", "") == "PREDICTION";
|
||||
path_health = getenv("AIP_HEALTH_ROUTE", "", true); // default: using the route defined in server.cpp
|
||||
path_predict = getenv("AIP_PREDICT_ROUTE", "/predict", true);
|
||||
port = std::stoi(getenv("AIP_HTTP_PORT", "8080"));
|
||||
}
|
||||
|
||||
static std::string getenv(const char * name, const std::string & default_value, bool ensure_leading_slash = false) {
|
||||
const char * value = std::getenv(name);
|
||||
if (value == nullptr || value[0] == '\0') {
|
||||
return default_value;
|
||||
}
|
||||
std::string val = value;
|
||||
if (ensure_leading_slash && !val.empty() && val[0] != '/') {
|
||||
val.insert(val.begin(), '/');
|
||||
}
|
||||
return val;
|
||||
}
|
||||
};
|
||||
|
||||
bool server_http_context::init(const common_params & params) {
|
||||
const gcp_params gcp;
|
||||
|
||||
path_prefix = params.api_prefix;
|
||||
port = params.port;
|
||||
hostname = params.hostname;
|
||||
|
||||
if (gcp.enabled) {
|
||||
LOG_INF("%s: Google Cloud Platform compat: health route = %s, predict route = %s, port = %d\n", __func__, gcp.path_health.c_str(), gcp.path_predict.c_str(), gcp.port);
|
||||
|
||||
if (port != gcp.port) {
|
||||
LOG_WRN("%s: Google Cloud Platform compat: overriding server port %d with AIP_HTTP_PORT %d\n", __func__, port, gcp.port);
|
||||
}
|
||||
|
||||
port = gcp.port;
|
||||
}
|
||||
|
||||
auto & srv = pimpl->srv;
|
||||
|
||||
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
|
||||
|
|
@ -420,6 +462,7 @@ static void process_handler_response(server_http_req_ptr && request, server_http
|
|||
}
|
||||
|
||||
void server_http_context::get(const std::string & path, const server_http_context::handler_t & handler) const {
|
||||
handlers.emplace(path, handler);
|
||||
pimpl->srv->Get(path_prefix + path, [handler](const httplib::Request & req, httplib::Response & res) {
|
||||
server_http_req_ptr request = std::make_unique<server_http_req>(server_http_req{
|
||||
get_params(req),
|
||||
|
|
@ -436,6 +479,7 @@ void server_http_context::get(const std::string & path, const server_http_contex
|
|||
}
|
||||
|
||||
void server_http_context::post(const std::string & path, const server_http_context::handler_t & handler) const {
|
||||
handlers.emplace(path, handler);
|
||||
pimpl->srv->Post(path_prefix + path, [handler](const httplib::Request & req, httplib::Response & res) {
|
||||
std::string body = req.body;
|
||||
std::map<std::string, uploaded_file> files;
|
||||
|
|
@ -481,3 +525,176 @@ void server_http_context::post(const std::string & path, const server_http_conte
|
|||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Vertex AI Prediction protocol (AIP_PREDICT_ROUTE)
|
||||
// https://cloud.google.com/vertex-ai/docs/predictions/custom-container-requirements
|
||||
//
|
||||
|
||||
// Derives the camelCase @requestFormat alias for a registered path.
|
||||
// e.g. "/v1/chat/completions" -> "chatCompletions", "/apply-template" -> "applyTemplate"
|
||||
static std::string path_to_gcp_format(const std::string & path) {
|
||||
std::string s = path;
|
||||
if (s.size() > 3 && s[0] == '/' && s[1] == 'v' && s[2] == '1') {
|
||||
s = s.substr(3);
|
||||
}
|
||||
if (!s.empty() && s[0] == '/') {
|
||||
s = s.substr(1);
|
||||
}
|
||||
std::string result;
|
||||
bool cap = false;
|
||||
for (unsigned char c : s) {
|
||||
if (c == ':') break; // stop before path parameters
|
||||
if (c == '/' || c == '-' || c == '_') {
|
||||
cap = true;
|
||||
} else {
|
||||
result += cap ? (char)std::toupper(c) : (char)c;
|
||||
cap = false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static json parse_gcp_predict_response(const server_http_res_ptr & res) {
|
||||
if (res == nullptr) {
|
||||
throw std::runtime_error("empty response from internal handler");
|
||||
}
|
||||
if (res->is_stream()) {
|
||||
throw std::invalid_argument("predict route does not support streaming responses");
|
||||
}
|
||||
if (res->data.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
try {
|
||||
return json::parse(res->data);
|
||||
} catch (...) {
|
||||
return res->data;
|
||||
}
|
||||
}
|
||||
|
||||
void server_http_context::register_gcp_compat() {
|
||||
const gcp_params gcp;
|
||||
|
||||
if (!gcp.enabled) {
|
||||
// do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
if (handlers.count(gcp.path_predict)) {
|
||||
LOG_ERR("%s: AIP_PREDICT_ROUTE=%s conflicts with an existing llama-server route\n", __func__, gcp.path_predict.c_str());
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// camelCase alias -> canonical path (first registration wins on collision)
|
||||
// e.g. "chatCompletions" -> "/v1/chat/completions"
|
||||
std::unordered_map<std::string, std::string> alias_to_path;
|
||||
for (const auto & [path, _] : handlers) {
|
||||
alias_to_path.emplace(path_to_gcp_format(path), path);
|
||||
}
|
||||
|
||||
if (!gcp.path_health.empty()) {
|
||||
auto health_handler = handlers.find("/health");
|
||||
GGML_ASSERT(health_handler != handlers.end());
|
||||
get(gcp.path_health, health_handler->second);
|
||||
}
|
||||
|
||||
post(gcp.path_predict, [this, alias_to_path = std::move(alias_to_path)](const server_http_req & req) -> server_http_res_ptr {
|
||||
static const auto build_error = [](const std::string & message, error_type type) -> json {
|
||||
return json {{"error", format_error_response(message, type)}};
|
||||
};
|
||||
|
||||
json data;
|
||||
try {
|
||||
data = json::parse(req.body);
|
||||
} catch (const std::exception & e) {
|
||||
auto res = std::make_unique<server_http_res>();
|
||||
res->status = 400;
|
||||
res->data = safe_json_to_str({{"error", format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST)}});
|
||||
return res;
|
||||
}
|
||||
if (!data.is_object()) {
|
||||
auto res = std::make_unique<server_http_res>();
|
||||
res->status = 400;
|
||||
res->data = safe_json_to_str({{"error", format_error_response("request body must be a JSON object", ERROR_TYPE_INVALID_REQUEST)}});
|
||||
return res;
|
||||
}
|
||||
if (!data.contains("instances") || !data.at("instances").is_array()) {
|
||||
auto res = std::make_unique<server_http_res>();
|
||||
res->status = 400;
|
||||
res->data = safe_json_to_str({{"error", format_error_response("request body must include an array field named instances", ERROR_TYPE_INVALID_REQUEST)}});
|
||||
return res;
|
||||
}
|
||||
|
||||
const json & instances = data.at("instances");
|
||||
static const size_t MAX_INSTANCES = 128;
|
||||
if (instances.size() > MAX_INSTANCES) {
|
||||
auto res = std::make_unique<server_http_res>();
|
||||
res->status = 400;
|
||||
res->data = safe_json_to_str({{"error", format_error_response("instances array exceeds maximum size of " + std::to_string(MAX_INSTANCES), ERROR_TYPE_INVALID_REQUEST)}});
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<std::future<json>> futures;
|
||||
futures.reserve(instances.size());
|
||||
|
||||
for (const auto & instance : instances) {
|
||||
futures.push_back(std::async(std::launch::async, [this, &req, &alias_to_path, instance]() -> json {
|
||||
if (!instance.is_object()) {
|
||||
return build_error("each instance must be a JSON object", ERROR_TYPE_INVALID_REQUEST);
|
||||
}
|
||||
if (!instance.contains("@requestFormat") || !instance.at("@requestFormat").is_string()) {
|
||||
return build_error("each instance must include a string @requestFormat", ERROR_TYPE_INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
json payload = instance;
|
||||
const std::string format = payload.at("@requestFormat").get<std::string>();
|
||||
payload.erase("@requestFormat");
|
||||
|
||||
if (payload.contains("stream")) {
|
||||
LOG_WRN("%s: ignoring client-provided stream field in instance, streaming is not supported in predict route\n", __func__);
|
||||
payload["stream"] = false;
|
||||
}
|
||||
|
||||
// accept both camelCase aliases (e.g. "chatCompletions") and direct paths
|
||||
std::string dispatch_path;
|
||||
auto it_alias = alias_to_path.find(format);
|
||||
if (it_alias != alias_to_path.end()) {
|
||||
dispatch_path = it_alias->second;
|
||||
} else if (handlers.count(format)) {
|
||||
dispatch_path = format;
|
||||
} else {
|
||||
return build_error("no handler registered for @requestFormat: " + format, ERROR_TYPE_INVALID_REQUEST);
|
||||
}
|
||||
|
||||
const server_http_req internal_req {
|
||||
req.params,
|
||||
req.headers,
|
||||
path_prefix + dispatch_path,
|
||||
req.query_string,
|
||||
payload.dump(),
|
||||
{},
|
||||
req.should_stop,
|
||||
};
|
||||
|
||||
server_http_res_ptr internal_res = handlers.at(dispatch_path)(internal_req);
|
||||
return parse_gcp_predict_response(internal_res);
|
||||
} catch (const std::invalid_argument & e) {
|
||||
return build_error(e.what(), ERROR_TYPE_INVALID_REQUEST);
|
||||
} catch (const std::exception & e) {
|
||||
return build_error(e.what(), ERROR_TYPE_SERVER);
|
||||
} catch (...) {
|
||||
return build_error("unknown error", ERROR_TYPE_SERVER);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
json predictions = json::array();
|
||||
for (auto & future : futures) {
|
||||
predictions.push_back(future.get());
|
||||
}
|
||||
|
||||
auto res = std::make_unique<server_http_res>();
|
||||
res->data = safe_json_to_str({{"predictions", predictions}});
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ struct server_http_context {
|
|||
std::thread thread; // server thread
|
||||
std::atomic<bool> is_ready = false;
|
||||
|
||||
// note: the handler should never throw exceptions
|
||||
using handler_t = std::function<server_http_res_ptr(const server_http_req & req)>;
|
||||
mutable std::unordered_map<std::string, handler_t> handlers;
|
||||
|
||||
std::string path_prefix;
|
||||
std::string hostname;
|
||||
int port;
|
||||
|
|
@ -78,12 +82,13 @@ struct server_http_context {
|
|||
bool start();
|
||||
void stop() const;
|
||||
|
||||
// note: the handler should never throw exceptions
|
||||
using handler_t = std::function<server_http_res_ptr(const server_http_req & req)>;
|
||||
|
||||
void get(const std::string & path, const handler_t & handler) const;
|
||||
void post(const std::string & path, const handler_t & handler) const;
|
||||
|
||||
// Register the Google Cloud Platform (Vertex AI) compat (AIP_PREDICT_ROUTE env var, or /predict)
|
||||
// Must be called AFTER all other API routes are registered
|
||||
void register_gcp_compat();
|
||||
|
||||
// for debugging
|
||||
std::string listening_address;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ extern char **environ;
|
|||
#define CMD_ROUTER_TO_CHILD_EXIT "cmd_router_to_child:exit"
|
||||
#define CMD_CHILD_TO_ROUTER_READY "cmd_child_to_router:ready" // also sent when waking up from sleep
|
||||
#define CMD_CHILD_TO_ROUTER_SLEEP "cmd_child_to_router:sleep"
|
||||
#define CMD_CHILD_TO_ROUTER_INFO "cmd_child_to_router:info:" // followed by json string
|
||||
|
||||
// address for child process, this is needed because router may run on 0.0.0.0
|
||||
// ref: https://github.com/ggml-org/llama.cpp/issues/17862
|
||||
|
|
@ -718,10 +719,11 @@ void server_models::load(const std::string & name) {
|
|||
|
||||
// prepare new instance info
|
||||
instance_t inst;
|
||||
inst.meta = meta;
|
||||
inst.meta.port = get_free_port();
|
||||
inst.meta.status = SERVER_MODEL_STATUS_LOADING;
|
||||
inst.meta.last_used = ggml_time_ms();
|
||||
inst.meta = meta;
|
||||
inst.meta.port = get_free_port();
|
||||
inst.meta.status = SERVER_MODEL_STATUS_LOADING;
|
||||
inst.meta.loaded_info = json{};
|
||||
inst.meta.last_used = ggml_time_ms();
|
||||
|
||||
if (inst.meta.port <= 0) {
|
||||
throw std::runtime_error("failed to get a port number");
|
||||
|
|
@ -767,12 +769,14 @@ void server_models::load(const std::string & name) {
|
|||
// read stdout/stderr and forward to main server log
|
||||
// also handle status report from child process
|
||||
if (stdout_file) {
|
||||
char buffer[4096];
|
||||
char buffer[128 * 1024]; // large buffer for storing info
|
||||
while (fgets(buffer, sizeof(buffer), stdout_file) != nullptr) {
|
||||
LOG("[%5d] %s", port, buffer);
|
||||
std::string str(buffer);
|
||||
if (string_starts_with(buffer, CMD_CHILD_TO_ROUTER_READY)) {
|
||||
this->update_status(name, SERVER_MODEL_STATUS_LOADED, 0);
|
||||
} else if (string_starts_with(buffer, CMD_CHILD_TO_ROUTER_INFO)) {
|
||||
this->update_loaded_info(name, str);
|
||||
} else if (string_starts_with(buffer, CMD_CHILD_TO_ROUTER_SLEEP)) {
|
||||
this->update_status(name, SERVER_MODEL_STATUS_SLEEPING, 0);
|
||||
}
|
||||
|
|
@ -916,6 +920,29 @@ void server_models::update_status(const std::string & name, server_model_status
|
|||
cv.notify_all();
|
||||
}
|
||||
|
||||
void server_models::update_loaded_info(const std::string & name, std::string & raw_info) {
|
||||
if (!string_starts_with(raw_info, CMD_CHILD_TO_ROUTER_INFO)) {
|
||||
SRV_WRN("invalid loaded info format from child for model name=%s: %s\n", name.c_str(), raw_info.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
json info;
|
||||
try {
|
||||
info = json::parse(raw_info.substr(strlen(CMD_CHILD_TO_ROUTER_INFO)));
|
||||
} catch (const std::exception & e) {
|
||||
SRV_WRN("failed to parse loaded info from child for model name=%s: %s\n", name.c_str(), e.what());
|
||||
return;
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto it = mapping.find(name);
|
||||
if (it != mapping.end()) {
|
||||
auto & meta = it->second.meta;
|
||||
meta.loaded_info = info;
|
||||
}
|
||||
cv.notify_all();
|
||||
}
|
||||
|
||||
void server_models::wait_until_loading_finished(const std::string & name) {
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
cv.wait(lk, [this, &name]() {
|
||||
|
|
@ -994,12 +1021,14 @@ bool server_models::is_child_server() {
|
|||
return router_port != nullptr;
|
||||
}
|
||||
|
||||
std::thread server_models::setup_child_server(const std::function<void(int)> & shutdown_handler) {
|
||||
std::thread server_models::setup_child_server(const std::function<void(int)> & shutdown_handler, const json & model_info) {
|
||||
// send a notification to the router server that a model instance is ready
|
||||
common_log_pause(common_log_main());
|
||||
fflush(stdout);
|
||||
fprintf(stdout, "%s\n", CMD_CHILD_TO_ROUTER_READY);
|
||||
fflush(stdout);
|
||||
fprintf(stdout, "%s%s\n", CMD_CHILD_TO_ROUTER_INFO, safe_json_to_str(model_info).c_str());
|
||||
fflush(stdout);
|
||||
common_log_resume(common_log_main());
|
||||
|
||||
// setup thread for monitoring stdin
|
||||
|
|
@ -1176,7 +1205,8 @@ void server_models_routes::init_routes() {
|
|||
status["exit_code"] = meta.exit_code;
|
||||
status["failed"] = true;
|
||||
}
|
||||
models_json.push_back(json {
|
||||
|
||||
json model_info = json {
|
||||
{"id", meta.name},
|
||||
{"aliases", meta.aliases},
|
||||
{"tags", meta.tags},
|
||||
|
|
@ -1185,7 +1215,17 @@ void server_models_routes::init_routes() {
|
|||
{"created", t}, // for OAI-compat
|
||||
{"status", status},
|
||||
// TODO: add other fields, may require reading GGUF metadata
|
||||
});
|
||||
};
|
||||
|
||||
// merge with loaded_info from the child process if available
|
||||
if (meta.is_running()) {
|
||||
for (auto it = meta.loaded_info.begin(); it != meta.loaded_info.end(); ++it) {
|
||||
if (!model_info.contains(it.key())) {
|
||||
model_info[it.key()] = it.value();
|
||||
}
|
||||
}
|
||||
}
|
||||
models_json.push_back(model_info);
|
||||
}
|
||||
res_ok(res, {
|
||||
{"data", models_json},
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ struct server_model_meta {
|
|||
server_model_status status = SERVER_MODEL_STATUS_UNLOADED;
|
||||
int64_t last_used = 0; // for LRU unloading
|
||||
std::vector<std::string> args; // args passed to the model instance, will be populated by render_args()
|
||||
json loaded_info; // info to be reflected via /v1/models endpoint
|
||||
int exit_code = 0; // exit code of the model instance process (only valid if status == FAILED)
|
||||
int stop_timeout = 0; // seconds to wait before force-killing the model instance during shutdown
|
||||
|
||||
|
|
@ -145,6 +146,7 @@ public:
|
|||
|
||||
// update the status of a model instance (thread-safe)
|
||||
void update_status(const std::string & name, server_model_status status, int exit_code);
|
||||
void update_loaded_info(const std::string & name, std::string & raw_info);
|
||||
|
||||
// wait until the model instance is fully loaded (thread-safe)
|
||||
// return when the model no longer in "loading" state
|
||||
|
|
@ -163,7 +165,7 @@ public:
|
|||
|
||||
// notify the router server that a model instance is ready
|
||||
// return the monitoring thread (to be joined by the caller)
|
||||
static std::thread setup_child_server(const std::function<void(int)> & shutdown_handler);
|
||||
static std::thread setup_child_server(const std::function<void(int)> & shutdown_handler, const json & model_info);
|
||||
|
||||
// notify the router server that the sleeping state has changed
|
||||
static void notify_router_sleeping_state(bool sleeping);
|
||||
|
|
|
|||
|
|
@ -204,6 +204,10 @@ int main(int argc, char ** argv) {
|
|||
// Save & load slots
|
||||
ctx_http.get ("/slots", ex_wrapper(routes.get_slots));
|
||||
ctx_http.post("/slots/:id_slot", ex_wrapper(routes.post_slots));
|
||||
|
||||
// Google Cloud Platform (Vertex AI) compat
|
||||
ctx_http.register_gcp_compat();
|
||||
|
||||
// CORS proxy (EXPERIMENTAL, only used by the Web UI for MCP)
|
||||
if (params.webui_mcp_proxy) {
|
||||
SRV_WRN("%s", "-----------------\n");
|
||||
|
|
@ -334,7 +338,8 @@ int main(int argc, char ** argv) {
|
|||
// optionally, notify router server that this instance is ready
|
||||
std::thread monitor_thread;
|
||||
if (server_models::is_child_server()) {
|
||||
monitor_thread = server_models::setup_child_server(shutdown_handler);
|
||||
json model_info = routes.get_model_info();
|
||||
monitor_thread = server_models::setup_child_server(shutdown_handler, model_info);
|
||||
}
|
||||
|
||||
// this call blocks the main thread until queue_tasks.terminate() is called
|
||||
|
|
|
|||
60
tools/server/tests/unit/test_compat_gcp.py
Normal file
60
tools/server/tests/unit/test_compat_gcp.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import pytest
|
||||
from utils import *
|
||||
|
||||
server: ServerProcess
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def create_server():
|
||||
global server
|
||||
server = ServerPreset.tinyllama2()
|
||||
server.gcp_compat = True
|
||||
|
||||
|
||||
def test_gcp_predict_camel_case():
|
||||
global server
|
||||
server.start()
|
||||
res = server.make_request("POST", "/predict", data={
|
||||
"instances": [
|
||||
{
|
||||
"@requestFormat": "chatCompletions",
|
||||
"max_tokens": 8,
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the meaning of life?"},
|
||||
],
|
||||
}
|
||||
],
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert "predictions" in res.body
|
||||
assert len(res.body["predictions"]) == 1
|
||||
prediction = res.body["predictions"][0]
|
||||
assert "choices" in prediction
|
||||
assert len(prediction["choices"]) == 1
|
||||
assert prediction["choices"][0]["message"]["role"] == "assistant"
|
||||
assert len(prediction["choices"][0]["message"]["content"]) > 0
|
||||
|
||||
|
||||
def test_gcp_predict_multiple_instances():
|
||||
global server
|
||||
server.n_slots = 2
|
||||
server.start()
|
||||
res = server.make_request("POST", "/predict", data={
|
||||
"instances": [
|
||||
{
|
||||
"@requestFormat": "chatCompletions",
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": "Say hello"}],
|
||||
},
|
||||
{
|
||||
"@requestFormat": "chatCompletions",
|
||||
"max_tokens": 8,
|
||||
"messages": [{"role": "user", "content": "Say world"}],
|
||||
},
|
||||
],
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert len(res.body["predictions"]) == 2
|
||||
for prediction in res.body["predictions"]:
|
||||
assert "choices" in prediction
|
||||
assert len(prediction["choices"][0]["message"]["content"]) > 0
|
||||
|
|
@ -108,6 +108,7 @@ class ServerProcess:
|
|||
no_cache_idle_slots: bool = False
|
||||
log_path: str | None = None
|
||||
webui_mcp_proxy: bool = False
|
||||
gcp_compat: bool = False
|
||||
|
||||
# session variables
|
||||
process: subprocess.Popen | None = None
|
||||
|
|
@ -122,6 +123,9 @@ class ServerProcess:
|
|||
self.external_server = "DEBUG_EXTERNAL" in os.environ
|
||||
|
||||
def start(self, timeout_seconds: int = DEFAULT_HTTP_TIMEOUT) -> None:
|
||||
env = {**os.environ}
|
||||
if "LLAMA_CACHE" not in os.environ:
|
||||
env["LLAMA_CACHE"] = "tmp"
|
||||
if self.external_server:
|
||||
print(f"[external_server]: Assuming external server running on {self.server_host}:{self.server_port}")
|
||||
return
|
||||
|
|
@ -248,6 +252,8 @@ class ServerProcess:
|
|||
server_args.append("--no-cache-idle-slots")
|
||||
if self.webui_mcp_proxy:
|
||||
server_args.append("--webui-mcp-proxy")
|
||||
if self.gcp_compat:
|
||||
env["AIP_MODE"] = "PREDICTION"
|
||||
|
||||
args = [str(arg) for arg in [server_path, *server_args]]
|
||||
print(f"tests: starting server with: {' '.join(args)}")
|
||||
|
|
@ -268,7 +274,7 @@ class ServerProcess:
|
|||
creationflags=flags,
|
||||
stdout=self._log,
|
||||
stderr=self._log if self._log != sys.stdout else sys.stdout,
|
||||
env={**os.environ, "LLAMA_CACHE": "tmp"} if "LLAMA_CACHE" not in os.environ else None,
|
||||
env=env,
|
||||
)
|
||||
server_instances.add(self)
|
||||
|
||||
|
|
|
|||
36
tools/server/webui/package-lock.json
generated
36
tools/server/webui/package-lock.json
generated
|
|
@ -2307,9 +2307,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
|
||||
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
|
||||
"version": "2.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
|
||||
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3640,9 +3640,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.0.tgz",
|
||||
"integrity": "sha512-GLOBZRVy3hxNHIQ2MpD/+5aK9KcBFZRhUJtZ1UDABXdlVR4K6zFpgt4T+Rwuhf2sQzlc6yK1q/DprHPjwT4Pjw==",
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
|
||||
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -4856,9 +4856,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz",
|
||||
"integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
|
|
@ -7943,9 +7943,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10084,9 +10084,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
|
|
@ -10302,9 +10302,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite-plugin-devtools-json/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
interface Props {
|
||||
onMcpSettingsClick?: () => void;
|
||||
|
|
@ -52,7 +53,7 @@
|
|||
function handleMcpSettingsClick() {
|
||||
onMcpSettingsClick?.();
|
||||
|
||||
goto(`${hasMcpServers ? '' : '?add'}#/settings/mcp`);
|
||||
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||
import { AttachmentMenuItemId } from '$lib/enums';
|
||||
import { PencilRuler } from '@lucide/svelte';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from '$lib/constants/routes';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -146,13 +148,16 @@
|
|||
|
||||
<div class="my-2 border-t"></div>
|
||||
|
||||
<a href="#/settings/mcp" class="flex items-center gap-3 px-3 py-2">
|
||||
<a href={ROUTES.MCP_SERVERS} class="flex items-center gap-3 px-3 py-2">
|
||||
<McpLogo class="inline h-4 w-4" />
|
||||
|
||||
<span class="text-sm">MCP Servers</span>
|
||||
</a>
|
||||
|
||||
<a href="#/settings/chat/tools" class="flex items-center gap-3 px-3 py-2">
|
||||
<a
|
||||
href={RouterService.settings(SETTINGS_SECTION_SLUGS.TOOLS)}
|
||||
class="flex items-center gap-3 px-3 py-2"
|
||||
>
|
||||
<PencilRuler class="inline h-4 w-4" />
|
||||
|
||||
<span class="text-sm">Tools</span>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
|
|
@ -100,7 +101,7 @@
|
|||
{onSystemPromptClick}
|
||||
{onMcpPromptClick}
|
||||
{onMcpResourcesClick}
|
||||
onMcpSettingsClick={() => goto('#/settings/mcp')}
|
||||
onMcpSettingsClick={() => goto(ROUTES.MCP_SERVERS)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
import { deriveAgenticSections } from '$lib/utils';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -182,7 +183,7 @@
|
|||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(`#/`);
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
@ -205,7 +206,7 @@
|
|||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
|
||||
if (conversationDeleted) {
|
||||
goto(`#/`);
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
} else {
|
||||
chatActions.delete(message);
|
||||
|
|
@ -271,7 +272,7 @@
|
|||
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
|
||||
isEditing = false;
|
||||
if (conversationDeleted) {
|
||||
goto(`#/`);
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,10 +85,6 @@
|
|||
editCtx.setUploadedFiles([...editCtx.editedUploadedFiles, ...processed]);
|
||||
}
|
||||
|
||||
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
|
||||
editCtx.setUploadedFiles(files);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
chatStore.setEditModeActive(handleFilesAdd);
|
||||
|
||||
|
|
@ -104,7 +100,7 @@
|
|||
<ChatForm
|
||||
value={editCtx.editedContent}
|
||||
attachments={editCtx.editedExtras}
|
||||
uploadedFiles={editCtx.editedUploadedFiles}
|
||||
bind:uploadedFiles={editCtx.editedUploadedFiles}
|
||||
placeholder="Edit your message..."
|
||||
showMcpPromptButton
|
||||
showAddButton={editCtx.messageRole === MessageRole.USER}
|
||||
|
|
@ -112,7 +108,6 @@
|
|||
onValueChange={editCtx.setContent}
|
||||
onAttachmentRemove={handleAttachmentRemove}
|
||||
onUploadedFileRemove={handleUploadedFileRemove}
|
||||
onUploadedFilesChange={handleUploadedFilesChange}
|
||||
onFilesAdd={handleFilesAdd}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { Shield, ShieldOff } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
includeSensitiveData = $bindable(false),
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
open: boolean;
|
||||
includeSensitiveData: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
} = $props();
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
{#if includeSensitiveData}
|
||||
<ShieldOff class="h-5 w-5 text-destructive" />
|
||||
{:else}
|
||||
<Shield class="h-5 w-5 text-destructive" />
|
||||
{/if}
|
||||
Export Settings
|
||||
</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
{#if includeSensitiveData}
|
||||
<p class="text-amber-500">
|
||||
Warning: This export will include sensitive data such as API keys and MCP server custom
|
||||
headers (e.g., authorization tokens). Do not share this file with anyone you don't
|
||||
trust.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
Sensitive data (API keys, MCP server custom headers) will not be included in the export
|
||||
to protect your credentials.
|
||||
</p>
|
||||
{/if}
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<Checkbox id="include-sensitive" bind:checked={includeSensitiveData} />
|
||||
|
||||
<Label
|
||||
for="include-sensitive"
|
||||
class="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{#if includeSensitiveData}
|
||||
<span class="text-destructive">Include sensitive data (not recommended)</span>
|
||||
{:else}
|
||||
<span>Include sensitive data</span>
|
||||
{/if}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={onCancel}>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={onConfirm}
|
||||
class="bg-destructive text-white hover:bg-destructive/80"
|
||||
>
|
||||
{#if includeSensitiveData}
|
||||
Export Anyway
|
||||
{:else}
|
||||
Export Without Sensitive Data
|
||||
{/if}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
|
@ -18,6 +18,37 @@
|
|||
*/
|
||||
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
|
||||
|
||||
/**
|
||||
* **DialogExportSettings** - Settings export dialog with sensitive data warning
|
||||
*
|
||||
* Dialog for exporting settings with an option to include or exclude
|
||||
* sensitive data (API keys, MCP server custom headers). Defaults to excluding
|
||||
* sensitive data for security. User must explicitly opt-in to include them.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN AlertDialog
|
||||
* - Checkbox to toggle sensitive data inclusion (defaults to false)
|
||||
* - Warning icon and message when sensitive data is included
|
||||
* - Destructive variant for the action button when exporting with sensitive data
|
||||
*
|
||||
* **Features:**
|
||||
* - Secure default: sensitive data excluded by default
|
||||
* - User must explicitly opt-in to include sensitive data
|
||||
* - Visual warning (ShieldOff icon) when sensitive data is included
|
||||
* - Different action text based on sensitive data state
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogExportSettings
|
||||
* bind:open={showExportSettings}
|
||||
* bind:includeSensitiveData
|
||||
* onConfirm={handleSettingsExport}
|
||||
* onCancel={() => showExportSettings = false}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogExportSettings } from './DialogExportSettings.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* CONFIRMATION DIALOGS
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import {
|
||||
conversationsStore,
|
||||
conversations,
|
||||
|
|
@ -159,7 +161,7 @@
|
|||
}
|
||||
|
||||
handleMobileSidebarItemClick();
|
||||
await goto(`#/chat/${id}`);
|
||||
await goto(RouterService.chat(id));
|
||||
}
|
||||
|
||||
function handleStopGeneration(id: string) {
|
||||
|
|
@ -171,7 +173,7 @@
|
|||
<ScrollArea class="h-full flex-1">
|
||||
<Sidebar.Header class="gap-4 bg-sidebar/50 p-3 backdrop-blur-lg md:pt-4 md:pb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="#/" onclick={handleMobileSidebarItemClick}>
|
||||
<a href={ROUTES.START} onclick={handleMobileSidebarItemClick}>
|
||||
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">{APP_NAME}</h1>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
import { DropdownMenuActions } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -112,7 +114,7 @@
|
|||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<a
|
||||
href="#/chat/{conversation.forkedFromConversationId}"
|
||||
href={RouterService.chat(conversation.forkedFromConversationId)}
|
||||
class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
|
|
@ -148,9 +150,7 @@
|
|||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<span class="truncate text-sm font-medium">
|
||||
{conversation.name}
|
||||
</span>
|
||||
<TruncatedText text={conversation.name} class="text-sm font-medium" showTooltip={false} />
|
||||
</div>
|
||||
|
||||
{#if renderActionsDropdown}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
import { fade, fly, scale } from 'svelte/transition';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
|
||||
|
|
@ -63,7 +65,7 @@
|
|||
|
||||
try {
|
||||
// Update the API key in settings first
|
||||
settingsStore.updateConfig('apiKey', apiKeyInput.trim());
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.API_KEY, apiKeyInput.trim());
|
||||
|
||||
// Test the API key by making a real request to the server
|
||||
const response = await fetch(`${base}/props`, {
|
||||
|
|
@ -79,7 +81,7 @@
|
|||
|
||||
// Show success state briefly, then navigate to home
|
||||
setTimeout(() => {
|
||||
goto(`#/`);
|
||||
goto(ROUTES.START);
|
||||
}, 1000);
|
||||
} else {
|
||||
// API key is invalid - User Story A
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import SettingsChatFooter from './SettingsChatFooter.svelte';
|
||||
import SettingsChatFields from './SettingsChatFields.svelte';
|
||||
import SettingsChatToolsTab from './SettingsChatToolsTab.svelte';
|
||||
import SettingsChatImportExportTab from './SettingsChatImportExportTab.svelte';
|
||||
import {
|
||||
SettingsChatDesktopSidebar,
|
||||
SettingsChatMobileHeader
|
||||
SettingsChatFields,
|
||||
SettingsChatImportExportTab,
|
||||
SettingsChatMobileHeader,
|
||||
SettingsChatToolsTab,
|
||||
SettingsFooter
|
||||
} from '$lib/components/app/settings';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
SETTINGS_SECTION_TITLES,
|
||||
type SettingsSection
|
||||
} from '$lib/constants';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
|
@ -22,7 +23,8 @@
|
|||
import { page } from '$app/state';
|
||||
import { setChatSettingsConfigContext } from '$lib/contexts';
|
||||
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
|
||||
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
interface Props {
|
||||
initialSection?: string;
|
||||
getSectionHref?: (section: SettingsSection) => string;
|
||||
|
|
@ -33,14 +35,30 @@
|
|||
let activeSlug = $derived(
|
||||
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
|
||||
);
|
||||
|
||||
let currentSection = $derived(
|
||||
SETTINGS_CHAT_SECTIONS.find((section) => section.slug === activeSlug) ||
|
||||
SETTINGS_CHAT_SECTIONS[0]
|
||||
);
|
||||
|
||||
let localConfig: SettingsConfigType = $state({ ...config() });
|
||||
|
||||
let mobileHeader: { updateCarousel: () => void } | undefined;
|
||||
|
||||
let fetchInitiated = false;
|
||||
|
||||
$effect(() => {
|
||||
if (isRouterMode() && currentSection.fields && !fetchInitiated) {
|
||||
fetchInitiated = true;
|
||||
|
||||
void modelsStore
|
||||
.fetch()
|
||||
.then(() => modelsStore.fetchRouterModels())
|
||||
.then(() => modelsStore.fetchModalitiesForLoadedModels())
|
||||
.then(() => modelsStore.ensureFirstModelSelected());
|
||||
}
|
||||
});
|
||||
|
||||
function handleThemeChange(newTheme: string) {
|
||||
localConfig.theme = newTheme;
|
||||
setMode(newTheme as ColorMode);
|
||||
|
|
@ -110,13 +128,15 @@
|
|||
<SettingsChatDesktopSidebar
|
||||
sections={SETTINGS_CHAT_SECTIONS}
|
||||
isActive={(section: SettingsSection) => section.slug === activeSlug}
|
||||
getHref={getSectionHref ?? ((section: SettingsSection) => `#/settings/chat/${section.slug}`)}
|
||||
getHref={getSectionHref ??
|
||||
((section: SettingsSection) => RouterService.settings(section.slug))}
|
||||
/>
|
||||
|
||||
<SettingsChatMobileHeader
|
||||
sections={SETTINGS_CHAT_SECTIONS}
|
||||
isActive={(section: SettingsSection) => section.slug === activeSlug}
|
||||
getHref={getSectionHref ?? ((section: SettingsSection) => `#/settings/chat/${section.slug}`)}
|
||||
getHref={getSectionHref ??
|
||||
((section: SettingsSection) => RouterService.settings(section.slug))}
|
||||
bind:this={mobileHeader}
|
||||
/>
|
||||
|
||||
|
|
@ -149,7 +169,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsChatFooter onReset={handleReset} onSave={handleSave} />
|
||||
<SettingsFooter onReset={handleReset} onSave={handleSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { modelsStore, selectedModelName } from '$lib/stores/models.svelte';
|
||||
import { modelsStore, selectedModelName, propsCacheVersion } from '$lib/stores/models.svelte';
|
||||
import { normalizeFloatingPoint } from '$lib/utils/precision';
|
||||
import SettingsChatParameterSourceIndicator from './SettingsChatParameterSourceIndicator.svelte';
|
||||
import { SettingsChatParameterSourceIndicator } from '$lib/components/app/settings';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -23,13 +23,19 @@
|
|||
|
||||
let { fields, localConfig, onConfigChange, onThemeChange }: Props = $props();
|
||||
|
||||
// server sampling defaults for placeholders
|
||||
let sp = $derived.by(() => {
|
||||
let currentModelParams = $derived.by(() => {
|
||||
propsCacheVersion();
|
||||
|
||||
if (serverStore.isRouterMode) {
|
||||
const m = selectedModelName();
|
||||
if (m) {
|
||||
const p = modelsStore.getModelProps(m);
|
||||
return (p?.default_generation_settings?.params ?? {}) as Record<string, unknown>;
|
||||
const currentModelName = selectedModelName();
|
||||
|
||||
if (currentModelName) {
|
||||
const currentModelProps = modelsStore.getModelProps(currentModelName);
|
||||
|
||||
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
return (serverStore.defaultParams ?? {}) as Record<string, unknown>;
|
||||
|
|
@ -40,7 +46,7 @@
|
|||
<div class="space-y-2">
|
||||
{#if field.type === SettingsFieldType.INPUT}
|
||||
{@const currentValue = String(localConfig[field.key] ?? '')}
|
||||
{@const serverDefault = sp[field.key]}
|
||||
{@const serverDefault = currentModelParams[field.key]}
|
||||
{@const isCustomRealTime = (() => {
|
||||
if (serverDefault == null) return false;
|
||||
if (currentValue === '') return false;
|
||||
|
|
@ -78,8 +84,8 @@
|
|||
// Update local config immediately for real-time badge feedback
|
||||
onConfigChange(field.key, e.currentTarget.value);
|
||||
}}
|
||||
placeholder={sp[field.key] != null
|
||||
? `Default: ${normalizeFloatingPoint(sp[field.key])}`
|
||||
placeholder={currentModelParams[field.key] != null
|
||||
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
|
||||
: ''}
|
||||
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
|
||||
/>
|
||||
|
|
@ -104,13 +110,15 @@
|
|||
</p>
|
||||
{/if}
|
||||
{:else if field.type === SettingsFieldType.TEXTAREA}
|
||||
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
||||
{field.label}
|
||||
{#if field.label}
|
||||
<Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
|
||||
{field.label}
|
||||
|
||||
{#if field.isExperimental}
|
||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</Label>
|
||||
{#if field.isExperimental}
|
||||
<FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</Label>
|
||||
{/if}
|
||||
|
||||
<Textarea
|
||||
id={field.key}
|
||||
|
|
@ -131,7 +139,8 @@
|
|||
<Checkbox
|
||||
id="showSystemMessage"
|
||||
checked={Boolean(localConfig.showSystemMessage ?? true)}
|
||||
onCheckedChange={(checked) => onConfigChange('showSystemMessage', Boolean(checked))}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
|
||||
/>
|
||||
|
||||
<Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
|
||||
|
|
@ -145,7 +154,7 @@
|
|||
opt.value === localConfig[field.key]
|
||||
)}
|
||||
{@const currentValue = localConfig[field.key]}
|
||||
{@const serverDefault = sp[field.key]}
|
||||
{@const serverDefault = currentModelParams[field.key]}
|
||||
{@const isCustomRealTime = (() => {
|
||||
if (serverDefault == null) return false;
|
||||
if (currentValue === '' || currentValue === undefined) return false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Button, type ButtonVariant } from '$lib/components/ui/button';
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
IconComponent,
|
||||
buttonText,
|
||||
onclick,
|
||||
titleClass,
|
||||
buttonVariant,
|
||||
buttonClass,
|
||||
wrapperClass,
|
||||
summary
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
IconComponent: Component;
|
||||
buttonText: string;
|
||||
onclick: () => void;
|
||||
titleClass?: string;
|
||||
buttonVariant?: ButtonVariant;
|
||||
buttonClass?: string;
|
||||
wrapperClass?: string;
|
||||
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
|
||||
} = $props();
|
||||
|
||||
let sectionButtonClass = $derived(buttonClass ?? 'justify-start justify-self-start md:w-auto');
|
||||
let sectionButtonVariant = $derived(buttonVariant ?? 'outline');
|
||||
</script>
|
||||
|
||||
<div class="grid gap-1 {wrapperClass ?? ''}">
|
||||
<h4 class="mt-0 mb-2 text-sm font-medium {titleClass ?? ''}">{title}</h4>
|
||||
|
||||
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
<Button class={sectionButtonClass} {onclick} variant={sectionButtonVariant}>
|
||||
<IconComponent class="mr-2 h-4 w-4" />
|
||||
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
{#if summary && summary.show && summary.items.length > 0}
|
||||
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
|
||||
<h5 class="mb-2 text-sm font-medium">
|
||||
{summary.verb}
|
||||
{summary.items.length} conversation{summary.items.length === 1 ? '' : 's'}
|
||||
</h5>
|
||||
|
||||
<ul class="space-y-1 text-sm text-muted-foreground">
|
||||
{#each summary.items.slice(0, 10) as conv (conv.id)}
|
||||
<li class="truncate">• {conv.name || 'Untitled conversation'}</li>
|
||||
{/each}
|
||||
|
||||
{#if summary.items.length > 10}
|
||||
<li class="italic">... and {summary.items.length - 10} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,21 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import { Download, Upload, Trash2 } from '@lucide/svelte';
|
||||
import { Button, type ButtonVariant } from '$lib/components/ui/button';
|
||||
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
|
||||
import {
|
||||
DialogConversationSelection,
|
||||
DialogConfirmation,
|
||||
DialogExportSettings
|
||||
} from '$lib/components/app';
|
||||
import { createMessageCountMap } from '$lib/utils';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { ConversationSelectionMode, HtmlInputType, FileExtensionText } from '$lib/enums';
|
||||
|
||||
interface SectionOpts {
|
||||
wrapperClass?: string;
|
||||
titleClass?: string;
|
||||
buttonVariant?: ButtonVariant;
|
||||
buttonClass?: string;
|
||||
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
|
||||
}
|
||||
import SettingsChatImportExportSection from './SettingsChatImportExportSection.svelte';
|
||||
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
|
||||
|
||||
let exportedConversations = $state<DatabaseConversation[]>([]);
|
||||
let importedConversations = $state<DatabaseConversation[]>([]);
|
||||
|
|
@ -33,6 +30,82 @@
|
|||
// Delete functionality state
|
||||
let showDeleteDialog = $state(false);
|
||||
|
||||
// Settings import/export state
|
||||
let showSettingsExportSummary = $state(false);
|
||||
let showSettingsImportSummary = $state(false);
|
||||
let showSettingsExportDialog = $state(false);
|
||||
let includeSensitiveData = $state(false);
|
||||
|
||||
function handleSettingsExport() {
|
||||
showSettingsExportDialog = true;
|
||||
includeSensitiveData = false;
|
||||
}
|
||||
|
||||
function handleSettingsExportConfirm() {
|
||||
showSettingsExportDialog = false;
|
||||
|
||||
try {
|
||||
const data = settingsStore.exportSettings(includeSensitiveData);
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `llama_settings_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showSettingsExportSummary = true;
|
||||
showSettingsImportSummary = false;
|
||||
toast.success('Settings exported');
|
||||
} catch (err) {
|
||||
console.error('Failed to export settings:', err);
|
||||
toast.error('Failed to export settings');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsExportCancel() {
|
||||
showSettingsExportDialog = false;
|
||||
}
|
||||
|
||||
function handleSettingsImport() {
|
||||
try {
|
||||
const input = document.createElement('input');
|
||||
input.type = HtmlInputType.FILE;
|
||||
input.accept = FileExtensionText.JSON;
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!data || typeof data !== 'object' || !data.config) {
|
||||
toast.error('Invalid settings file: missing config');
|
||||
return;
|
||||
}
|
||||
|
||||
settingsStore.importSettings(data);
|
||||
|
||||
showSettingsImportSummary = true;
|
||||
showSettingsExportSummary = false;
|
||||
toast.success('Settings imported successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to import settings:', err);
|
||||
toast.error('Failed to import settings');
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
} catch (err) {
|
||||
console.error('Failed to open file picker:', err);
|
||||
toast.error('Failed to open file picker');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportClick() {
|
||||
try {
|
||||
const allConversations = conversations();
|
||||
|
|
@ -181,94 +254,66 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#snippet summaryList(show: boolean, verb: string, items: DatabaseConversation[])}
|
||||
{#if show && items.length > 0}
|
||||
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
|
||||
<h5 class="mb-2 text-sm font-medium">
|
||||
{verb}
|
||||
{items.length} conversation{items.length === 1 ? '' : 's'}
|
||||
</h5>
|
||||
<div class="space-y-12" in:fade={{ duration: 150 }}>
|
||||
<SettingsGroup title="Conversations">
|
||||
<SettingsChatImportExportSection
|
||||
title="Export"
|
||||
description="Download your conversations as a JSON file. This includes all messages, attachments, and conversation history."
|
||||
IconComponent={Download}
|
||||
buttonText="Export conversations"
|
||||
onclick={handleExportClick}
|
||||
summary={{ show: showExportSummary, verb: 'Exported', items: exportedConversations }}
|
||||
/>
|
||||
|
||||
<ul class="space-y-1 text-sm text-muted-foreground">
|
||||
{#each items.slice(0, 10) as conv (conv.id)}
|
||||
<li class="truncate">• {conv.name || 'Untitled conversation'}</li>
|
||||
{/each}
|
||||
<SettingsChatImportExportSection
|
||||
title="Import"
|
||||
description="Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations."
|
||||
IconComponent={Upload}
|
||||
buttonText="Import conversations"
|
||||
onclick={handleImportClick}
|
||||
summary={{ show: showImportSummary, verb: 'Imported', items: importedConversations }}
|
||||
/>
|
||||
|
||||
{#if items.length > 10}
|
||||
<li class="italic">... and {items.length - 10} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<SettingsChatImportExportSection
|
||||
title="Delete All"
|
||||
description="Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup."
|
||||
IconComponent={Trash2}
|
||||
buttonText="Delete all conversations"
|
||||
onclick={handleDeleteAllClick}
|
||||
titleClass="text-destructive"
|
||||
buttonVariant="destructive"
|
||||
buttonClass="text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{#snippet section(
|
||||
title: string,
|
||||
description: string,
|
||||
IconComponent: Component,
|
||||
buttonText: string,
|
||||
onclick: () => void,
|
||||
opts: SectionOpts
|
||||
)}
|
||||
{@const buttonClass = opts?.buttonClass ?? 'justify-start justify-self-start md:w-auto'}
|
||||
{@const buttonVariant = opts?.buttonVariant ?? 'outline'}
|
||||
<div class="grid gap-1 {opts?.wrapperClass ?? ''}">
|
||||
<h4 class="mt-0 mb-2 text-sm font-medium {opts?.titleClass ?? ''}">{title}</h4>
|
||||
<SettingsGroup title="Settings">
|
||||
<SettingsChatImportExportSection
|
||||
title="Export"
|
||||
description="Export your chat settings and preferences as a JSON file."
|
||||
IconComponent={Download}
|
||||
buttonText="Export settings"
|
||||
onclick={handleSettingsExport}
|
||||
summary={{ show: showSettingsExportSummary, verb: 'Exported', items: [] }}
|
||||
/>
|
||||
|
||||
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
<Button class={buttonClass} {onclick} variant={buttonVariant}>
|
||||
<IconComponent class="mr-2 h-4 w-4" />
|
||||
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
{#if opts?.summary}
|
||||
{@render summaryList(opts.summary.show, opts.summary.verb, opts.summary.items)}
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-6" in:fade={{ duration: 150 }}>
|
||||
<div class="space-y-6">
|
||||
{@render section(
|
||||
'Export Conversations',
|
||||
'Download all your conversations as a JSON file. This includes all messages, attachments, and conversation history.',
|
||||
Download,
|
||||
'Export conversations',
|
||||
handleExportClick,
|
||||
{ summary: { show: showExportSummary, verb: 'Exported', items: exportedConversations } }
|
||||
)}
|
||||
|
||||
{@render section(
|
||||
'Import Conversations',
|
||||
'Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations.',
|
||||
Upload,
|
||||
'Import conversations',
|
||||
handleImportClick,
|
||||
{
|
||||
wrapperClass: 'border-t border-border/30 pt-6',
|
||||
summary: { show: showImportSummary, verb: 'Imported', items: importedConversations }
|
||||
}
|
||||
)}
|
||||
|
||||
{@render section(
|
||||
'Delete All Conversations',
|
||||
'Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup.',
|
||||
Trash2,
|
||||
'Delete all conversations',
|
||||
handleDeleteAllClick,
|
||||
{
|
||||
wrapperClass: 'border-t border-border/30 pt-4',
|
||||
titleClass: 'text-destructive',
|
||||
buttonVariant: 'destructive',
|
||||
buttonClass:
|
||||
'text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto'
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<SettingsChatImportExportSection
|
||||
title="Import"
|
||||
description="Import chat settings from a previously exported JSON file. This will merge with your existing settings."
|
||||
IconComponent={Upload}
|
||||
buttonText="Import settings"
|
||||
onclick={handleSettingsImport}
|
||||
summary={{ show: showSettingsImportSummary, verb: 'Imported', items: [] }}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
||||
<DialogExportSettings
|
||||
bind:open={showSettingsExportDialog}
|
||||
bind:includeSensitiveData
|
||||
onConfirm={handleSettingsExportConfirm}
|
||||
onCancel={handleSettingsExportCancel}
|
||||
/>
|
||||
|
||||
<DialogConversationSelection
|
||||
conversations={availableConversations}
|
||||
{messageCountMap}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-6 text-base font-semibold">{title}</h3>
|
||||
|
||||
<div class="space-y-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -19,32 +19,6 @@ export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSide
|
|||
*/
|
||||
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
|
||||
|
||||
/**
|
||||
* Settings Import/Export panel.
|
||||
* Provides UI for importing and exporting chat conversations.
|
||||
*/
|
||||
export { default as SettingsChatImportExportTab } from './SettingsChat/SettingsChatImportExportTab.svelte';
|
||||
|
||||
/**
|
||||
* MCP Servers configuration panel.
|
||||
* Provides UI for managing Model Context Protocol (MCP) server connections.
|
||||
*/
|
||||
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';
|
||||
|
||||
/**
|
||||
* Footer with save/cancel buttons for settings panel. Positioned at bottom
|
||||
* of settings dialog. Save button commits form state to config store,
|
||||
* cancel button triggers reset and close.
|
||||
*/
|
||||
export { default as SettingsChatFooter } from './SettingsChat/SettingsChatFooter.svelte';
|
||||
|
||||
/**
|
||||
* Form fields renderer for individual settings. Generates appropriate input
|
||||
* components based on field type (text, number, select, checkbox, textarea).
|
||||
* Handles validation, help text display, and parameter source indicators.
|
||||
*/
|
||||
export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields.svelte';
|
||||
|
||||
/**
|
||||
* Badge indicating parameter source for sampling settings. Shows one of:
|
||||
* - **Custom**: User has explicitly set this value (orange badge)
|
||||
|
|
@ -54,6 +28,44 @@ export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields
|
|||
*/
|
||||
export { default as SettingsChatParameterSourceIndicator } from './SettingsChat/SettingsChatParameterSourceIndicator.svelte';
|
||||
|
||||
/**
|
||||
* Section wrapper for settings panels. Displays a title heading with
|
||||
* child content in a structured layout.
|
||||
*/
|
||||
export { default as SettingsGroup } from './SettingsGroup.svelte';
|
||||
|
||||
/**
|
||||
* Footer with save/cancel buttons for settings panel. Positioned at bottom
|
||||
* of settings dialog. Save button commits form state to config store,
|
||||
* cancel button triggers reset and close.
|
||||
*/
|
||||
export { default as SettingsFooter } from './SettingsFooter.svelte';
|
||||
|
||||
/**
|
||||
* Settings Import/Export panel.
|
||||
* Provides UI for importing and exporting chat conversations.
|
||||
*/
|
||||
export { default as SettingsChatImportExportTab } from './SettingsChat/SettingsChatImportExportTab.svelte';
|
||||
|
||||
/**
|
||||
* Section wrapper for import/export sections. Displays a title, description,
|
||||
* icon button, and optional summary of recent actions.
|
||||
*/
|
||||
export { default as SettingsChatImportExportSection } from './SettingsChat/SettingsChatImportExportSection.svelte';
|
||||
|
||||
/**
|
||||
* MCP Servers configuration panel.
|
||||
* Provides UI for managing Model Context Protocol (MCP) server connections.
|
||||
*/
|
||||
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';
|
||||
|
||||
/**
|
||||
* Form fields renderer for individual settings. Generates appropriate input
|
||||
* components based on field type (text, number, select, checkbox, textarea).
|
||||
* Handles validation, help text display, and parameter source indicators.
|
||||
*/
|
||||
export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields.svelte';
|
||||
|
||||
/**
|
||||
* **SettingsChatToolsTab** - Tools configuration tab for chat settings
|
||||
*
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ export * from './message-export';
|
|||
export * from './model-id';
|
||||
export * from './precision';
|
||||
export * from './processing-info';
|
||||
export * from './settings-config';
|
||||
export * from './settings-fields';
|
||||
export * from './routes';
|
||||
export * from './settings-keys';
|
||||
export * from './settings-sections';
|
||||
export * from './settings-registry';
|
||||
export * from './supported-file-types';
|
||||
export * from './table-html-restorer';
|
||||
export * from './title-generation';
|
||||
export * from './tools';
|
||||
export * from './tooltip-config';
|
||||
export * from './ui';
|
||||
|
|
|
|||
26
tools/server/webui/src/lib/constants/routes.ts
Normal file
26
tools/server/webui/src/lib/constants/routes.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const NEW_CHAT_PARAM = 'new_chat';
|
||||
|
||||
/** Settings section slugs — used for routes and navigation. */
|
||||
export const SETTINGS_SECTION_SLUGS = {
|
||||
GENERAL: 'general',
|
||||
DISPLAY: 'display',
|
||||
SAMPLING: 'sampling',
|
||||
PENALTIES: 'penalties',
|
||||
AGENTIC: 'agentic',
|
||||
DEVELOPER: 'developer',
|
||||
TOOLS: 'tools',
|
||||
IMPORT_EXPORT: 'import-export'
|
||||
} as const;
|
||||
|
||||
export const ROUTES = {
|
||||
/** Root — start of the app. */
|
||||
START: '#/',
|
||||
/** New chat — root with new chat query param. */
|
||||
NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`,
|
||||
/** Chat base — for dynamic chat URLs use RouterService. */
|
||||
CHAT: '#/chat',
|
||||
/** MCP servers. */
|
||||
MCP_SERVERS: '#/mcp-servers',
|
||||
/** Settings base — for dynamic settings URLs use RouterService. */
|
||||
SETTINGS: '#/settings'
|
||||
} as const;
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { Monitor, Moon, Sun } from '@lucide/svelte';
|
||||
|
||||
export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean | undefined> = {
|
||||
// Note: in order not to introduce breaking changes, please keep the same data type (number, string, etc) if you want to change the default value.
|
||||
// Do not use nested objects, keep it single level. Prefix the key if you need to group them.
|
||||
apiKey: '',
|
||||
systemMessage: '',
|
||||
showSystemMessage: true,
|
||||
theme: ColorMode.SYSTEM,
|
||||
showThoughtInProgress: true,
|
||||
disableReasoningParsing: false,
|
||||
excludeReasoningFromContext: false,
|
||||
showRawOutputSwitch: false,
|
||||
keepStatsVisible: false,
|
||||
showMessageStats: true,
|
||||
askForTitleConfirmation: false,
|
||||
titleGenerationUseFirstLine: false,
|
||||
pasteLongTextToFileLen: 2500,
|
||||
copyTextAttachmentsAsPlainText: false,
|
||||
pdfAsImage: false,
|
||||
disableAutoScroll: false,
|
||||
renderUserContentAsMarkdown: false,
|
||||
alwaysShowSidebarOnDesktop: false,
|
||||
autoShowSidebarOnNewChat: true,
|
||||
sendOnEnter: true,
|
||||
autoMicOnEmpty: false,
|
||||
fullHeightCodeBlocks: false,
|
||||
showRawModelNames: false,
|
||||
mcpServers: '[]',
|
||||
mcpServerUsageStats: '{}', // JSON object: { [serverId]: usageCount }
|
||||
agenticMaxTurns: 10,
|
||||
agenticMaxToolPreviewLines: 25,
|
||||
showToolCallInProgress: false,
|
||||
alwaysShowAgenticTurns: false,
|
||||
// sampling params: empty means "use server default"
|
||||
// the server / preset is the source of truth
|
||||
// empty values are shown as placeholders from /props in the UI
|
||||
// and are NOT sent in API requests, letting the server decide
|
||||
samplers: '',
|
||||
backend_sampling: false,
|
||||
temperature: undefined,
|
||||
dynatemp_range: undefined,
|
||||
dynatemp_exponent: undefined,
|
||||
top_k: undefined,
|
||||
top_p: undefined,
|
||||
min_p: undefined,
|
||||
xtc_probability: undefined,
|
||||
xtc_threshold: undefined,
|
||||
typ_p: undefined,
|
||||
repeat_last_n: undefined,
|
||||
repeat_penalty: undefined,
|
||||
presence_penalty: undefined,
|
||||
frequency_penalty: undefined,
|
||||
dry_multiplier: undefined,
|
||||
dry_base: undefined,
|
||||
dry_allowed_length: undefined,
|
||||
dry_penalty_last_n: undefined,
|
||||
max_tokens: undefined,
|
||||
custom: '', // custom json-stringified object
|
||||
preEncodeConversation: false,
|
||||
// experimental features
|
||||
pyInterpreterEnabled: false,
|
||||
enableContinueGeneration: true
|
||||
};
|
||||
|
||||
export const SETTING_CONFIG_INFO: Record<string, string> = {
|
||||
apiKey: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
|
||||
systemMessage: 'The starting message that defines how model should behave.',
|
||||
showSystemMessage: 'Display the system message at the top of each conversation.',
|
||||
theme:
|
||||
'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
||||
pasteLongTextToFileLen:
|
||||
'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
|
||||
copyTextAttachmentsAsPlainText:
|
||||
'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||
samplers:
|
||||
'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
|
||||
backend_sampling:
|
||||
'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
|
||||
temperature:
|
||||
'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
|
||||
dynatemp_range:
|
||||
'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
|
||||
dynatemp_exponent:
|
||||
'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
|
||||
top_k: 'Keeps only k top tokens.',
|
||||
top_p: 'Limits tokens to those that together have a cumulative probability of at least p',
|
||||
min_p:
|
||||
'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
|
||||
xtc_probability:
|
||||
'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
|
||||
xtc_threshold:
|
||||
'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
|
||||
typ_p: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
|
||||
repeat_last_n: 'Last n tokens to consider for penalizing repetition',
|
||||
repeat_penalty: 'Controls the repetition of token sequences in the generated text',
|
||||
presence_penalty: 'Limits tokens based on whether they appear in the output or not.',
|
||||
frequency_penalty: 'Limits tokens based on how often they appear in the output.',
|
||||
dry_multiplier:
|
||||
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
|
||||
dry_base:
|
||||
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
|
||||
dry_allowed_length:
|
||||
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
|
||||
dry_penalty_last_n:
|
||||
'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
|
||||
max_tokens: 'The maximum number of token per output. Use -1 for infinite (no limit).',
|
||||
custom: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
|
||||
showThoughtInProgress: 'Expand thought process by default when generating messages.',
|
||||
disableReasoningParsing:
|
||||
'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
|
||||
excludeReasoningFromContext:
|
||||
'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
|
||||
showRawOutputSwitch:
|
||||
'Show toggle button to display messages as plain text instead of Markdown-formatted content',
|
||||
keepStatsVisible: 'Keep processing statistics visible after generation finishes.',
|
||||
showMessageStats:
|
||||
'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
|
||||
askForTitleConfirmation:
|
||||
'Ask for confirmation before automatically changing conversation title when editing the first message.',
|
||||
titleGenerationUseFirstLine:
|
||||
'Use only the first non-empty line of the prompt to generate the conversation title.',
|
||||
pdfAsImage:
|
||||
'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
|
||||
disableAutoScroll:
|
||||
'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
|
||||
renderUserContentAsMarkdown: 'Render user messages using markdown formatting in the chat.',
|
||||
alwaysShowSidebarOnDesktop:
|
||||
'Always keep the sidebar visible on desktop instead of auto-hiding it.',
|
||||
autoShowSidebarOnNewChat:
|
||||
'Automatically show sidebar when starting a new chat. Disable to keep the sidebar hidden until you click on it.',
|
||||
sendOnEnter:
|
||||
'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
|
||||
autoMicOnEmpty:
|
||||
'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
fullHeightCodeBlocks:
|
||||
'Always display code blocks at their full natural height, overriding any height limits.',
|
||||
showRawModelNames:
|
||||
'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
|
||||
mcpServers:
|
||||
'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
|
||||
mcpServerUsageStats:
|
||||
'Usage statistics for MCP servers. Tracks how many times tools from each server have been used.',
|
||||
agenticMaxTurns:
|
||||
'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
|
||||
agenticMaxToolPreviewLines:
|
||||
'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
|
||||
showToolCallInProgress:
|
||||
'Automatically expand tool call details while executing and keep them expanded after completion.',
|
||||
pyInterpreterEnabled:
|
||||
'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
|
||||
preEncodeConversation:
|
||||
'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
|
||||
enableContinueGeneration:
|
||||
'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.'
|
||||
};
|
||||
|
||||
export const SETTINGS_COLOR_MODES_CONFIG = [
|
||||
{ value: ColorMode.SYSTEM, label: 'System', icon: Monitor },
|
||||
{ value: ColorMode.LIGHT, label: 'Light', icon: Sun },
|
||||
{ value: ColorMode.DARK, label: 'Dark', icon: Moon }
|
||||
];
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* List of all numeric fields in settings configuration.
|
||||
* These fields will be converted from strings to numbers during save.
|
||||
*/
|
||||
export const NUMERIC_FIELDS = [
|
||||
'temperature',
|
||||
'top_k',
|
||||
'top_p',
|
||||
'min_p',
|
||||
'max_tokens',
|
||||
'pasteLongTextToFileLen',
|
||||
'dynatemp_range',
|
||||
'dynatemp_exponent',
|
||||
'typ_p',
|
||||
'xtc_probability',
|
||||
'xtc_threshold',
|
||||
'repeat_last_n',
|
||||
'repeat_penalty',
|
||||
'presence_penalty',
|
||||
'frequency_penalty',
|
||||
'dry_multiplier',
|
||||
'dry_base',
|
||||
'dry_allowed_length',
|
||||
'dry_penalty_last_n',
|
||||
'agenticMaxTurns',
|
||||
'agenticMaxToolPreviewLines'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fields that must be positive integers (>= 1).
|
||||
* These will be clamped to minimum 1 and rounded during save.
|
||||
*/
|
||||
export const POSITIVE_INTEGER_FIELDS = ['agenticMaxTurns', 'agenticMaxToolPreviewLines'] as const;
|
||||
|
|
@ -16,6 +16,8 @@ export const SETTINGS_KEYS = {
|
|||
PDF_AS_IMAGE: 'pdfAsImage',
|
||||
ASK_FOR_TITLE_CONFIRMATION: 'askForTitleConfirmation',
|
||||
TITLE_GENERATION_USE_FIRST_LINE: 'titleGenerationUseFirstLine',
|
||||
TITLE_GENERATION_USE_LLM: 'titleGenerationUseLLM',
|
||||
TITLE_GENERATION_PROMPT: 'titleGenerationPrompt',
|
||||
// Display
|
||||
SHOW_MESSAGE_STATS: 'showMessageStats',
|
||||
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
||||
|
|
@ -26,6 +28,7 @@ export const SETTINGS_KEYS = {
|
|||
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
|
||||
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||
// Sampling
|
||||
TEMPERATURE: 'temperature',
|
||||
DYNATEMP_RANGE: 'dynatemp_range',
|
||||
|
|
@ -49,6 +52,7 @@ export const SETTINGS_KEYS = {
|
|||
DRY_ALLOWED_LENGTH: 'dry_allowed_length',
|
||||
DRY_PENALTY_LAST_N: 'dry_penalty_last_n',
|
||||
// MCP
|
||||
MCP_SERVERS: 'mcpServers',
|
||||
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
|
||||
ALWAYS_SHOW_AGENTIC_TURNS: 'alwaysShowAgenticTurns',
|
||||
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
|
||||
|
|
@ -59,5 +63,6 @@ export const SETTINGS_KEYS = {
|
|||
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
|
||||
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
|
||||
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
|
||||
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
|
||||
CUSTOM: 'custom'
|
||||
} as const;
|
||||
|
|
|
|||
719
tools/server/webui/src/lib/constants/settings-registry.ts
Normal file
719
tools/server/webui/src/lib/constants/settings-registry.ts
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
import { ColorMode } from '$lib/enums/ui';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { SyncableParameterType } from '$lib/enums';
|
||||
import {
|
||||
Funnel,
|
||||
AlertTriangle,
|
||||
Code,
|
||||
Monitor,
|
||||
ListRestart,
|
||||
Sliders,
|
||||
PencilRuler,
|
||||
Database,
|
||||
Monitor as MonitorIcon,
|
||||
Sun,
|
||||
Moon
|
||||
} from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import type {
|
||||
SettingsConfigValue,
|
||||
SyncableParameter,
|
||||
SettingsEntry,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionEntry,
|
||||
SettingsSection
|
||||
} from '$lib/types';
|
||||
import { SETTINGS_KEYS } from './settings-keys';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
||||
import { TITLE_GENERATION } from './title-generation';
|
||||
|
||||
export const SETTINGS_SECTION_TITLES = {
|
||||
GENERAL: 'General',
|
||||
DISPLAY: 'Display',
|
||||
SAMPLING: 'Sampling',
|
||||
PENALTIES: 'Penalties',
|
||||
AGENTIC: 'Agentic',
|
||||
TOOLS: 'Tools',
|
||||
IMPORT_EXPORT: 'Import/Export',
|
||||
DEVELOPER: 'Developer'
|
||||
} as const;
|
||||
|
||||
const STANDALONE_SECTIONS: { title: SettingsSectionTitle; slug: string; icon: Component }[] = [
|
||||
{ title: SETTINGS_SECTION_TITLES.TOOLS, slug: SETTINGS_SECTION_SLUGS.TOOLS, icon: PencilRuler },
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
|
||||
slug: SETTINGS_SECTION_SLUGS.IMPORT_EXPORT,
|
||||
icon: Database
|
||||
}
|
||||
];
|
||||
|
||||
const COLOR_MODE_OPTIONS: Array<{ value: string; label: string; icon: Component }> = [
|
||||
{ value: ColorMode.SYSTEM, label: 'System', icon: MonitorIcon },
|
||||
{ value: ColorMode.LIGHT, label: 'Light', icon: Sun },
|
||||
{ value: ColorMode.DARK, label: 'Dark', icon: Moon }
|
||||
];
|
||||
|
||||
const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
[SETTINGS_SECTION_SLUGS.GENERAL]: {
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL,
|
||||
slug: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
icon: Sliders,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
label: 'Theme',
|
||||
help: 'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
|
||||
defaultValue: ColorMode.SYSTEM,
|
||||
type: SettingsFieldType.SELECT,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
options: COLOR_MODE_OPTIONS,
|
||||
sync: { serverKey: SETTINGS_KEYS.THEME, paramType: SyncableParameterType.STRING }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.API_KEY,
|
||||
label: 'API Key',
|
||||
help: 'Set the API Key if you are using <code>--api-key</code> option for the server.',
|
||||
defaultValue: '',
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
|
||||
label: 'System Message',
|
||||
help: 'The starting message that defines how model should behave.',
|
||||
defaultValue: '',
|
||||
type: SettingsFieldType.TEXTAREA,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: { serverKey: SETTINGS_KEYS.SYSTEM_MESSAGE, paramType: SyncableParameterType.STRING }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
label: 'Paste long text to file length',
|
||||
help: 'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
|
||||
defaultValue: 2500,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SEND_ON_ENTER,
|
||||
label: 'Send message on Enter',
|
||||
help: 'Use Enter to send messages and Shift + Enter for new lines. When disabled, use Ctrl/Cmd + Enter.',
|
||||
defaultValue: true,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: { serverKey: SETTINGS_KEYS.SEND_ON_ENTER, paramType: SyncableParameterType.BOOLEAN }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
label: 'Copy text attachments as plain text',
|
||||
help: 'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
label: 'Enable "Continue" button',
|
||||
help: 'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
isExperimental: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
label: 'Parse PDF as image',
|
||||
help: 'Parse PDF as image instead of text. Automatically falls back to text processing for non-vision models.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: { serverKey: SETTINGS_KEYS.PDF_AS_IMAGE, paramType: SyncableParameterType.BOOLEAN }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
|
||||
label: 'Ask for confirmation before changing conversation title',
|
||||
help: 'Ask for confirmation before automatically changing conversation title when editing the first message.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
label: 'Use first non-empty line for conversation title',
|
||||
help: 'Use only the first non-empty line of the prompt to generate the conversation title.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_LLM,
|
||||
label: 'Use LLM to generate conversation title',
|
||||
help: 'Use the LLM to automatically generate conversation titles based on the first message exchange.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_PROMPT,
|
||||
label: 'LLM title generation prompt',
|
||||
help: 'Optional template for the title generation prompt. Use {{USER}} for the user message and {{ASSISTANT}} for the assistant message.',
|
||||
defaultValue: TITLE_GENERATION.DEFAULT_PROMPT,
|
||||
type: SettingsFieldType.TEXTAREA,
|
||||
section: SETTINGS_SECTION_SLUGS.GENERAL
|
||||
}
|
||||
]
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.DISPLAY]: {
|
||||
title: SETTINGS_SECTION_TITLES.DISPLAY,
|
||||
slug: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
icon: Monitor,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
||||
label: 'Show message generation statistics',
|
||||
help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
|
||||
defaultValue: true,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
|
||||
label: 'Show thought in progress',
|
||||
help: 'Expand thought process by default when generating messages.',
|
||||
defaultValue: true,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
|
||||
label: 'Show tool call in progress',
|
||||
help: 'Automatically expand tool call details while executing and keep them expanded after completion.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
|
||||
label: 'Keep stats visible after generation',
|
||||
help: 'Keep processing statistics visible after generation finishes.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
isExperimental: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||
label: 'Render user content as Markdown',
|
||||
help: 'Render user messages using markdown formatting in the chat.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
|
||||
label: 'Use full height code blocks',
|
||||
help: 'Always display code blocks at their full natural height, overriding any height limits.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
|
||||
label: 'Disable automatic scroll',
|
||||
help: 'Disable automatic scrolling while messages stream so you can control the viewport position manually.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
|
||||
label: 'Always show sidebar on desktop',
|
||||
help: 'Always keep the sidebar visible on desktop instead of auto-hiding it.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
|
||||
label: 'Show raw model names',
|
||||
help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
label: 'Always show agentic turns in conversation',
|
||||
help: 'Always expand and display agentic loop turns in conversation messages.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.SAMPLING]: {
|
||||
title: SETTINGS_SECTION_TITLES.SAMPLING,
|
||||
slug: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
icon: Funnel,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.TEMPERATURE,
|
||||
label: 'Temperature',
|
||||
help: 'Controls the randomness of the generated text by affecting the probability distribution of the output tokens. Higher = more random, lower = more focused.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.TEMPERATURE, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DYNATEMP_RANGE,
|
||||
label: 'Dynamic temperature range',
|
||||
help: 'Addon for the temperature sampler. The added value to the range of dynamic temperature, which adjusts probabilities by entropy of tokens.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.DYNATEMP_RANGE, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
|
||||
label: 'Dynamic temperature exponent',
|
||||
help: 'Addon for the temperature sampler. Smoothes out the probability redistribution based on the most probable token.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.DYNATEMP_EXPONENT,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TOP_K,
|
||||
label: 'Top K',
|
||||
help: 'Keeps only k top tokens.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.TOP_K, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TOP_P,
|
||||
label: 'Top P',
|
||||
help: 'Limits tokens to those that together have a cumulative probability of at least p',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.TOP_P, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MIN_P,
|
||||
label: 'Min P',
|
||||
help: 'Limits tokens based on the minimum probability for a token to be considered, relative to the probability of the most likely token.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.MIN_P, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.XTC_PROBABILITY,
|
||||
label: 'XTC probability',
|
||||
help: 'XTC sampler cuts out top tokens; this parameter controls the chance of cutting tokens at all. 0 disables XTC.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.XTC_PROBABILITY, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.XTC_THRESHOLD,
|
||||
label: 'XTC threshold',
|
||||
help: 'XTC sampler cuts out top tokens; this parameter controls the token probability that is required to cut that token.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.XTC_THRESHOLD, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TYP_P,
|
||||
label: 'Typical P',
|
||||
help: 'Sorts and limits tokens based on the difference between log-probability and entropy.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.TYP_P, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MAX_TOKENS,
|
||||
label: 'Max tokens',
|
||||
help: 'The maximum number of token per output. Use -1 for infinite (no limit).',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.MAX_TOKENS, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SAMPLERS,
|
||||
label: 'Samplers',
|
||||
help: 'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
|
||||
defaultValue: '',
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: { serverKey: SETTINGS_KEYS.SAMPLERS, paramType: SyncableParameterType.STRING }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.BACKEND_SAMPLING,
|
||||
label: 'Backend sampling',
|
||||
help: 'Enable backend-based samplers. When enabled, supported samplers run on the accelerator backend for faster sampling.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.SAMPLING,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.BACKEND_SAMPLING,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.PENALTIES]: {
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES,
|
||||
slug: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
icon: AlertTriangle,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.REPEAT_LAST_N,
|
||||
label: 'Repeat last N',
|
||||
help: 'Last n tokens to consider for penalizing repetition',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { serverKey: SETTINGS_KEYS.REPEAT_LAST_N, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.REPEAT_PENALTY,
|
||||
label: 'Repeat penalty',
|
||||
help: 'Controls the repetition of token sequences in the generated text',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { serverKey: SETTINGS_KEYS.REPEAT_PENALTY, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PRESENCE_PENALTY,
|
||||
label: 'Presence penalty',
|
||||
help: 'Limits tokens based on whether they appear in the output or not.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { serverKey: SETTINGS_KEYS.PRESENCE_PENALTY, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
label: 'Frequency penalty',
|
||||
help: 'Limits tokens based on how often they appear in the output.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_MULTIPLIER,
|
||||
label: 'DRY multiplier',
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling multiplier.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { serverKey: SETTINGS_KEYS.DRY_MULTIPLIER, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_BASE,
|
||||
label: 'DRY base',
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the DRY sampling base value.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: { serverKey: SETTINGS_KEYS.DRY_BASE, paramType: SyncableParameterType.NUMBER }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
|
||||
label: 'DRY allowed length',
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets the allowed length for DRY sampling.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
|
||||
label: 'DRY penalty last N',
|
||||
help: 'DRY sampling reduces repetition in generated text even across long contexts. This parameter sets DRY penalty for the last n tokens.',
|
||||
defaultValue: undefined,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.PENALTIES,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.AGENTIC]: {
|
||||
title: SETTINGS_SECTION_TITLES.AGENTIC,
|
||||
slug: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
icon: ListRestart,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
label: 'Agentic turns',
|
||||
help: 'Maximum number of tool execution cycles before stopping (prevents infinite loops).',
|
||||
defaultValue: 10,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
label: 'Max lines per tool preview',
|
||||
help: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
|
||||
defaultValue: 25,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.DEVELOPER]: {
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER,
|
||||
slug: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
icon: Code,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
|
||||
label: 'Pre-fill KV cache after response',
|
||||
help: 'After each response, re-submit the conversation to pre-fill the server KV cache. Makes the next turn faster since the prompt is already encoded while you read the response.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
|
||||
label: 'Disable reasoning content parsing',
|
||||
help: 'Send reasoning_format=none so the server returns thinking tokens inline instead of extracting them into a separate field.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
|
||||
label: 'Exclude reasoning from context',
|
||||
help: 'Strip thinking from previous messages before sending. When off, thinking is sent back via the reasoning_content field so the model sees its own chain-of-thought across turns.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||
label: 'Enable raw output toggle',
|
||||
help: 'Show toggle button to display messages as plain text instead of Markdown-formatted content',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||
paramType: SyncableParameterType.BOOLEAN
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.CUSTOM,
|
||||
label: 'Custom JSON',
|
||||
help: 'Custom JSON parameters to send to the API. Must be valid JSON format.',
|
||||
defaultValue: '',
|
||||
type: SettingsFieldType.TEXTAREA,
|
||||
section: SETTINGS_SECTION_SLUGS.DEVELOPER
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
const NON_UI_SETTINGS: SettingsEntry[] = [
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE,
|
||||
label: 'Show system message',
|
||||
help: 'Display the system message at the top of each conversation.',
|
||||
defaultValue: true,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
sync: { serverKey: SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, paramType: SyncableParameterType.BOOLEAN }
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MCP_SERVERS,
|
||||
label: 'MCP servers',
|
||||
help: 'Configure MCP servers as a JSON list. Use the form in the MCP Client settings section to edit.',
|
||||
defaultValue: '[]',
|
||||
type: SettingsFieldType.INPUT,
|
||||
sync: { serverKey: SETTINGS_KEYS.MCP_SERVERS, paramType: SyncableParameterType.STRING }
|
||||
}
|
||||
// {
|
||||
// key: SETTINGS_KEYS.PY_INTERPRETER_ENABLED,
|
||||
// label: 'Python interpreter enabled',
|
||||
// help: 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.',
|
||||
// defaultValue: false,
|
||||
// type: SettingsFieldType.CHECKBOX,
|
||||
// isExperimental: true,
|
||||
// sync: { serverKey: SETTINGS_KEYS.PY_INTERPRETER_ENABLED, paramType: SyncableParameterType.BOOLEAN }
|
||||
// }
|
||||
];
|
||||
|
||||
function getAllSettings(): SettingsEntry[] {
|
||||
const result: SettingsEntry[] = [];
|
||||
for (const section of Object.values(SETTINGS_REGISTRY)) {
|
||||
result.push(...section.settings);
|
||||
}
|
||||
result.push(...NON_UI_SETTINGS);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Flat config object stored in localStorage. */
|
||||
export const SETTING_CONFIG_DEFAULT: Record<string, SettingsConfigValue> = Object.fromEntries(
|
||||
getAllSettings().map((s) => [s.key, s.defaultValue])
|
||||
) as Record<string, SettingsConfigValue>;
|
||||
|
||||
/** Help text for every setting (including non-UI). */
|
||||
export const SETTING_CONFIG_INFO: Record<string, string> = Object.fromEntries(
|
||||
getAllSettings().map((s) => [s.key, s.help])
|
||||
) as Record<string, string>;
|
||||
|
||||
/** Theme select options. */
|
||||
export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS;
|
||||
|
||||
export type { SettingsSectionTitle } from '$lib/types';
|
||||
export type { SettingsSection } from '$lib/types';
|
||||
|
||||
/** Sidebar sections + field configs (as consumed by UI). */
|
||||
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||
...Object.values(SETTINGS_REGISTRY).map((section) => ({
|
||||
title: section.title,
|
||||
slug: section.slug,
|
||||
icon: section.icon,
|
||||
fields: section.settings.map((s) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
type: s.type,
|
||||
isExperimental: s.isExperimental,
|
||||
help: s.help,
|
||||
options: s.options
|
||||
}))
|
||||
})),
|
||||
...STANDALONE_SECTIONS
|
||||
];
|
||||
|
||||
/** INPUT-type settings whose value is a number. */
|
||||
export const NUMERIC_FIELDS = getAllSettings()
|
||||
.filter((s) => s.type === SettingsFieldType.INPUT && typeof s.defaultValue !== 'string')
|
||||
.map((s) => s.key) as readonly string[];
|
||||
|
||||
/** Numeric fields clamped to ≥ 1 and rounded. */
|
||||
export const POSITIVE_INTEGER_FIELDS = getAllSettings()
|
||||
.filter((s) => s.isPositiveInteger)
|
||||
.map((s) => s.key) as readonly string[];
|
||||
|
||||
/** Derived for the parameter sync service. */
|
||||
export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings()
|
||||
.filter((s) => s.sync !== undefined)
|
||||
.map((s) => ({
|
||||
key: s.key,
|
||||
serverKey: s.sync!.serverKey,
|
||||
type: s.sync!.paramType,
|
||||
canSync: true
|
||||
}));
|
||||
|
||||
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
|
||||
|
||||
export { SETTINGS_KEYS } from './settings-keys';
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
/**
|
||||
* Settings section titles constants for ChatSettings component.
|
||||
*
|
||||
* These titles define the navigation sections in the settings dialog.
|
||||
* Used for both sidebar navigation and mobile horizontal scroll menu.
|
||||
*/
|
||||
export const SETTINGS_SECTION_TITLES = {
|
||||
GENERAL: 'General',
|
||||
DISPLAY: 'Display',
|
||||
SAMPLING: 'Sampling',
|
||||
PENALTIES: 'Penalties',
|
||||
AGENTIC: 'Agentic',
|
||||
TOOLS: 'Tools',
|
||||
MCP: 'MCP',
|
||||
IMPORT_EXPORT: 'Import/Export',
|
||||
DEVELOPER: 'Developer'
|
||||
} as const;
|
||||
|
||||
/** Type for settings section titles */
|
||||
export type SettingsSectionTitle =
|
||||
(typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
|
||||
|
||||
import {
|
||||
Funnel,
|
||||
AlertTriangle,
|
||||
Code,
|
||||
Monitor,
|
||||
ListRestart,
|
||||
Sliders,
|
||||
PencilRuler,
|
||||
Database
|
||||
} from '@lucide/svelte';
|
||||
import { SettingsFieldType } from '$lib/enums/settings';
|
||||
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export interface SettingsSection {
|
||||
fields?: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
slug: string;
|
||||
title: SettingsSectionTitle;
|
||||
}
|
||||
|
||||
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL,
|
||||
slug: 'general',
|
||||
icon: Sliders,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.THEME,
|
||||
label: 'Theme',
|
||||
type: SettingsFieldType.SELECT,
|
||||
options: SETTINGS_COLOR_MODES_CONFIG
|
||||
},
|
||||
{ key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
|
||||
{
|
||||
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
|
||||
label: 'System Message',
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
|
||||
label: 'Paste long text to file length',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SEND_ON_ENTER,
|
||||
label: 'Send message on Enter',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
|
||||
label: 'Copy text attachments as plain text',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
|
||||
label: 'Enable "Continue" button',
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PDF_AS_IMAGE,
|
||||
label: 'Parse PDF as image',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
|
||||
label: 'Ask for confirmation before changing conversation title',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TITLE_GENERATION_USE_FIRST_LINE,
|
||||
label: 'Use first non-empty line for conversation title',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.DISPLAY,
|
||||
slug: 'display',
|
||||
icon: Monitor,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
|
||||
label: 'Show message generation statistics',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
|
||||
label: 'Show thought in progress',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
|
||||
label: 'Show tool call in progress',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
|
||||
label: 'Keep stats visible after generation',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
isExperimental: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||
label: 'Render user content as Markdown',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
|
||||
label: 'Use full height code blocks',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
|
||||
label: 'Disable automatic scroll',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
|
||||
label: 'Always show sidebar on desktop',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
|
||||
label: 'Show raw model names',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||
label: 'Always show agentic turns in conversation',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.SAMPLING,
|
||||
slug: 'sampling',
|
||||
icon: Funnel,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.TEMPERATURE,
|
||||
label: 'Temperature',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DYNATEMP_RANGE,
|
||||
label: 'Dynamic temperature range',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
|
||||
label: 'Dynamic temperature exponent',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TOP_K,
|
||||
label: 'Top K',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TOP_P,
|
||||
label: 'Top P',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MIN_P,
|
||||
label: 'Min P',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.XTC_PROBABILITY,
|
||||
label: 'XTC probability',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.XTC_THRESHOLD,
|
||||
label: 'XTC threshold',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.TYP_P,
|
||||
label: 'Typical P',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MAX_TOKENS,
|
||||
label: 'Max tokens',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SAMPLERS,
|
||||
label: 'Samplers',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.BACKEND_SAMPLING,
|
||||
label: 'Backend sampling',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.PENALTIES,
|
||||
slug: 'penalties',
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.REPEAT_LAST_N,
|
||||
label: 'Repeat last N',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.REPEAT_PENALTY,
|
||||
label: 'Repeat penalty',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.PRESENCE_PENALTY,
|
||||
label: 'Presence penalty',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
|
||||
label: 'Frequency penalty',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_MULTIPLIER,
|
||||
label: 'DRY multiplier',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_BASE,
|
||||
label: 'DRY base',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
|
||||
label: 'DRY allowed length',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
|
||||
label: 'DRY penalty last N',
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.AGENTIC,
|
||||
slug: 'agentic',
|
||||
icon: ListRestart,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
|
||||
label: 'Agentic turns',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
label: 'Max lines per tool preview',
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.TOOLS,
|
||||
slug: 'tools',
|
||||
icon: PencilRuler
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
|
||||
slug: 'import-export',
|
||||
icon: Database
|
||||
},
|
||||
{
|
||||
title: SETTINGS_SECTION_TITLES.DEVELOPER,
|
||||
slug: 'developer',
|
||||
icon: Code,
|
||||
fields: [
|
||||
{
|
||||
key: SETTINGS_KEYS.PRE_ENCODE_CONVERSATION,
|
||||
label: 'Pre-fill KV cache after response',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
|
||||
label: 'Disable reasoning content parsing',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
|
||||
label: 'Exclude reasoning from context',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||
label: 'Enable raw output toggle',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.CUSTOM,
|
||||
label: 'Custom JSON',
|
||||
type: SettingsFieldType.TEXTAREA
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
9
tools/server/webui/src/lib/constants/title-generation.ts
Normal file
9
tools/server/webui/src/lib/constants/title-generation.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* Title generation constants */
|
||||
export const TITLE_GENERATION = {
|
||||
MIN_LENGTH: 3,
|
||||
FALLBACK: 'New Chat',
|
||||
DEFAULT_PROMPT:
|
||||
'Based on the following interaction, generate a short, concise title (maximum 6-8 words) that captures the main topic. Return ONLY the title text, nothing else. Do not use quotes.\n\nUser: {{USER}}\n\nAssistant: {{ASSISTANT}}\n\nTitle:',
|
||||
PREFIX_PATTERN: /^(Title:|Subject:|Topic:)\s*/i,
|
||||
QUOTE_PATTERN: /^["]|["]$/g
|
||||
} as const;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Settings, Search, SquarePen } from '@lucide/svelte';
|
||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import { ROUTES } from './routes';
|
||||
|
||||
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||
|
|
@ -19,18 +20,18 @@ export interface DesktopIconStripItem {
|
|||
}
|
||||
|
||||
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
||||
{ icon: SquarePen, tooltip: 'New chat', route: '?new_chat=true#/', keys: ['shift', 'cmd', 'o'] },
|
||||
{ icon: SquarePen, tooltip: 'New chat', route: ROUTES.NEW_CHAT, keys: ['shift', 'cmd', 'o'] },
|
||||
{ icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] },
|
||||
{
|
||||
icon: McpLogo,
|
||||
tooltip: 'MCP Servers',
|
||||
route: '#/settings/mcp',
|
||||
activeRouteId: '/settings/mcp'
|
||||
route: ROUTES.MCP_SERVERS,
|
||||
activeRouteId: '/mcp-servers'
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
tooltip: 'Settings',
|
||||
route: '#/settings/chat/general',
|
||||
activeRoutePrefix: '/settings/chat'
|
||||
route: ROUTES.SETTINGS,
|
||||
activeRoutePrefix: '/settings'
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
interface KeyboardShortcutsCallbacks {
|
||||
activateSearchMode?: () => void;
|
||||
|
|
@ -27,7 +28,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
|
|||
) {
|
||||
event.preventDefault();
|
||||
|
||||
goto('?new_chat=true#/');
|
||||
goto(ROUTES.NEW_CHAT);
|
||||
}
|
||||
|
||||
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { page } from '$app/state';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
export interface ChatSettings {
|
||||
reset: () => void;
|
||||
|
|
@ -15,11 +16,8 @@ export function useSettingsNavigation() {
|
|||
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
|
||||
|
||||
beforeNavigate(({ to, from }) => {
|
||||
if (
|
||||
to?.route?.id?.startsWith('/settings/chat') &&
|
||||
!from?.route?.id?.startsWith('/settings/chat')
|
||||
) {
|
||||
settingsReferrer.url = window.location.hash || '#/';
|
||||
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
|
||||
settingsReferrer.url = window.location.hash || ROUTES.START;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,59 @@ import {
|
|||
ReasoningFormat,
|
||||
UrlProtocol
|
||||
} from '$lib/enums';
|
||||
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import type {
|
||||
ApiChatMessageContentPart,
|
||||
ApiChatMessageData,
|
||||
ApiChatCompletionToolCall
|
||||
} from '$lib/types/api';
|
||||
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
|
||||
export class ChatService {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Title Generation
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sends a streaming chat completion request for generating a chat title.
|
||||
* Delegates to `sendMessage` for fetch, SSE parsing, and error handling.
|
||||
*
|
||||
* @param message - The single message to send (a user message containing the title generation prompt)
|
||||
* @param model - Optional model name to use (required in ROUTER mode)
|
||||
* @param signal - Optional AbortSignal to cancel the request
|
||||
* @returns {Promise<string>} The aggregated title text, or empty string if request failed
|
||||
* @static
|
||||
*/
|
||||
static async generateTitle(
|
||||
message: ApiChatMessageData,
|
||||
model?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
let titleResponse = '';
|
||||
try {
|
||||
await ChatService.sendMessage(
|
||||
[message],
|
||||
{
|
||||
model: model || undefined,
|
||||
stream: true,
|
||||
custom: { chat_template_kwargs: { enable_thinking: false } },
|
||||
onChunk: (chunk: string) => {
|
||||
titleResponse += chunk;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
signal
|
||||
);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
return titleResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
|
|
@ -122,7 +170,11 @@ export class ChatService {
|
|||
return true;
|
||||
});
|
||||
// If only text remains and it's a single part, simplify to string
|
||||
if (msg.content.length === 1 && msg.content[0].type === ContentPartType.TEXT) {
|
||||
if (
|
||||
msg.content.length === 1 &&
|
||||
msg.content[0].type === ContentPartType.TEXT &&
|
||||
typeof msg.content[0].text === 'string'
|
||||
) {
|
||||
msg.content = msg.content[0].text;
|
||||
}
|
||||
}
|
||||
|
|
@ -461,7 +513,7 @@ export class ChatService {
|
|||
|
||||
const serializedToolCalls = JSON.stringify(aggregatedToolCalls);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
||||
console.log('[ChatService] Aggregated tool calls:', serializedToolCalls);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -260,3 +260,26 @@ export { ParameterSyncService } from './parameter-sync.service';
|
|||
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18
|
||||
*/
|
||||
export { MCPService } from './mcp.service';
|
||||
|
||||
/**
|
||||
* **RouterService** — Dynamic route URL construction utility
|
||||
*
|
||||
* Stateless utility for building dynamic route URLs from ROUTES base paths.
|
||||
* Static routes (START, NEW_CHAT, MCP_SERVERS) live in ROUTES constants;
|
||||
* dynamic routes (CHAT, SETTINGS) are constructed here by appending parameters.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **RouterService** (this class): Stateless URL construction
|
||||
* - Builds dynamic route URLs from ROUTES base paths
|
||||
* - No side effects — receives route parameters, returns route strings
|
||||
*
|
||||
* - **ROUTES constant** (constants/routes.ts): Static route base paths
|
||||
* - **All components/stores**: Call RouterService for dynamic route URLs
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id`
|
||||
* - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section`
|
||||
*
|
||||
* @see ROUTES in constants/routes.ts — static route base paths
|
||||
*/
|
||||
export { RouterService } from './router.service';
|
||||
|
|
|
|||
|
|
@ -1,253 +1,8 @@
|
|||
import { normalizeFloatingPoint } from '$lib/utils';
|
||||
import type { SyncableParameter, ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
|
||||
import { SETTINGS_KEYS, SYNCABLE_PARAMETERS } from '$lib/constants';
|
||||
import type { ParameterRecord, ParameterInfo, ParameterValue } from '$lib/types';
|
||||
import { SyncableParameterType, ParameterSource } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Mapping of webui setting keys to server parameter keys.
|
||||
* Only parameters listed here can be synced from the server `/props` endpoint.
|
||||
* Each entry defines the webui key, corresponding server key, value type,
|
||||
* and whether sync is enabled.
|
||||
*/
|
||||
export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
|
||||
{
|
||||
key: 'temperature',
|
||||
serverKey: 'temperature',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{ key: 'top_k', serverKey: 'top_k', type: SyncableParameterType.NUMBER, canSync: true },
|
||||
{ key: 'top_p', serverKey: 'top_p', type: SyncableParameterType.NUMBER, canSync: true },
|
||||
{ key: 'min_p', serverKey: 'min_p', type: SyncableParameterType.NUMBER, canSync: true },
|
||||
{
|
||||
key: 'dynatemp_range',
|
||||
serverKey: 'dynatemp_range',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'dynatemp_exponent',
|
||||
serverKey: 'dynatemp_exponent',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'xtc_probability',
|
||||
serverKey: 'xtc_probability',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'xtc_threshold',
|
||||
serverKey: 'xtc_threshold',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{ key: 'typ_p', serverKey: 'typ_p', type: SyncableParameterType.NUMBER, canSync: true },
|
||||
{
|
||||
key: 'repeat_last_n',
|
||||
serverKey: 'repeat_last_n',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'repeat_penalty',
|
||||
serverKey: 'repeat_penalty',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'presence_penalty',
|
||||
serverKey: 'presence_penalty',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'frequency_penalty',
|
||||
serverKey: 'frequency_penalty',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'dry_multiplier',
|
||||
serverKey: 'dry_multiplier',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{ key: 'dry_base', serverKey: 'dry_base', type: SyncableParameterType.NUMBER, canSync: true },
|
||||
{
|
||||
key: 'dry_allowed_length',
|
||||
serverKey: 'dry_allowed_length',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'dry_penalty_last_n',
|
||||
serverKey: 'dry_penalty_last_n',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{ key: 'max_tokens', serverKey: 'max_tokens', type: SyncableParameterType.NUMBER, canSync: true },
|
||||
{ key: 'samplers', serverKey: 'samplers', type: SyncableParameterType.STRING, canSync: true },
|
||||
{
|
||||
key: 'backend_sampling',
|
||||
serverKey: 'backend_sampling',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'pasteLongTextToFileLen',
|
||||
serverKey: 'pasteLongTextToFileLen',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'pdfAsImage',
|
||||
serverKey: 'pdfAsImage',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showThoughtInProgress',
|
||||
serverKey: 'showThoughtInProgress',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'keepStatsVisible',
|
||||
serverKey: 'keepStatsVisible',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showMessageStats',
|
||||
serverKey: 'showMessageStats',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'askForTitleConfirmation',
|
||||
serverKey: 'askForTitleConfirmation',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'titleGenerationUseFirstLine',
|
||||
serverKey: 'titleGenerationUseFirstLine',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'disableAutoScroll',
|
||||
serverKey: 'disableAutoScroll',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'renderUserContentAsMarkdown',
|
||||
serverKey: 'renderUserContentAsMarkdown',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'autoMicOnEmpty',
|
||||
serverKey: 'autoMicOnEmpty',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'pyInterpreterEnabled',
|
||||
serverKey: 'pyInterpreterEnabled',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'enableContinueGeneration',
|
||||
serverKey: 'enableContinueGeneration',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'fullHeightCodeBlocks',
|
||||
serverKey: 'fullHeightCodeBlocks',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'systemMessage',
|
||||
serverKey: 'systemMessage',
|
||||
type: SyncableParameterType.STRING,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showSystemMessage',
|
||||
serverKey: 'showSystemMessage',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{ key: 'theme', serverKey: 'theme', type: SyncableParameterType.STRING, canSync: true },
|
||||
{
|
||||
key: 'copyTextAttachmentsAsPlainText',
|
||||
serverKey: 'copyTextAttachmentsAsPlainText',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showRawOutputSwitch',
|
||||
serverKey: 'showRawOutputSwitch',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'alwaysShowSidebarOnDesktop',
|
||||
serverKey: 'alwaysShowSidebarOnDesktop',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showRawModelNames',
|
||||
serverKey: 'showRawModelNames',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{ key: 'mcpServers', serverKey: 'mcpServers', type: SyncableParameterType.STRING, canSync: true },
|
||||
{
|
||||
key: 'agenticMaxTurns',
|
||||
serverKey: 'agenticMaxTurns',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'agenticMaxToolPreviewLines',
|
||||
serverKey: 'agenticMaxToolPreviewLines',
|
||||
type: SyncableParameterType.NUMBER,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'showToolCallInProgress',
|
||||
serverKey: 'showToolCallInProgress',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'alwaysShowAgenticTurns',
|
||||
serverKey: 'alwaysShowAgenticTurns',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'excludeReasoningFromContext',
|
||||
serverKey: 'excludeReasoningFromContext',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
},
|
||||
{
|
||||
key: 'sendOnEnter',
|
||||
serverKey: 'sendOnEnter',
|
||||
type: SyncableParameterType.BOOLEAN,
|
||||
canSync: true
|
||||
}
|
||||
];
|
||||
|
||||
export class ParameterSyncService {
|
||||
/**
|
||||
*
|
||||
|
|
@ -298,7 +53,7 @@ export class ParameterSyncService {
|
|||
|
||||
// Handle samplers array conversion to string
|
||||
if (serverParams.samplers && Array.isArray(serverParams.samplers)) {
|
||||
extracted.samplers = serverParams.samplers.join(';');
|
||||
extracted[SETTINGS_KEYS.SAMPLERS] = serverParams.samplers.join(';');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
11
tools/server/webui/src/lib/services/router.service.ts
Normal file
11
tools/server/webui/src/lib/services/router.service.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
export class RouterService {
|
||||
static chat(id: string): string {
|
||||
return `${ROUTES.CHAT}/${id}`;
|
||||
}
|
||||
|
||||
static settings(section: string): string {
|
||||
return `${ROUTES.SETTINGS}/${section}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -413,8 +413,6 @@ class AgenticStore {
|
|||
|
||||
const tools = toolsStore.getEnabledToolsForLLM();
|
||||
if (tools.length === 0) {
|
||||
console.log('[AgenticStore] No tools available, falling back to standard chat');
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ import {
|
|||
import {
|
||||
MAX_INACTIVE_CONVERSATION_STATES,
|
||||
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
|
||||
SYSTEM_MESSAGE_PLACEHOLDER
|
||||
SYSTEM_MESSAGE_PLACEHOLDER,
|
||||
TITLE_GENERATION
|
||||
} from '$lib/constants';
|
||||
import type {
|
||||
ChatMessageTimings,
|
||||
|
|
@ -44,7 +45,12 @@ import type {
|
|||
ChatStreamCallbacks,
|
||||
ErrorDialogState
|
||||
} from '$lib/types/chat';
|
||||
import type { ApiProcessingState, DatabaseMessage, DatabaseMessageExtra } from '$lib/types';
|
||||
import type {
|
||||
ApiChatMessageData,
|
||||
ApiProcessingState,
|
||||
DatabaseMessage,
|
||||
DatabaseMessageExtra
|
||||
} from '$lib/types';
|
||||
import { ErrorDialogType, MessageRole, MessageType } from '$lib/enums';
|
||||
|
||||
interface ConversationStateEntry {
|
||||
|
|
@ -259,7 +265,7 @@ class ChatStore {
|
|||
}
|
||||
|
||||
private isChatLoadingInternal(convId: string): boolean {
|
||||
return this.chatStreamingStates.has(convId);
|
||||
return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId);
|
||||
}
|
||||
|
||||
hasPendingMessage(convId: string): boolean {
|
||||
|
|
@ -572,7 +578,11 @@ class ChatStore {
|
|||
conversationsStore.addMessageToActive(assistantMessage);
|
||||
await this.streamChatCompletion(
|
||||
conversationsStore.activeMessages.slice(0, -1),
|
||||
assistantMessage
|
||||
assistantMessage,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
config().titleGenerationUseLLM && isNewConversation ? content : undefined
|
||||
);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
|
|
@ -601,7 +611,8 @@ class ChatStore {
|
|||
assistantMessage: DatabaseMessage,
|
||||
onComplete?: (content: string) => Promise<void>,
|
||||
onError?: (error: Error) => void,
|
||||
modelOverride?: string | null
|
||||
modelOverride?: string | null,
|
||||
firstUserMessageContent?: string
|
||||
): Promise<void> {
|
||||
let effectiveModel = modelOverride;
|
||||
|
||||
|
|
@ -845,6 +856,10 @@ class ChatStore {
|
|||
perChatOverrides
|
||||
});
|
||||
if (agenticResult.handled) {
|
||||
// Generate LLM based title for new conversations after agentic flow completes
|
||||
if (firstUserMessageContent) {
|
||||
await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
|
||||
}
|
||||
// Check if there's a pending steering message to re-send
|
||||
const pending = agenticStore.consumePendingSteeringMessage(convId);
|
||||
if (pending) {
|
||||
|
|
@ -894,6 +909,12 @@ class ChatStore {
|
|||
if (onComplete) await onComplete(content);
|
||||
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
|
||||
|
||||
// Generate LLM based title for new conversations (avoids stale reference
|
||||
// issue when user switches conversations while streaming)
|
||||
if (firstUserMessageContent) {
|
||||
await this.generateTitleWithLLM(firstUserMessageContent, streamedContent, convId);
|
||||
}
|
||||
|
||||
// Check if there's a pending message queued during streaming
|
||||
const pending = this.consumePendingMessage(convId);
|
||||
if (pending) {
|
||||
|
|
@ -921,6 +942,49 @@ class ChatStore {
|
|||
this.setProcessingState(convId, null);
|
||||
this.clearPendingMessage(convId);
|
||||
}
|
||||
|
||||
private async generateTitleWithLLM(
|
||||
userContent: string,
|
||||
assistantContent: string,
|
||||
convId: string
|
||||
): Promise<void> {
|
||||
const effectiveModel = isRouterMode() && selectedModelName() ? selectedModelName() : undefined;
|
||||
const configValue = config();
|
||||
const titlePromptTemplate =
|
||||
typeof configValue.titleGenerationPrompt === 'string' &&
|
||||
configValue.titleGenerationPrompt.trim()
|
||||
? configValue.titleGenerationPrompt
|
||||
: TITLE_GENERATION.DEFAULT_PROMPT;
|
||||
|
||||
const titlePrompt = titlePromptTemplate
|
||||
.replace('{{USER}}', String(userContent || ''))
|
||||
.replace('{{ASSISTANT}}', String(assistantContent || ''));
|
||||
|
||||
const titleMessage: ApiChatMessageData = {
|
||||
role: MessageRole.USER,
|
||||
content: titlePrompt
|
||||
};
|
||||
|
||||
const titleResponse = await ChatService.generateTitle(titleMessage, effectiveModel);
|
||||
|
||||
if (!titleResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cleanTitle = titleResponse.trim();
|
||||
cleanTitle = cleanTitle
|
||||
.replace(TITLE_GENERATION.PREFIX_PATTERN, '')
|
||||
.replace(TITLE_GENERATION.QUOTE_PATTERN, '')
|
||||
.trim();
|
||||
if (!cleanTitle || cleanTitle.length < TITLE_GENERATION.MIN_LENGTH) {
|
||||
const firstLine = userContent.split('\n').find((l) => l.trim().length > 0);
|
||||
cleanTitle = firstLine ? firstLine.trim() : TITLE_GENERATION.FALLBACK;
|
||||
}
|
||||
if (cleanTitle && cleanTitle.length >= TITLE_GENERATION.MIN_LENGTH) {
|
||||
await conversationsStore.updateConversationName(convId, cleanTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private async savePartialResponseIfNeeded(convId?: string): Promise<void> {
|
||||
const conversationId = convId || conversationsStore.activeConversation?.id;
|
||||
if (!conversationId) return;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import {
|
|||
MULTIPLE_UNDERSCORE_REGEX,
|
||||
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY
|
||||
} from '$lib/constants';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface ConversationTreeItem {
|
||||
|
|
@ -260,7 +262,7 @@ class ConversationsStore {
|
|||
this.activeConversation = conversation;
|
||||
this.activeMessages = [];
|
||||
|
||||
await goto(`#/chat/${conversation.id}`);
|
||||
await goto(RouterService.chat(conversation.id));
|
||||
|
||||
return conversation.id;
|
||||
}
|
||||
|
|
@ -336,7 +338,7 @@ class ConversationsStore {
|
|||
|
||||
if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
|
||||
this.clearActiveConversation();
|
||||
await goto(`?new_chat=true#/`);
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
}
|
||||
} else {
|
||||
// Reparent direct children to deleted conv's parent (or promote to top-level)
|
||||
|
|
@ -352,7 +354,7 @@ class ConversationsStore {
|
|||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.clearActiveConversation();
|
||||
await goto(`?new_chat=true#/`);
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -376,7 +378,7 @@ class ConversationsStore {
|
|||
|
||||
toast.success('All conversations deleted');
|
||||
|
||||
await goto(`?new_chat=true#/`);
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
toast.error('Failed to delete conversations');
|
||||
|
|
@ -729,7 +731,7 @@ class ConversationsStore {
|
|||
|
||||
this.conversations = [newConv, ...this.conversations];
|
||||
|
||||
await goto(`#/chat/${newConv.id}`);
|
||||
await goto(RouterService.chat(newConv.id));
|
||||
|
||||
toast.success('Conversation forked');
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
|
||||
import { browser } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
|
|
@ -556,13 +557,13 @@ class MCPStore {
|
|||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
useProxy: serverData.useProxy
|
||||
};
|
||||
settingsStore.updateConfig('mcpServers', JSON.stringify([...servers, newServer]));
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
|
||||
}
|
||||
|
||||
updateServer(id: string, updates: Partial<MCPServerSettingsEntry>): void {
|
||||
const servers = this.getServers();
|
||||
settingsStore.updateConfig(
|
||||
'mcpServers',
|
||||
SETTINGS_KEYS.MCP_SERVERS,
|
||||
JSON.stringify(
|
||||
servers.map((server) => (server.id === id ? { ...server, ...updates } : server))
|
||||
)
|
||||
|
|
@ -571,7 +572,10 @@ class MCPStore {
|
|||
|
||||
removeServer(id: string): void {
|
||||
const servers = this.getServers();
|
||||
settingsStore.updateConfig('mcpServers', JSON.stringify(servers.filter((s) => s.id !== id)));
|
||||
settingsStore.updateConfig(
|
||||
SETTINGS_KEYS.MCP_SERVERS,
|
||||
JSON.stringify(servers.filter((s) => s.id !== id))
|
||||
);
|
||||
this.clearHealthCheck(id);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
MODEL_PROPS_CACHE_MAX_ENTRIES,
|
||||
FAVORITE_MODELS_LOCALSTORAGE_KEY
|
||||
} from '$lib/constants';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
|
||||
/**
|
||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
|
||||
|
|
@ -424,6 +425,103 @@ class ModelsStore {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the model name from the last assistant message in the active conversation.
|
||||
* Iterates backward through messages to find the most recent message with a model.
|
||||
* Used by both the chat page and settings page to maintain model consistency.
|
||||
* @returns The model name or null if not found
|
||||
*/
|
||||
getModelFromLastAssistantResponse(): string | null {
|
||||
const messages = conversationsStore.activeMessages;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
// Iterate backward to find the last message with a model
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].model) {
|
||||
return messages[i].model;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-selects the model from the last assistant response if available and loaded.
|
||||
* Returns true if a model was selected, false otherwise.
|
||||
* This is used by the chat page to maintain model consistency across page navigation.
|
||||
*/
|
||||
async selectModelFromLastAssistantResponse(): Promise<boolean> {
|
||||
const lastModel = this.getModelFromLastAssistantResponse();
|
||||
if (!lastModel) return false;
|
||||
|
||||
// Skip if already selected
|
||||
if (this.selectedModelName === lastModel) return false;
|
||||
|
||||
const matchingModel = this.models.find((option) => option.model === lastModel);
|
||||
if (!matchingModel) return false;
|
||||
|
||||
if (!this.isModelLoaded(lastModel)) {
|
||||
console.log('[modelsStore] last assistant model not loaded:', lastModel);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.selectModelById(matchingModel.id);
|
||||
console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[modelsStore] Failed to automatically select model from last message:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-selects the first available model if none is selected, and fetches its props.
|
||||
* Prioritizes:
|
||||
* 1. Model from active conversation's last assistant response (if loaded)
|
||||
* 2. Model from active conversation's last assistant response (if not loaded)
|
||||
* 3. First loaded model (not from active conversation)
|
||||
* 4. First available model
|
||||
* This is used to ensure default values are populated in settings pages.
|
||||
*/
|
||||
async ensureFirstModelSelected(): Promise<void> {
|
||||
if (this.selectedModelName) return;
|
||||
|
||||
// Filter models that are visible in webui
|
||||
const availableModels = this.models.filter((option) => {
|
||||
const modelProps = this.getModelProps(option.model);
|
||||
return modelProps?.webui !== false;
|
||||
});
|
||||
|
||||
if (availableModels.length === 0) return;
|
||||
|
||||
// Try to select model from last assistant response first
|
||||
const lastModel = this.getModelFromLastAssistantResponse();
|
||||
if (lastModel) {
|
||||
const lastModelOption = availableModels.find((m) => m.model === lastModel);
|
||||
if (lastModelOption) {
|
||||
await this.selectModelById(lastModelOption.id);
|
||||
if (this.isModelLoaded(lastModel)) {
|
||||
await this.fetchModelProps(lastModel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a loaded model first
|
||||
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
|
||||
if (loadedModel) {
|
||||
await this.selectModelById(loadedModel.id);
|
||||
await this.fetchModelProps(loadedModel.model);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to the first available model
|
||||
const firstModel = availableModels[0];
|
||||
await this.selectModelById(firstModel.id);
|
||||
// Don't fetch props for unloaded models (will fail in ROUTER mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update modalities for a specific model
|
||||
* Called when a model is loaded or when we need fresh modality data
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
let _url = $state('#/');
|
||||
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
|
||||
|
||||
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
|
||||
|
||||
export const settingsReferrer = {
|
||||
get url() {
|
||||
|
|
|
|||
|
|
@ -32,9 +32,13 @@
|
|||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { ColorMode } from '$lib/enums';
|
||||
import type { SettingsExportType } from '$lib/types';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import {
|
||||
CONFIG_LOCALSTORAGE_KEY,
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
SETTINGS_KEYS,
|
||||
USER_OVERRIDES_LOCALSTORAGE_KEY
|
||||
} from '$lib/constants';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
|
|
@ -57,7 +61,6 @@ class SettingsStore {
|
|||
*/
|
||||
|
||||
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
|
||||
theme = $state<string>('auto');
|
||||
isInitialized = $state(false);
|
||||
userOverrides = $state<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -99,7 +102,9 @@ class SettingsStore {
|
|||
initialize() {
|
||||
try {
|
||||
this.loadConfig();
|
||||
this.loadTheme();
|
||||
this.migrateLegacyTheme();
|
||||
// Apply the persisted theme from config on initial load
|
||||
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize settings store:', error);
|
||||
|
|
@ -124,9 +129,9 @@ class SettingsStore {
|
|||
};
|
||||
|
||||
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||
if (!('sendOnEnter' in savedVal)) {
|
||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||
if (new IsMobile().current) {
|
||||
this.config.sendOnEnter = false;
|
||||
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,12 +148,21 @@ class SettingsStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Load theme from localStorage
|
||||
* Migrate the legacy un-namespaced "theme" localStorage key into config.
|
||||
* Previously theme was stored separately in localStorage("theme") — now it lives
|
||||
* inside the config object alongside all other settings.
|
||||
* After migration the legacy key is removed.
|
||||
*/
|
||||
private loadTheme() {
|
||||
private migrateLegacyTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
this.theme = localStorage.getItem('theme') || 'auto';
|
||||
const legacyTheme = localStorage.getItem('theme');
|
||||
if (legacyTheme) {
|
||||
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
|
||||
localStorage.removeItem('theme');
|
||||
this.saveConfig();
|
||||
setMode(legacyTheme as ColorMode);
|
||||
}
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
|
@ -233,29 +247,13 @@ class SettingsStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the theme setting
|
||||
* Update the theme setting.
|
||||
* @param newTheme - The new theme value
|
||||
*/
|
||||
updateTheme(newTheme: string) {
|
||||
this.theme = newTheme;
|
||||
this.saveTheme();
|
||||
}
|
||||
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
|
||||
|
||||
/**
|
||||
* Save the current theme to localStorage
|
||||
*/
|
||||
private saveTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
if (this.theme === 'auto') {
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
localStorage.setItem('theme', this.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save theme to localStorage:', error);
|
||||
}
|
||||
setMode(newTheme as ColorMode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -271,22 +269,26 @@ class SettingsStore {
|
|||
*/
|
||||
resetConfig() {
|
||||
this.config = { ...SETTING_CONFIG_DEFAULT };
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset theme to auto
|
||||
* Reset theme to default value.
|
||||
* Theme is now stored inside the config object.
|
||||
*/
|
||||
resetTheme() {
|
||||
this.theme = 'auto';
|
||||
this.saveTheme();
|
||||
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
|
||||
|
||||
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all settings to defaults
|
||||
* Reset all settings to defaults.
|
||||
*/
|
||||
resetAll() {
|
||||
this.resetConfig();
|
||||
|
||||
this.resetTheme();
|
||||
}
|
||||
|
||||
|
|
@ -456,10 +458,86 @@ class SettingsStore {
|
|||
this.saveConfig();
|
||||
console.log('Cleared all user overrides');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import / Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Export all settings as a versioned JSON-compatible object.
|
||||
* The export captures the full config (excluding sensitive values like API key)
|
||||
* and user overrides. Sensitive fields are filtered out for security by default.
|
||||
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
|
||||
*/
|
||||
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
|
||||
// Build config excluding sensitive data unless user opts in
|
||||
const configToExport: Record<string, string | number | boolean | undefined> =
|
||||
includeSensitiveData
|
||||
? { ...this.config }
|
||||
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
|
||||
|
||||
// Handle MCP servers: exclude custom headers unless user opts in
|
||||
if ('mcpServers' in configToExport && !includeSensitiveData) {
|
||||
try {
|
||||
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const safeServers = mcpServers.map((server) => {
|
||||
delete server.headers;
|
||||
return server;
|
||||
});
|
||||
configToExport.mcpServers = JSON.stringify(safeServers);
|
||||
} catch {
|
||||
// If parsing fails, just exclude the entire mcpServers field
|
||||
delete (configToExport as Record<string, unknown>).mcpServers;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
config: configToExport,
|
||||
userOverrides: Array.from(this.userOverrides)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from a previously exported object.
|
||||
* Restores config (including theme) and user overrides.
|
||||
* @param data - The exported settings object
|
||||
*/
|
||||
importSettings(data: SettingsExportType): void {
|
||||
if (!browser) return;
|
||||
|
||||
if (!data || !data.config) {
|
||||
throw new Error('Invalid settings data: missing config');
|
||||
}
|
||||
|
||||
// Restore config (theme is included in config)
|
||||
this.config = {
|
||||
...SETTING_CONFIG_DEFAULT,
|
||||
...data.config
|
||||
};
|
||||
|
||||
// Restore user overrides (derived state — may be stale if server defaults differ)
|
||||
this.userOverrides = new Set(data.userOverrides ?? []);
|
||||
|
||||
// Persist to localStorage
|
||||
this.saveConfig();
|
||||
|
||||
// Apply theme for immediate visual feedback
|
||||
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
|
||||
|
||||
console.log('Settings imported successfully');
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsStore = new SettingsStore();
|
||||
|
||||
export const config = () => settingsStore.config;
|
||||
export const theme = () => settingsStore.theme;
|
||||
export const theme = () => settingsStore.config[SETTINGS_KEYS.THEME];
|
||||
export const isInitialized = () => settingsStore.isInitialized;
|
||||
|
|
|
|||
|
|
@ -76,10 +76,15 @@ export type {
|
|||
SettingsFieldConfig,
|
||||
SettingsChatServiceOptions,
|
||||
SettingsConfigType,
|
||||
SettingsExportType,
|
||||
ParameterValue,
|
||||
ParameterRecord,
|
||||
ParameterInfo,
|
||||
SyncableParameter
|
||||
SyncableParameter,
|
||||
SettingsEntry,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionEntry,
|
||||
SettingsSection
|
||||
} from './settings';
|
||||
|
||||
// Common types
|
||||
|
|
|
|||
55
tools/server/webui/src/lib/types/settings.d.ts
vendored
55
tools/server/webui/src/lib/types/settings.d.ts
vendored
|
|
@ -1,12 +1,42 @@
|
|||
import type { SETTING_CONFIG_DEFAULT } from '$lib/constants';
|
||||
import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/constants';
|
||||
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
|
||||
import type { OpenAIToolDefinition } from './mcp';
|
||||
import type { DatabaseMessageExtra } from './database';
|
||||
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
|
||||
import type { Icon } from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type SettingsConfigValue = string | number | boolean | undefined;
|
||||
|
||||
/** Section title type derived from registry section titles. */
|
||||
export type SettingsSectionTitle =
|
||||
(typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
|
||||
|
||||
/** Per-setting metadata — one entry per setting. */
|
||||
export interface SettingsEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
help: string;
|
||||
defaultValue: SettingsConfigValue;
|
||||
type: SettingsFieldType;
|
||||
section?: string;
|
||||
options?: Array<{ value: string; label: string; icon: Component }>;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
sync?: {
|
||||
serverKey: string;
|
||||
paramType: SyncableParameterType;
|
||||
};
|
||||
}
|
||||
|
||||
/** A settings section with its icon, slug, title, and ordered settings. */
|
||||
export interface SettingsSectionEntry {
|
||||
title: SettingsSectionTitle;
|
||||
slug: string;
|
||||
icon: Component;
|
||||
settings: SettingsEntry[];
|
||||
}
|
||||
|
||||
export interface SettingsFieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
@ -16,6 +46,14 @@ export interface SettingsFieldConfig {
|
|||
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
|
||||
}
|
||||
|
||||
/** Re-exported for backward compatibility. */
|
||||
export interface SettingsSection {
|
||||
fields?: SettingsFieldConfig[];
|
||||
icon: Component;
|
||||
slug: string;
|
||||
title: SettingsSectionTitle;
|
||||
}
|
||||
|
||||
export interface SettingsChatServiceOptions {
|
||||
stream?: boolean;
|
||||
// Model (required in ROUTER mode, optional in MODEL mode)
|
||||
|
|
@ -94,3 +132,18 @@ export interface SyncableParameter {
|
|||
type: SyncableParameterType;
|
||||
canSync: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the settings JSON export file.
|
||||
* Versioned to allow future schema evolution.
|
||||
*/
|
||||
export interface SettingsExportType {
|
||||
/** Export format version — bumped on breaking changes */
|
||||
version: number;
|
||||
/** Unix timestamp of export */
|
||||
timestamp: number;
|
||||
/** Full settings config (includes theme as a config key) */
|
||||
config: SettingsConfigType;
|
||||
/** Keys that differ from server defaults (derived, but persisted for fidelity) */
|
||||
userOverrides: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { convertPDFToImage, convertPDFToText } from './pdf-processing';
|
|||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
|
|
@ -106,7 +107,7 @@ export async function parseFilesToMessageExtras(
|
|||
console.log('Non-vision model detected: forcing PDF-to-text mode and updating settings');
|
||||
|
||||
// Update the setting in localStorage
|
||||
settingsStore.updateConfig('pdfAsImage', false);
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, false);
|
||||
|
||||
// Show toast notification to user
|
||||
toast.warning(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
|
@ -104,7 +105,7 @@ export async function processFilesToChatUploaded(
|
|||
action: {
|
||||
label: 'Enable PDF as Images',
|
||||
onClick: () => {
|
||||
settingsStore.updateConfig('pdfAsImage', true);
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.PDF_AS_IMAGE, true);
|
||||
toast.success('PDF parsing as images enabled!', {
|
||||
duration: 3000
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { APP_NAME } from '$lib/constants';
|
||||
import { APP_NAME, NEW_CHAT_PARAM } from '$lib/constants';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get('q'));
|
||||
let modelParam = $derived(page.url.searchParams.get('model'));
|
||||
let newChatParam = $derived(page.url.searchParams.get('new_chat'));
|
||||
let newChatParam = $derived(page.url.searchParams.get(NEW_CHAT_PARAM));
|
||||
|
||||
// Dialog state for model not available error
|
||||
let showModelNotAvailable = $state(false);
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
url.searchParams.delete('q');
|
||||
url.searchParams.delete('model');
|
||||
url.searchParams.delete('new_chat');
|
||||
url.searchParams.delete(NEW_CHAT_PARAM);
|
||||
|
||||
replaceState(url.toString(), {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@
|
|||
import { page } from '$app/state';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
import { chatStore, isLoading } from '$lib/stores/chat.svelte';
|
||||
import {
|
||||
conversationsStore,
|
||||
activeConversation,
|
||||
activeMessages
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
|
||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
|
||||
let chatId = $derived(page.params.id);
|
||||
let currentChatId: string | undefined = undefined;
|
||||
|
|
@ -73,47 +70,9 @@
|
|||
urlParamsProcessed = true;
|
||||
}
|
||||
|
||||
async function selectModelFromLastAssistantResponse() {
|
||||
const messages = activeMessages();
|
||||
if (messages.length === 0) return;
|
||||
|
||||
let lastMessageWithModel: DatabaseMessage | undefined;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].model) {
|
||||
lastMessageWithModel = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastMessageWithModel) return;
|
||||
|
||||
const currentModelId = selectedModelId();
|
||||
const currentModelName = modelOptions().find((m) => m.id === currentModelId)?.model;
|
||||
|
||||
if (currentModelName === lastMessageWithModel.model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingModel = modelOptions().find(
|
||||
(option) => option.model === lastMessageWithModel.model
|
||||
);
|
||||
|
||||
if (matchingModel && modelsStore.isModelLoaded(matchingModel.model)) {
|
||||
try {
|
||||
await modelsStore.selectModelById(matchingModel.id);
|
||||
console.log(
|
||||
`Automatically selected model: ${lastMessageWithModel.model} from last message`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to automatically select model from last message:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
setTimeout(() => {
|
||||
selectModelFromLastAssistantResponse();
|
||||
void modelsStore.selectModelFromLastAssistantResponse();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
|
|
@ -141,7 +100,7 @@
|
|||
await handleUrlParams();
|
||||
}
|
||||
} else {
|
||||
await goto('#/');
|
||||
await goto(ROUTES.START);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ServerErrorSplash } from '$lib/components/app';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
|
||||
let error = $derived($page.error);
|
||||
let status = $derived($page.status);
|
||||
|
|
@ -17,7 +18,7 @@
|
|||
|
||||
function handleRetry() {
|
||||
// Navigate back to home page after successful API key validation
|
||||
goto('#/');
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -60,7 +61,7 @@
|
|||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => goto('#/')}
|
||||
onclick={() => goto(ROUTES.START)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Go Home
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { ROUTES } from '$lib/constants/routes';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
|
@ -53,7 +55,7 @@
|
|||
const currentId = page.params.id;
|
||||
|
||||
if (!currentId) {
|
||||
goto(`#/chat/${allConvs[direction === 1 ? 0 : allConvs.length - 1].id}`);
|
||||
goto(RouterService.chat(allConvs[direction === 1 ? 0 : allConvs.length - 1].id));
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -64,9 +66,9 @@
|
|||
const targetIdx = idx + direction;
|
||||
|
||||
if (targetIdx >= 0 && targetIdx < allConvs.length) {
|
||||
goto(`#/chat/${allConvs[targetIdx].id}`);
|
||||
goto(RouterService.chat(allConvs[targetIdx].id));
|
||||
} else {
|
||||
goto('?new_chat=true#/');
|
||||
goto(ROUTES.NEW_CHAT);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -21,7 +22,7 @@
|
|||
if (browser && window.history.length > 1 && !prevIsSettings) {
|
||||
history.back();
|
||||
} else {
|
||||
goto('#/');
|
||||
goto(SETTINGS_FALLBACK_EXIT_ROUTE);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { SettingsChat } from '$lib/components/app/settings';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { RouterService } from '$lib/services';
|
||||
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (!page.params.section) {
|
||||
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SettingsChat } from '$lib/components/app/settings';
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
|
||||
|
|
@ -96,6 +96,7 @@ export default defineConfig({
|
|||
'/props': 'http://localhost:8080',
|
||||
'/models': 'http://localhost:8080',
|
||||
'/tools': 'http://localhost:8080',
|
||||
'/slots': 'http://localhost:8080',
|
||||
'/cors-proxy': 'http://localhost:8080'
|
||||
},
|
||||
headers: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue