From 8a103ff67baeedc3a2563babe1aefaf1f0979d29 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 03:46:59 -0700 Subject: [PATCH 01/10] ci: add release workflow for cross-platform packaging --- .github/workflows/release.yml | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..ec7578c0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,120 @@ +name: Release + +on: + push: + tags: ['v*'] + pull_request: + branches: [main] + +jobs: + build-linux: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Setup Conan + run: | + pip install conan + conan profile detect + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Configure CMake + run: | + conan install . --build=missing -s build_type=Release + cmake -B build -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake -DCMAKE_BUILD_TYPE=Release + + - name: Build C++ + run: cmake --build build -j + + - name: Build WebUI + run: | + cd webui + npm ci + npm run build + + - name: Assemble green package + run: | + mkdir -p merak-pkg/webui merak-pkg/config + cp build/cli/merak merak-pkg/ + cp -r webui/dist/* merak-pkg/webui/ + cp config/prompts merak-pkg/config/ -r 2>/dev/null || true + cp scripts/start.sh merak-pkg/ + + - name: Package + run: tar czf merak-linux-x64.tar.gz -C merak-pkg . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: merak-linux-x64 + path: merak-linux-x64.tar.gz + + build-windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + + - name: Setup Conan + run: | + pip install conan + conan profile detect + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Configure CMake + run: | + conan install . --build=missing -s build_type=Release + cmake -B build -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake -DCMAKE_BUILD_TYPE=Release + + - name: Build C++ + run: cmake --build build --config Release -j + + - name: Build WebUI + run: | + cd webui + npm ci + npm run build + + - name: Assemble green package + shell: bash + run: | + mkdir -p merak-pkg/webui merak-pkg/config + cp build/cli/Release/merak.exe merak-pkg/ || cp build/cli/merak.exe merak-pkg/ + cp build/cli/Release/merak-launcher.exe merak-pkg/ 2>/dev/null || true + cp -r webui/dist/* merak-pkg/webui/ + cp config/prompts merak-pkg/config/ -r 2>/dev/null || true + + - name: Package + shell: bash + run: | + 7z a merak-windows-x64.zip ./merak-pkg/* + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: merak-windows-x64 + path: merak-windows-x64.zip + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [build-linux, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + merak-linux-x64/merak-linux-x64.tar.gz + merak-windows-x64/merak-windows-x64.zip + draft: true + generate_release_notes: true From 49d8659c930dd17418b0292960bdda22f7f99f11 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 03:53:08 -0700 Subject: [PATCH 02/10] ci: add caching, pin conan version, fix start.sh fallback --- .github/workflows/release.yml | 36 +++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec7578c0..117c64b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,14 +14,28 @@ jobs: - name: Setup Conan run: | - pip install conan + pip install "conan>=2.0,<3.0" conan profile detect + - name: Cache Conan + uses: actions/cache@v4 + with: + path: ~/.conan2 + key: ${{ runner.os }}-conan-${{ hashFiles('conanfile.txt') }} + restore-keys: ${{ runner.os }}-conan- + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }} + restore-keys: ${{ runner.os }}-npm- + - name: Configure CMake run: | conan install . --build=missing -s build_type=Release @@ -42,7 +56,7 @@ jobs: cp build/cli/merak merak-pkg/ cp -r webui/dist/* merak-pkg/webui/ cp config/prompts merak-pkg/config/ -r 2>/dev/null || true - cp scripts/start.sh merak-pkg/ + cp scripts/start.sh merak-pkg/ 2>/dev/null || true - name: Package run: tar czf merak-linux-x64.tar.gz -C merak-pkg . @@ -60,14 +74,28 @@ jobs: - name: Setup Conan run: | - pip install conan + pip install "conan>=2.0,<3.0" conan profile detect + - name: Cache Conan + uses: actions/cache@v4 + with: + path: ~/.conan2 + key: ${{ runner.os }}-conan-${{ hashFiles('conanfile.txt') }} + restore-keys: ${{ runner.os }}-conan- + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }} + restore-keys: ${{ runner.os }}-npm- + - name: Configure CMake run: | conan install . --build=missing -s build_type=Release @@ -105,7 +133,7 @@ jobs: release: if: startsWith(github.ref, 'refs/tags/v') needs: [build-linux, build-windows] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: write steps: From d81799a50a756c8b66d6dd00bd98734fda057746 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 03:58:43 -0700 Subject: [PATCH 03/10] feat: add portable PostgreSQL lifecycle module --- CMakeLists.txt | 1 + libs/portable_pg/CMakeLists.txt | 5 + .../portable_pg/include/merak/portable_pg.hpp | 45 +++++ libs/portable_pg/src/portable_pg.cpp | 168 ++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 libs/portable_pg/CMakeLists.txt create mode 100644 libs/portable_pg/include/merak/portable_pg.hpp create mode 100644 libs/portable_pg/src/portable_pg.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 57cf95ab..f0f96233 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ add_subdirectory(libs/prompts) add_subdirectory(libs/loop) add_subdirectory(libs/storage) add_subdirectory(libs/worldbuilding) +add_subdirectory(libs/portable_pg) add_subdirectory(libs/runtime) add_subdirectory(libs/http) add_subdirectory(cli) diff --git a/libs/portable_pg/CMakeLists.txt b/libs/portable_pg/CMakeLists.txt new file mode 100644 index 00000000..52333fc8 --- /dev/null +++ b/libs/portable_pg/CMakeLists.txt @@ -0,0 +1,5 @@ +add_library(merak-portable-pg STATIC + src/portable_pg.cpp +) +target_include_directories(merak-portable-pg PUBLIC include) +target_link_libraries(merak-portable-pg PUBLIC merak-core) diff --git a/libs/portable_pg/include/merak/portable_pg.hpp b/libs/portable_pg/include/merak/portable_pg.hpp new file mode 100644 index 00000000..ab60542c --- /dev/null +++ b/libs/portable_pg/include/merak/portable_pg.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include + +namespace merak { + +class PortablePg { +public: + explicit PortablePg(std::filesystem::path pg_dir); + ~PortablePg(); + + PortablePg(const PortablePg&) = delete; + PortablePg& operator=(const PortablePg&) = delete; + + // Start PostgreSQL. Returns true on success. + // If pg_dir_/data doesn't exist or is empty, runs initdb first. + bool start(); + + // Stop PostgreSQL via pg_ctl stop -m fast + void stop(); + + // True if PostgreSQL is running + bool is_running() const { return running_; } + + // Port number PostgreSQL is listening on + int port() const { return port_; } + + // Connection string for the merak database (libpq format) + std::string connection_string() const; + +private: + std::filesystem::path pg_dir_; + int port_ = 0; + bool running_ = false; + + std::filesystem::path bin_dir() const { return pg_dir_ / "bin"; } + std::filesystem::path data_dir() const { return pg_dir_ / "data"; } + bool initdb(); + bool start_server(); + bool create_database(); + int find_free_port(); + bool is_port_in_use(int port) const; +}; + +} // namespace merak diff --git a/libs/portable_pg/src/portable_pg.cpp b/libs/portable_pg/src/portable_pg.cpp new file mode 100644 index 00000000..8c65e644 --- /dev/null +++ b/libs/portable_pg/src/portable_pg.cpp @@ -0,0 +1,168 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#pragma comment(lib, "ws2_32.lib") +#define SOCKET SOCKET +#define INVALID_SOCKET INVALID_SOCKET +#define SOCKET_ERROR SOCKET_ERROR +#define closesocket closesocket +#else +#include +#include +#include +#include +#define SOCKET int +#define INVALID_SOCKET -1 +#define SOCKET_ERROR -1 +#define closesocket close +#endif + +namespace merak { + +PortablePg::PortablePg(std::filesystem::path pg_dir) : pg_dir_(std::move(pg_dir)) { +#ifdef _WIN32 + WSADATA wsaData; + WSAStartup(MAKEWORD(2, 2), &wsaData); +#endif +} + +PortablePg::~PortablePg() { + if (running_) stop(); +#ifdef _WIN32 + WSACleanup(); +#endif +} + +bool PortablePg::is_port_in_use(int port) const { + SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock == INVALID_SOCKET) return true; + struct sockaddr_in addr; + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(port)); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + int result = connect(sock, reinterpret_cast(&addr), sizeof(addr)); + closesocket(sock); + return result != SOCKET_ERROR; +} + +int PortablePg::find_free_port() { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dist(15432, 16431); + for (int i = 0; i < 100; ++i) { + int port = dist(gen); + if (!is_port_in_use(port)) return port; + } + return 0; +} + +bool PortablePg::initdb() { + std::filesystem::create_directories(data_dir()); + auto initdb_path = bin_dir() / "initdb"; +#ifdef _WIN32 + initdb_path += ".exe"; +#endif + std::ostringstream cmd; + cmd << "\"" << initdb_path.string() << "\"" + << " -D \"" << data_dir().string() << "\"" + << " --no-locale --encoding=UTF8"; + auto log_path = pg_dir_ / "pg.init.log"; + cmd << " > \"" << log_path.string() << "\" 2>&1"; + int ret = std::system(cmd.str().c_str()); + return ret == 0; +} + +bool PortablePg::start_server() { + port_ = find_free_port(); + if (port_ == 0) return false; + + auto pg_ctl_path = bin_dir() / "pg_ctl"; +#ifdef _WIN32 + pg_ctl_path += ".exe"; +#endif + auto log_path = pg_dir_ / "pg.log"; + std::ostringstream cmd; + cmd << "\"" << pg_ctl_path.string() << "\"" + << " start -D \"" << data_dir().string() << "\"" + << " -l \"" << log_path.string() << "\"" + << " -o \"-p " << port_ + << " -k \\\"" << pg_dir_.string() << "\\\"\"" + << " >> \"" << (pg_dir_ / "pg.init.log").string() << "\" 2>&1"; + + int ret = std::system(cmd.str().c_str()); + if (ret != 0) return false; + + std::ofstream port_file(pg_dir_ / "pg.port"); + port_file << port_; + running_ = true; + return true; +} + +bool PortablePg::create_database() { + auto psql_path = bin_dir() / "psql"; +#ifdef _WIN32 + psql_path += ".exe"; +#endif + std::ostringstream cmd; + cmd << "\"" << psql_path.string() << "\"" + << " -h \"" << pg_dir_.string() << "\"" + << " -p " << port_ + << " -U postgres -d postgres" + << " -c \"CREATE DATABASE merak;\"" + << " >> \"" << (pg_dir_ / "pg.init.log").string() << "\" 2>&1"; + // Database may already exist — that's fine + std::system(cmd.str().c_str()); + return true; +} + +bool PortablePg::start() { + if (running_) return true; + if (!std::filesystem::exists(data_dir()) || std::filesystem::is_empty(data_dir())) { + if (!initdb()) { + std::cerr << "PortablePg: initdb failed\n"; + return false; + } + } + if (!start_server()) { + std::cerr << "PortablePg: pg_ctl start failed\n"; + return false; + } + create_database(); + return true; +} + +void PortablePg::stop() { + if (!running_) return; + auto pg_ctl_path = bin_dir() / "pg_ctl"; +#ifdef _WIN32 + pg_ctl_path += ".exe"; +#endif + std::ostringstream cmd; + cmd << "\"" << pg_ctl_path.string() << "\"" + << " stop -D \"" << data_dir().string() << "\"" +#ifdef _WIN32 + << " -m fast > nul 2>&1"; +#else + << " -m fast > /dev/null 2>&1"; +#endif + std::system(cmd.str().c_str()); + running_ = false; +} + +std::string PortablePg::connection_string() const { + std::ostringstream s; + s << "host=" << pg_dir_.string() + << " port=" << port_ + << " dbname=merak user=postgres"; + return s.str(); +} + +} // namespace merak From e73439faf5d7332c2cdd69ee790f778f046d983f Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 04:06:40 -0700 Subject: [PATCH 04/10] feat: integrate portable PG and static file serving into merak serve --- cli/CMakeLists.txt | 1 + cli/src/main.cpp | 30 ++++++++++++- libs/http/CMakeLists.txt | 1 + libs/http/include/merak/http_server.hpp | 8 +++- libs/http/src/http_server.cpp | 60 ++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index 3723d5be..5d45948f 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -30,6 +30,7 @@ set_target_properties(merak-cli PROPERTIES OUTPUT_NAME merak) target_link_libraries(merak-cli PRIVATE merak-http merak-runtime merak-storage merak-loop merak-config merak-llm merak-tools merak-mcp merak-memory merak-context + merak-portable-pg CURL::libcurl tree-sitter ${TS_LANGUAGES} ) diff --git a/cli/src/main.cpp b/cli/src/main.cpp index 6464f798..a1884eb9 100644 --- a/cli/src/main.cpp +++ b/cli/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -80,6 +81,23 @@ static std::string normalize_team_pattern(std::string pattern){ static int run_server(int argc,char**argv) { auto cfg=load_config(); + // --- Portable PostgreSQL --- + std::unique_ptr portable_pg; + { + auto exe_dir = std::filesystem::canonical( + std::filesystem::path(argv[0]).parent_path()); + auto pg_path = exe_dir / "pg"; + if (std::filesystem::exists(pg_path) && cfg.memory.db_connection.empty()) { + portable_pg = std::make_unique(pg_path); + if (portable_pg->start()) { + cfg.memory.db_connection = portable_pg->connection_string(); + std::cout << "Portable PostgreSQL started on port " << portable_pg->port() << "\n"; + } else { + std::cerr << "Warning: portable PostgreSQL failed to start\n"; + portable_pg.reset(); + } + } + } // Instantiate WorldbuildingService std::shared_ptr wb_service; try { @@ -154,7 +172,17 @@ static int run_server(int argc,char**argv) { metadata.tools = tools->all_tools(); metadata.mcp_servers = mcp_status; metadata.agents = runtime->agents(); - HttpServer server(runtime,metadata); + HttpServer server(runtime, metadata, merak_home()); + // Serve static WebUI files + { + auto exe_dir = std::filesystem::canonical( + std::filesystem::path(argv[0]).parent_path()); + auto webui_path = exe_dir / "webui"; + if (std::filesystem::exists(webui_path)) { + server.serve_static_dir("/", webui_path.string()); + std::cout << "Serving WebUI from " << webui_path << "\n"; + } + } // Register Worldbuilding HTTP routes if (wb_service) { auto wb_handler = std::make_shared(wb_service); diff --git a/libs/http/CMakeLists.txt b/libs/http/CMakeLists.txt index 2dfe3c38..fb6f696e 100644 --- a/libs/http/CMakeLists.txt +++ b/libs/http/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(merak-http STATIC target_include_directories(merak-http PUBLIC include) target_link_libraries(merak-http PUBLIC merak-core + merak-config merak-runtime merak-worldbuilding httplib::httplib diff --git a/libs/http/include/merak/http_server.hpp b/libs/http/include/merak/http_server.hpp index 502c42c1..25acbc79 100644 --- a/libs/http/include/merak/http_server.hpp +++ b/libs/http/include/merak/http_server.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace merak { @@ -22,7 +23,9 @@ struct HttpResult { int status; nlohmann::json body; }; class HttpServer { public: - HttpServer(std::shared_ptr runtime, RuntimeMetadata metadata); + HttpServer(std::shared_ptr runtime, RuntimeMetadata metadata, + std::string merak_home = ""); + void serve_static_dir(const std::string& mount_point, const std::string& dir_path); void listen(int port); void stop(); httplib::Server& raw_server() { return server_; } @@ -37,7 +40,10 @@ class HttpServer { std::shared_ptr runtime_; RuntimeMetadata metadata_; httplib::Server server_; + std::string merak_home_path_; void install_routes(); + void handle_config_get(const httplib::Request& req, httplib::Response& res); + void handle_config_set(const httplib::Request& req, httplib::Response& res); static void json(httplib::Response& response, const HttpResult& result); static HttpResult error(const std::string& code, const std::string& message, int status, bool retryable = false); diff --git a/libs/http/src/http_server.cpp b/libs/http/src/http_server.cpp index 87331176..62bad3de 100644 --- a/libs/http/src/http_server.cpp +++ b/libs/http/src/http_server.cpp @@ -1,4 +1,5 @@ #include +#include namespace merak { namespace { @@ -13,7 +14,9 @@ DelegationRequest delegation_request_from_json(const nlohmann::json&body){ return request; } } -HttpServer::HttpServer(std::shared_ptrruntime,RuntimeMetadata metadata):runtime_(std::move(runtime)),metadata_(std::move(metadata)){install_routes();} +HttpServer::HttpServer(std::shared_ptrruntime,RuntimeMetadata metadata, + std::string merak_home):runtime_(std::move(runtime)),metadata_(std::move(metadata)), + merak_home_path_(std::move(merak_home)){install_routes();} HttpResult HttpServer::error(const std::string&code,const std::string&message,int status,bool retryable){return{status,{{"error",{{"code",code},{"message",message},{"retryable",retryable}}}}};} void HttpServer::json(httplib::Response&r,const HttpResult&v){r.status=v.status;r.set_content(v.body.dump(),"application/json");} HttpResult HttpServer::handle_runtime_metadata()const{nlohmann::json tools=nlohmann::json::array(),mcp=nlohmann::json::array(),agents=nlohmann::json::array(),models=nlohmann::json::array();for(const auto&t:metadata_.tools)tools.push_back({{"name",t.name},{"description",t.description},{"source",t.source}});for(const auto&s:metadata_.mcp_servers)mcp.push_back({{"name",s.name},{"alive",s.alive}});for(const auto&a:metadata_.agents)agents.push_back({{"id",a.id},{"description",a.description}});for(const auto&m:metadata_.models)models.push_back({{"name",m.name},{"provider",m.provider},{"max_context_tokens",m.max_context_tokens}});if(models.empty())models.push_back({{"name",metadata_.model},{"provider",metadata_.provider},{"max_context_tokens",128000}});return{200,{{"provider",metadata_.provider},{"model",metadata_.model},{"models",models},{"permission_mode",metadata_.permission_mode},{"memory",{{"enabled",metadata_.memory_enabled}}},{"tools",tools},{"mcp_servers",mcp},{"agents",agents},{"delegation_patterns",{"fan_out","sequential","pipeline"}}}};} @@ -33,7 +36,62 @@ void HttpServer::install_routes(){ server_.Post(R"(/v1/approvals/([^/]+))",[this](const auto&req,auto&r){try{auto b=nlohmann::json::parse(req.body);auto status=b.value("decision","")=="allow"?ApprovalStatus::Allowed:ApprovalStatus::Denied;auto a=runtime_->resolve_approval(req.matches[1],status);json(r,{200,{{"approval_id",a.id},{"status",to_string(a.status)}}});}catch(const std::exception&e){json(r,error("approval_not_found",e.what(),404));}}); server_.Post(R"(/v1/runs/([^/]+)/cancel)",[this](const auto&req,auto&r){try{runtime_->cancel_run(req.matches[1]);json(r,{202,{{"run_id",req.matches[1]},{"status","cancelled"}}});}catch(const RuntimeError&e){json(r,error(e.code(),e.what(),404));}}); server_.Get(R"(/v1/sessions/([^/]+)/events/stream)",[this](const auto&req,auto&r){auto id=req.matches[1].str();auto cursor=after(req);try{auto subscription=runtime_->subscribe(id);auto backlog=runtime_->events_after(id,cursor);r.set_chunked_content_provider("text/event-stream",[subscription,backlog=std::move(backlog),cursor](size_t,httplib::DataSink&sink)mutable{auto send=[&](const RuntimeEvent&e){if(e.seq<=cursor||e.type=="message_appended"||e.type=="compaction_applied")return true;auto payload=nlohmann::json(e).dump();auto frame="id: "+std::to_string(e.seq)+"\nevent: "+e.type+"\ndata: "+payload+"\n\n";if(!sink.write(frame.data(),frame.size()))return false;cursor=e.seq;return true;};while(!backlog.empty()){auto e=backlog.front();backlog.erase(backlog.begin());if(!send(e))return false;}RuntimeEvent live;if(subscription->wait_next(live,std::chrono::milliseconds(1000)))return send(live);auto ping=std::string(": keepalive\n\n");return sink.write(ping.data(),ping.size());});}catch(const RuntimeError&e){json(r,error(e.code(),e.what(),404));}}); + server_.Get("/api/config/llm", [this](const auto& req, auto& res) { handle_config_get(req, res); }); + server_.Post("/api/config/llm", [this](const auto& req, auto& res) { handle_config_set(req, res); }); } void HttpServer::listen(int port){server_.listen("127.0.0.1",port);} void HttpServer::stop(){server_.stop();} +void HttpServer::serve_static_dir(const std::string& mount_point, const std::string& dir_path) { + server_.set_mount_point(mount_point.c_str(), dir_path.c_str()); +} +void HttpServer::handle_config_get(const httplib::Request&, httplib::Response& res) { + auto cfg_result = ConfigLoader::load(); + if (!cfg_result.has_value()) { + json(res, error("config_load_failed", cfg_result.error().what(), 500)); + return; + } + auto& cfg = cfg_result.value(); + nlohmann::json body; + body["provider"] = cfg.llm.provider; + body["api_base_url"] = cfg.llm.api_base_url; + body["default_model"] = cfg.llm.default_model; + body["max_output_tokens"] = cfg.llm.max_output_tokens; + const auto& key = cfg.llm.api_key; + if (key.length() > 4) { + body["api_key_masked"] = std::string(key.length() - 4, '*') + key.substr(key.length() - 4); + } else { + body["api_key_masked"] = key.empty() ? "" : "****"; + } + res.set_content(body.dump(), "application/json"); +} +void HttpServer::handle_config_set(const httplib::Request& req, httplib::Response& res) { + try { + auto body = nlohmann::json::parse(req.body); + auto local_path = std::filesystem::path(merak_home_path_) / "settings.local.json"; + + nlohmann::json existing; + if (std::filesystem::exists(local_path)) { + std::ifstream in(local_path); + if (in) existing = nlohmann::json::parse(in, nullptr, false); + if (existing.is_discarded()) existing = nlohmann::json::object(); + } + + if (!existing.contains("llm")) existing["llm"] = nlohmann::json::object(); + if (body.contains("provider")) existing["llm"]["provider"] = body["provider"]; + if (body.contains("api_key") && body["api_key"].get() != "") { + existing["llm"]["api_key"] = body["api_key"]; + } + if (body.contains("api_base_url")) existing["llm"]["api_base_url"] = body["api_base_url"]; + if (body.contains("default_model")) existing["llm"]["default_model"] = body["default_model"]; + if (body.contains("max_output_tokens")) existing["llm"]["max_output_tokens"] = body["max_output_tokens"]; + + std::filesystem::create_directories(local_path.parent_path()); + std::ofstream out(local_path); + out << existing.dump(2); + + res.set_content("{\"ok\":true}", "application/json"); + } catch (const std::exception& e) { + json(res, error("config_save_failed", e.what(), 400)); + } +} } // namespace merak From 058eebd59e2545ab7b827d34c5903bfd087b85c2 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 04:08:12 -0700 Subject: [PATCH 05/10] feat: add Windows system-tray launcher for one-click start --- cli/CMakeLists.txt | 10 +++ cli/src/win_launcher.cpp | 172 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 cli/src/win_launcher.cpp diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index 5d45948f..2e0a7cfb 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -54,3 +54,13 @@ target_compile_features(test_persistence PRIVATE cxx_std_17) add_test(NAME test_persistence COMMAND test_persistence) install(TARGETS merak-cli RUNTIME DESTINATION bin) + +if(WIN32) + add_executable(merak-launcher src/win_launcher.cpp) + target_compile_features(merak-launcher PRIVATE cxx_std_23) + target_link_libraries(merak-launcher PRIVATE ws2_32 shell32 user32) + set_target_properties(merak-launcher PROPERTIES + RUNTIME_OUTPUT_NAME "merak-launcher" + WIN32_EXECUTABLE TRUE + ) +endif() diff --git a/cli/src/win_launcher.cpp b/cli/src/win_launcher.cpp new file mode 100644 index 00000000..f1158083 --- /dev/null +++ b/cli/src/win_launcher.cpp @@ -0,0 +1,172 @@ +#ifndef UNICODE +#define UNICODE +#endif +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif + +#include +#include +#include +#include +#include +#include + +#define WM_TRAYICON (WM_APP + 1) +#define IDM_OPEN_UI 1001 +#define IDM_STATUS 1002 +#define IDM_STOP 1003 +#define IDM_EXIT 1004 + +static HINSTANCE g_hInst; +static HWND g_hWnd; +static NOTIFYICONDATA g_nid; +static HANDLE g_hChildProcess = nullptr; +static bool g_serverRunning = false; + +static std::wstring ExeDir() { + WCHAR path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + std::wstring s(path); + auto pos = s.rfind(L'\\'); + return pos != std::wstring::npos ? s.substr(0, pos) : s; +} + +static void StartServer() { + if (g_serverRunning) return; + std::wstring cmd = L"\"" + ExeDir() + L"\\merak.exe\" serve"; + STARTUPINFOW si = { sizeof(si) }; + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + PROCESS_INFORMATION pi = {}; + if (CreateProcessW(nullptr, cmd.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { + g_hChildProcess = pi.hProcess; + CloseHandle(pi.hThread); + g_serverRunning = true; + } +} + +static void StopServer() { + if (g_hChildProcess) { + GenerateConsoleCtrlEvent(CTRL_C_EVENT, GetProcessId(g_hChildProcess)); + WaitForSingleObject(g_hChildProcess, 5000); + TerminateProcess(g_hChildProcess, 0); + CloseHandle(g_hChildProcess); + g_hChildProcess = nullptr; + } + g_serverRunning = false; +} + +static void OpenWebUI() { + ShellExecuteW(nullptr, L"open", L"http://localhost:3888", nullptr, nullptr, SW_SHOW); +} + +static void UpdateTrayMenu(HMENU menu) { + ModifyMenuW(menu, IDM_STATUS, MF_BYCOMMAND | MF_STRING, IDM_STATUS, + g_serverRunning ? L"Status: Running" : L"Status: Stopped"); + EnableMenuItem(menu, IDM_STOP, MF_BYCOMMAND | (g_serverRunning ? MF_ENABLED : MF_DISABLED)); + EnableMenuItem(menu, IDM_OPEN_UI, MF_BYCOMMAND | (g_serverRunning ? MF_ENABLED : MF_DISABLED)); +} + +static void ShowTrayMenu() { + HMENU menu = CreatePopupMenu(); + AppendMenuW(menu, MF_STRING, IDM_OPEN_UI, L"Open Merak WebUI"); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING | MF_DISABLED, IDM_STATUS, L"Status: Starting..."); + AppendMenuW(menu, MF_STRING, IDM_STOP, L"Stop Server"); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, IDM_EXIT, L"Exit"); + UpdateTrayMenu(menu); + + POINT pt; + GetCursorPos(&pt); + SetForegroundWindow(g_hWnd); + WORD cmd = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY, pt.x, pt.y, 0, g_hWnd, nullptr); + DestroyMenu(menu); + + switch (cmd) { + case IDM_OPEN_UI: OpenWebUI(); break; + case IDM_STOP: StopServer(); break; + case IDM_EXIT: + if (g_serverRunning) { + int answer = MessageBoxW(g_hWnd, L"Server is still running. Stop and exit?", + L"Merak", MB_YESNO | MB_ICONQUESTION); + if (answer == IDYES) { StopServer(); DestroyWindow(g_hWnd); } + } else { + DestroyWindow(g_hWnd); + } + break; + } +} + +static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_CREATE: { + g_nid.cbSize = sizeof(NOTIFYICONDATA); + g_nid.hWnd = hWnd; + g_nid.uID = 1; + g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; + g_nid.uCallbackMessage = WM_TRAYICON; + g_nid.hIcon = LoadIconW(nullptr, IDI_APPLICATION); + wcscpy_s(g_nid.szTip, L"Merak Agent"); + Shell_NotifyIconW(NIM_ADD, &g_nid); + // Poll for server readiness, then open browser + std::thread([]() { + for (int i = 0; i < 60; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock == INVALID_SOCKET) continue; + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = htons(3888); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != SOCKET_ERROR) { + closesocket(sock); + g_serverRunning = true; + ShellExecuteW(nullptr, L"open", L"http://localhost:3888", + nullptr, nullptr, SW_SHOW); + break; + } + closesocket(sock); + } + }).detach(); + return 0; + } + case WM_TRAYICON: + if (lParam == WM_RBUTTONUP) ShowTrayMenu(); + if (lParam == WM_LBUTTONDBLCLK) OpenWebUI(); + return 0; + case WM_DESTROY: + Shell_NotifyIconW(NIM_DELETE, &g_nid); + StopServer(); + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hWnd, msg, wParam, lParam); +} + +int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPSTR, int nCmdShow) { + g_hInst = hInstance; + WSADATA wsaData; + WSAStartup(MAKEWORD(2, 2), &wsaData); + + StartServer(); + + WNDCLASSEXW wc = { sizeof(wc) }; + wc.lpfnWndProc = WndProc; + wc.hInstance = hInstance; + wc.lpszClassName = L"MerakLauncher"; + RegisterClassExW(&wc); + + g_hWnd = CreateWindowExW(0, L"MerakLauncher", L"Merak", 0, + 0, 0, 0, 0, nullptr, nullptr, hInstance, nullptr); + + MSG msg; + while (GetMessageW(&msg, nullptr, 0, 0)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + WSACleanup(); + return 0; +} From baa7436c56624197a5089c4a967b619f71d7bd8a Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 04:09:48 -0700 Subject: [PATCH 06/10] feat: add Linux start and install scripts - start.sh: Launch Merak from a green package (Linux) - install.sh: Download and install the latest Merak release on Linux --- scripts/install.sh | 111 +++++++++++++++++++++------------------------ scripts/start.sh | 47 +++++++++++++++++++ 2 files changed, 99 insertions(+), 59 deletions(-) create mode 100755 scripts/start.sh diff --git a/scripts/install.sh b/scripts/install.sh index 8432af38..67854d54 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,68 +1,61 @@ #!/usr/bin/env bash +# install.sh — Download and install Merak on Linux set -euo pipefail -# Merak Agent — install script -# Usage: ./scripts/install.sh [--prefix ~/.local] +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } -PREFIX="${PREFIX:-$HOME/.local}" -BIN_DIR="$PREFIX/bin" -CONFIG_DIR="$HOME/.merak" +INSTALL_DIR="${MERAK_HOME:-$HOME/merak}" +REPO="ULookup/Merak" -echo "=== Merak Agent Install ===" -echo " binary → $BIN_DIR/merak" -echo " config → $CONFIG_DIR/" -echo "" +info "Merak Installer" +info "Install to: $INSTALL_DIR" -# 1. Configure once, then rebuild incrementally on every install -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -BUILD_DIR="$PROJECT_DIR/build" - -echo "[1/3] Building..." -if [ ! -f "$BUILD_DIR/CMakeCache.txt" ]; then - cmake -B "$BUILD_DIR" -S "$PROJECT_DIR" \ - -DCMAKE_TOOLCHAIN_FILE=Debug/generators/conan_toolchain.cmake \ - -DCMAKE_BUILD_TYPE=Debug +# 1. Get latest release +info "Fetching latest release..." +LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') +if [ -z "$LATEST_TAG" ]; then + error "Cannot fetch latest release. Check internet connection." fi -cmake --build "$BUILD_DIR" - -# 2. Install binary -echo "[2/3] Installing binary..." -mkdir -p "$BIN_DIR" -cp "$BUILD_DIR/cli/merak" "$BIN_DIR/merak" - -# 3. Install example config (if not exists) -echo "[3/3] Installing config..." -mkdir -p "$CONFIG_DIR" -if [ ! -f "$CONFIG_DIR/settings.json" ]; then - if [ -f "$PROJECT_DIR/.merak/settings.json" ]; then - cp "$PROJECT_DIR/.merak/settings.json" "$CONFIG_DIR/settings.json" - elif [ -f "$PROJECT_DIR/config/settings.json.example" ]; then - cp "$PROJECT_DIR/config/settings.json.example" "$CONFIG_DIR/settings.json" - else - cat > "$CONFIG_DIR/settings.json" << 'JSONEOF' -{ - "llm": { - "provider": "openai", - "api_base_url": "https://api.openai.com/v1", - "default_model": "gpt-4o" - }, - "agent": { - "system_prompt": "You are a helpful AI assistant. Use tools to complete tasks." - } -} -JSONEOF - fi +info "Latest version: $LATEST_TAG" + +# 2. Download +URL="https://github.com/$REPO/releases/download/$LATEST_TAG/merak-linux-x64.tar.gz" +info "Downloading $URL ..." +curl -fsSL "$URL" -o /tmp/merak.tar.gz || error "Download failed" + +# 3. Extract +mkdir -p "$INSTALL_DIR" +tar xzf /tmp/merak.tar.gz -C "$INSTALL_DIR" +rm /tmp/merak.tar.gz +info "Extracted to $INSTALL_DIR" + +# 4. Make executable +chmod +x "$INSTALL_DIR/merak" +chmod +x "$INSTALL_DIR/start.sh" 2>/dev/null || true + +# 5. Create desktop shortcut +DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications" +mkdir -p "$DESKTOP_DIR" +cat > "$DESKTOP_DIR/merak.desktop" << DESKTOPEOF +[Desktop Entry] +Name=Merak +Comment=AI Agent for Novel Writing +Exec=$INSTALL_DIR/start.sh +Icon=$INSTALL_DIR/icon.png +Terminal=true +Type=Application +Categories=Utility; +DESKTOPEOF +info "Desktop shortcut created" + +# 6. Init config if not exists +if [ ! -f "$HOME/.merak/settings.local.json" ]; then + "$INSTALL_DIR/merak" --init 2>/dev/null || true + info "Config template created at ~/.merak/settings.local.json" + warn "Edit ~/.merak/settings.local.json to add your API key" fi -echo "" -echo "Done. Now run: merak --init" -echo "" - -# PATH reminder -if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then - echo "Add $BIN_DIR to your PATH:" - echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc" - echo " source ~/.bashrc" - echo "" -fi +info "Done! Run: $INSTALL_DIR/start.sh" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 00000000..fe6c88e9 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# start.sh — Launch Merak from a green package (Linux) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "=== Merak Quick Start ===" + +# 1. Start merak serve in background +echo "[1/3] Starting merak serve..." +./merak serve --port 3888 & +SERVER_PID=$! +echo " merak serve PID: $SERVER_PID" + +# 2. Wait for server to be ready +echo "[2/3] Waiting for server..." +for i in $(seq 1 30); do + if curl -s http://127.0.0.1:3888/v1/runtime > /dev/null 2>&1; then + echo " Server ready." + break + fi + sleep 0.5 +done + +# 3. Open TUI in a new terminal +echo "[3/3] Launching TUI..." +if command -v gnome-terminal &>/dev/null; then + gnome-terminal -- bash -c "./merak tui; echo 'Press Enter to close...'; read" +elif command -v konsole &>/dev/null; then + konsole -e bash -c "./merak tui; echo 'Press Enter to close...'; read" +elif command -v xterm &>/dev/null; then + xterm -e "./merak tui" & +elif command -v x-terminal-emulator &>/dev/null; then + x-terminal-emulator -e "./merak tui" & +else + echo " No terminal found. Start TUI manually: ./merak tui" +fi + +echo "=== Merak is running ===" +echo " Server: http://127.0.0.1:3888" +echo " Press Ctrl+C to stop server" +echo "" + +# Wait for server +trap "kill $SERVER_PID 2>/dev/null; exit 0" INT TERM +wait $SERVER_PID From f2f69806a4cbf3db242034dedcffecefd3ac63be Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 04:10:41 -0700 Subject: [PATCH 07/10] feat: add Windows PowerShell install script --- scripts/install.ps1 | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 scripts/install.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..2a485d1c --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS + Install Merak on Windows — downloads the latest release and sets up shortcuts. +.DESCRIPTION + Run: irm https://raw.merak.dev/install.ps1 | iex +#> +param ( + [string]$InstallDir = "$env:USERPROFILE\merak" +) + +$ErrorActionPreference = "Stop" +$Repo = "ULookup/Merak" + +Write-Host "=== Merak Installer ===" -ForegroundColor Green +Write-Host "Install to: $InstallDir" + +# 1. Get latest release +Write-Host "[1/5] Fetching latest release..." -ForegroundColor Cyan +try { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" + $tag = $release.tag_name + Write-Host " Latest: $tag" +} catch { + Write-Host " Cannot fetch release info: $_" -ForegroundColor Red + exit 1 +} + +# 2. Download +$url = "https://github.com/$Repo/releases/download/$tag/merak-windows-x64.zip" +$zipPath = "$env:TEMP\merak-$tag.zip" +Write-Host "[2/5] Downloading $url ..." -ForegroundColor Cyan +try { + Invoke-WebRequest -Uri $url -OutFile $zipPath +} catch { + Write-Host " Download failed: $_" -ForegroundColor Red + exit 1 +} + +# 3. Extract +Write-Host "[3/5] Extracting..." -ForegroundColor Cyan +if (Test-Path $InstallDir) { + Remove-Item -Recurse -Force $InstallDir +} +Expand-Archive -Path $zipPath -DestinationPath $InstallDir +Remove-Item $zipPath + +# 4. Create desktop shortcut +Write-Host "[4/5] Creating shortcut..." -ForegroundColor Cyan +$WshShell = New-Object -ComObject WScript.Shell +$Shortcut = $WshShell.CreateShortcut("$env:USERPROFILE\Desktop\Merak.lnk") +$Shortcut.TargetPath = "$InstallDir\merak-launcher.exe" +$Shortcut.IconLocation = "$InstallDir\merak-launcher.exe,0" +$Shortcut.Save() + +# 5. Init config if not exists +Write-Host "[5/5] Setting up config..." -ForegroundColor Cyan +$merakHome = "$env:USERPROFILE\.merak" +if (-not (Test-Path "$merakHome\settings.local.json")) { + New-Item -ItemType Directory -Force -Path $merakHome | Out-Null + $template = @' +{ + "llm": {"provider":"anthropic","api_key":"","default_model":"claude-sonnet-4-6","max_output_tokens":4096}, + "agent": {"system_prompt":"You are a creative writing AI assistant. Use tools to help build worlds and stories."}, + "memory": {"enabled":false} +} +'@ + Set-Content -Path "$merakHome\settings.local.json" -Value $template + Write-Host " Config created at $merakHome\settings.local.json" -ForegroundColor Yellow + Write-Host " Configure your API key in the WebUI Settings page." -ForegroundColor Yellow +} + +Write-Host "=== Done! ===" -ForegroundColor Green +Write-Host "Double-click the Merak icon on your desktop to start." From c98edce4067654082e4c5fc67f45612de8621fb1 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 04:12:35 -0700 Subject: [PATCH 08/10] feat: add WebUI model configuration panel with API key management --- webui/src/api/client.ts | 10 ++ webui/src/components/Sidebar.tsx | 2 + .../Sidebar/SettingsPanel.module.css | 85 +++++++++++ .../src/components/Sidebar/SettingsPanel.tsx | 137 ++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 webui/src/components/Sidebar/SettingsPanel.module.css create mode 100644 webui/src/components/Sidebar/SettingsPanel.tsx diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index d20efac1..b4a610da 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -57,4 +57,14 @@ export const api = { getWorldTime: (worldId: string) => request('GET', `/api/worldbuilding/${worldId}/time`), sseUrl: (id: string, after = 0) => `${BASE}/v1/sessions/${id}/events/stream?after=${after}`, + + getConfig: () => request('GET', '/api/config/llm'), + + saveConfig: (config: { + provider?: string; + api_key?: string; + api_base_url?: string; + default_model?: string; + max_output_tokens?: number; + }) => request('POST', '/api/config/llm', config), }; diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index 1af7f6b2..6ebd9fc6 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -3,6 +3,7 @@ import styles from './Sidebar.module.css'; import ContextMeter from './Sidebar/ContextMeter'; import ModelSelector from './Sidebar/ModelSelector'; import SessionList from './Sidebar/SessionList'; +import SettingsPanel from './Sidebar/SettingsPanel'; import ToolPanel from './Sidebar/ToolPanel'; import WorldSelector from './Sidebar/WorldSelector'; @@ -35,6 +36,7 @@ export default function Sidebar({ open = true, onClose }: SidebarProps) { + ); } diff --git a/webui/src/components/Sidebar/SettingsPanel.module.css b/webui/src/components/Sidebar/SettingsPanel.module.css new file mode 100644 index 00000000..056f05bb --- /dev/null +++ b/webui/src/components/Sidebar/SettingsPanel.module.css @@ -0,0 +1,85 @@ +.panel { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.heading { + margin: 0 0 4px; + font-size: 14px; + font-weight: 600; +} + +.label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--color-text-secondary); +} + +.input { + padding: 6px 8px; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 13px; + background: var(--color-bg); + color: var(--color-text); + width: 100%; + box-sizing: border-box; +} + +.keyRow { + display: flex; + gap: 4px; +} + +.toggleBtn { + flex-shrink: 0; + padding: 6px 10px; + font-size: 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-secondary); + color: var(--color-text); + cursor: pointer; +} + +.actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.btn { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-secondary); + color: var(--color-text); + font-size: 13px; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.primary { + background: var(--color-accent, #2563eb); + color: #fff; + border-color: var(--color-accent, #2563eb); +} + +.ok { + font-size: 12px; + color: var(--color-success, #16a34a); +} + +.error { + font-size: 12px; + color: var(--color-error, #dc2626); +} diff --git a/webui/src/components/Sidebar/SettingsPanel.tsx b/webui/src/components/Sidebar/SettingsPanel.tsx new file mode 100644 index 00000000..e9f4f8bb --- /dev/null +++ b/webui/src/components/Sidebar/SettingsPanel.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import styles from './SettingsPanel.module.css'; + +interface ConfigState { + provider: string; + api_key_masked: string; + api_base_url: string; + default_model: string; + max_output_tokens: number; +} + +export default function SettingsPanel() { + const [config, setConfig] = useState(null); + const [apiKey, setApiKey] = useState(''); + const [provider, setProvider] = useState('anthropic'); + const [model, setModel] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [status, setStatus] = useState<'idle' | 'saved' | 'tested' | 'error'>('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + api.getConfig().then((data) => { + setConfig(data); + setProvider(data.provider || 'anthropic'); + setModel(data.default_model || ''); + setBaseUrl(data.api_base_url || ''); + }).catch(() => {}); + }, []); + + const handleSave = async () => { + setSaving(true); + setStatus('idle'); + try { + await api.saveConfig({ + provider, + api_key: apiKey || undefined, + default_model: model || undefined, + api_base_url: baseUrl || undefined, + }); + setStatus('saved'); + setApiKey(''); + } catch (e) { + setStatus('error'); + setErrorMsg(e instanceof Error ? e.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + setStatus('idle'); + try { + const res = await api.metadata(); + if (res.provider) { + setStatus('tested'); + } + } catch (e) { + setStatus('error'); + setErrorMsg(e instanceof Error ? e.message : 'Connection test failed'); + } finally { + setTesting(false); + } + }; + + if (!config) return
Loading config...
; + + return ( +
+

Model Configuration

+ + + + + + + + + +
+ + +
+ + {status === 'saved' &&
Configuration saved.
} + {status === 'tested' &&
Connection successful.
} + {status === 'error' &&
{errorMsg}
} +
+ ); +} From 1e5c70fda2eaa625948c83b9385c2a5d62c4c681 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 14:07:47 +0000 Subject: [PATCH 09/10] fix: resolve build errors for tree-sitter, fmt, and worldbuilding targets - Build tree-sitter v0.22.6 C library manually (no CMakeLists.txt in Rust project) - Add WASM stub functions for all parser/language.c references - Map all 13 grammar repos to correct URLs, branches, and source directories - Support C++ external scanners (.cc) for yaml and sql grammars - Fix fmt {:.1%} constexpr error on GCC 13 - Fix raw string delimiter in tool_search_tool.cpp - Fix namespace resolution for ToolMeta in tool_registry.cpp - Extract ToolResultCompactorConfig from incomplete-class context - Add explicit destructors for unique_ptr in world_store and agent_store - Add missing sqlite_helpers.cpp to worldbuilding library build - Add missing includes for spdlog, config_loader, and ids - Fix type mismatch in welcome_cell.hpp (int vs size_t) - Fix test namespace imports for worldbuilding test helpers --- cli/CMakeLists.txt | 129 +++++++++++++++++- cli/src/main.cpp | 31 +++-- cli/src/tui/history_cell/welcome_cell.hpp | 8 +- cli/src/win_launcher.cpp | 22 ++- .../include/merak/tool_result_compactor.hpp | 15 +- libs/http/include/merak/http_server.hpp | 10 +- libs/http/src/http_server.cpp | 63 ++++++--- libs/llm/include/merak/anthropic_provider.hpp | 1 + libs/llm/include/merak/llm_provider.hpp | 1 + libs/llm/include/merak/openai_provider.hpp | 1 + libs/llm/src/anthropic_provider.cpp | 23 ++++ libs/llm/src/openai_provider.cpp | 22 +++ libs/loop/src/agent_loop.cpp | 4 +- .../portable_pg/include/merak/portable_pg.hpp | 1 + libs/portable_pg/src/portable_pg.cpp | 25 +++- libs/runtime/src/runtime_service.cpp | 1 + libs/tools/src/tool_registry.cpp | 4 +- libs/tools/src/tool_search_tool.cpp | 4 +- libs/worldbuilding/CMakeLists.txt | 2 + .../merak/worldbuilding/agent_store.hpp | 1 + .../merak/worldbuilding/world_store.hpp | 1 + libs/worldbuilding/src/agent_store.cpp | 3 + libs/worldbuilding/src/world_store.cpp | 2 + libs/worldbuilding/tests/test_agent_store.cpp | 2 +- .../tests/test_foreshadowing_secret.cpp | 1 + .../tests/test_narrative_store.cpp | 1 + .../tests/test_scene_orchestrator.cpp | 1 + libs/worldbuilding/tests/test_world_store.cpp | 1 + .../tests/test_worldbuilding_e2e.cpp | 2 + tests/CMakeLists.txt | 7 +- webui/src/api/client.ts | 2 + .../src/components/Sidebar/SettingsPanel.tsx | 23 ++-- 32 files changed, 341 insertions(+), 73 deletions(-) diff --git a/cli/CMakeLists.txt b/cli/CMakeLists.txt index 2e0a7cfb..70d058a2 100644 --- a/cli/CMakeLists.txt +++ b/cli/CMakeLists.txt @@ -5,17 +5,129 @@ FetchContent_Declare( GIT_REPOSITORY https://github.com/tree-sitter/tree-sitter GIT_TAG v0.22.6 ) -FetchContent_MakeAvailable(tree-sitter) +FetchContent_GetProperties(tree-sitter) +if(NOT tree-sitter_POPULATED) + FetchContent_Populate(tree-sitter) +endif() + +# tree-sitter v0.22+ has no CMakeLists.txt (Rust project). +# Build the C library manually with WASM stubs since we use compiled-in grammars. +enable_language(C) +if(NOT TARGET tree-sitter) + # WASM stubs — grammar parser.c files reference these symbols, but we + # never load WASM grammars, so provide no-op implementations. + set(TS_WASM_STUB "${CMAKE_CURRENT_BINARY_DIR}/wasm_stubs.c") + file(WRITE ${TS_WASM_STUB} [=[ + #include + #include + typedef uint16_t TSStateId; + typedef struct TSLexer TSLexer; + // wasm_store stubs — grammars compiled as C never use WASM, + // but parser.c and language.c reference these symbols. + bool ts_language_is_wasm(const TSLanguage *self) { (void)self; return false; } + void ts_wasm_store_delete(TSWasmStore *self) { (void)self; } + bool ts_wasm_store_start(TSWasmStore *s, TSLexer *l, const TSLanguage *lang) { + (void)s; (void)l; (void)lang; return false; + } + void ts_wasm_store_reset(TSWasmStore *self) { (void)self; } + bool ts_wasm_store_has_error(const TSWasmStore *self) { (void)self; return false; } + bool ts_wasm_store_call_lex_main(TSWasmStore *s, TSStateId id) { (void)s; (void)id; return false; } + bool ts_wasm_store_call_lex_keyword(TSWasmStore *s, TSStateId id) { (void)s; (void)id; return false; } + uint32_t ts_wasm_store_call_scanner_create(TSWasmStore *s) { (void)s; return 0; } + void ts_wasm_store_call_scanner_destroy(TSWasmStore *s, uint32_t idx) { (void)s; (void)idx; } + bool ts_wasm_store_call_scanner_scan(TSWasmStore *s, uint32_t idx, uint32_t state) { (void)s; (void)idx; (void)state; return false; } + uint32_t ts_wasm_store_call_scanner_serialize(TSWasmStore *s, uint32_t idx, char *buf) { (void)s; (void)idx; (void)buf; return 0; } + void ts_wasm_store_call_scanner_deserialize(TSWasmStore *s, uint32_t idx, const char *buf, unsigned len) { (void)s; (void)idx; (void)buf; (void)len; } + void ts_wasm_language_retain(const TSLanguage *self) { (void)self; } + void ts_wasm_language_release(const TSLanguage *self) { (void)self; } + ]=]) + file(GLOB TS_SOURCES "${tree-sitter_SOURCE_DIR}/lib/src/*.c") + list(FILTER TS_SOURCES EXCLUDE REGEX ".*/lib\\.c$") + list(FILTER TS_SOURCES EXCLUDE REGEX ".*/wasm_store\\.c$") + add_library(tree-sitter STATIC ${TS_SOURCES} ${TS_WASM_STUB}) + target_include_directories(tree-sitter PUBLIC + ${tree-sitter_SOURCE_DIR}/lib/include + ) + target_include_directories(tree-sitter PRIVATE + ${tree-sitter_SOURCE_DIR}/lib/src + ) + target_compile_features(tree-sitter PRIVATE c_std_11) + set_target_properties(tree-sitter PROPERTIES LINKER_LANGUAGE C) +endif() set(TS_LANGUAGES cpp python go javascript typescript rust bash json yaml markdown sql java csharp ruby) +# Map language → repo URL + branch + source subdirectory. +# Default repo: tree-sitter/tree-sitter-${lang} +# Default branch: master +# Default src: src/ foreach(lang IN LISTS TS_LANGUAGES) + if(lang STREQUAL "typescript") + set(TS_REPO_URL "https://github.com/tree-sitter/tree-sitter-typescript") + set(TS_GIT_TAG "master") + set(TS_SRC_SUBDIR "typescript/src") + elseif(lang STREQUAL "csharp") + set(TS_REPO_URL "https://github.com/tree-sitter/tree-sitter-c-sharp") + set(TS_GIT_TAG "master") + set(TS_SRC_SUBDIR "src") + elseif(lang STREQUAL "yaml") + set(TS_REPO_URL "https://github.com/ikatyang/tree-sitter-yaml") + set(TS_GIT_TAG "master") + set(TS_SRC_SUBDIR "src") + elseif(lang STREQUAL "markdown") + set(TS_REPO_URL "https://github.com/tree-sitter-grammars/tree-sitter-markdown") + set(TS_GIT_TAG "split_parser") + set(TS_SRC_SUBDIR "tree-sitter-markdown/src") + elseif(lang STREQUAL "sql") + set(TS_REPO_URL "https://github.com/m-novikov/tree-sitter-sql") + set(TS_GIT_TAG "main") + set(TS_SRC_SUBDIR "src") + else() + set(TS_REPO_URL "https://github.com/tree-sitter/tree-sitter-${lang}") + set(TS_GIT_TAG "master") + set(TS_SRC_SUBDIR "src") + endif() + FetchContent_Declare( tree-sitter-${lang} - GIT_REPOSITORY https://github.com/tree-sitter/tree-sitter-${lang} - GIT_TAG master + GIT_REPOSITORY ${TS_REPO_URL} + GIT_TAG ${TS_GIT_TAG} ) - FetchContent_MakeAvailable(tree-sitter-${lang}) + FetchContent_GetProperties(tree-sitter-${lang}) + if(NOT tree-sitter-${lang}_POPULATED) + FetchContent_Populate(tree-sitter-${lang}) + endif() + if(NOT TARGET tree-sitter-${lang}) + add_library(tree-sitter-${lang} STATIC + ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR}/parser.c + ) + set(TS_LINK_LANG C) + if(EXISTS ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR}/scanner.c) + target_sources(tree-sitter-${lang} PRIVATE + ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR}/scanner.c + ) + elseif(EXISTS ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR}/scanner.cc) + target_sources(tree-sitter-${lang} PRIVATE + ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR}/scanner.cc + ) + set_source_files_properties( + ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR}/scanner.cc + PROPERTIES LANGUAGE CXX + ) + set(TS_LINK_LANG CXX) + endif() + target_include_directories(tree-sitter-${lang} PRIVATE + ${tree-sitter-${lang}_SOURCE_DIR}/${TS_SRC_SUBDIR} + ) + set_target_properties(tree-sitter-${lang} PROPERTIES LINKER_LANGUAGE ${TS_LINK_LANG}) + target_link_libraries(tree-sitter-${lang} PRIVATE tree-sitter) + endif() +endforeach() + +# Map language names to CMake target names: "cpp" → "tree-sitter-cpp" +set(TS_GRAMMAR_TARGETS "") +foreach(lang IN LISTS TS_LANGUAGES) + list(APPEND TS_GRAMMAR_TARGETS "tree-sitter-${lang}") endforeach() add_executable(merak-cli @@ -32,7 +144,7 @@ target_link_libraries(merak-cli PRIVATE merak-llm merak-tools merak-mcp merak-memory merak-context merak-portable-pg CURL::libcurl - tree-sitter ${TS_LANGUAGES} + tree-sitter ${TS_GRAMMAR_TARGETS} ) target_include_directories(merak-cli PRIVATE src) @@ -42,8 +154,11 @@ target_compile_features(test_buffer PRIVATE cxx_std_17) add_test(NAME test_buffer COMMAND test_buffer) add_executable(test_syntax tests/test_syntax.cpp) -target_link_libraries(test_syntax PRIVATE tree-sitter tree-sitter-python) -target_include_directories(test_syntax PRIVATE src) +target_link_libraries(test_syntax PRIVATE tree-sitter ${TS_GRAMMAR_TARGETS} nlohmann_json::nlohmann_json) +target_include_directories(test_syntax PRIVATE + src + ${tree-sitter_SOURCE_DIR}/lib/include +) target_compile_features(test_syntax PRIVATE cxx_std_17) add_test(NAME test_syntax COMMAND test_syntax) diff --git a/cli/src/main.cpp b/cli/src/main.cpp index a1884eb9..afe76ade 100644 --- a/cli/src/main.cpp +++ b/cli/src/main.cpp @@ -79,15 +79,29 @@ static std::string normalize_team_pattern(std::string pattern){ return pattern; } +#ifdef _WIN32 +#include +#endif + +static std::filesystem::path exe_dir_path() { +#ifdef _WIN32 + WCHAR path[MAX_PATH]; + if (GetModuleFileNameW(nullptr, path, MAX_PATH) == 0) return {}; + std::filesystem::path p(path); + return p.parent_path(); +#else + return std::filesystem::canonical("/proc/self/exe").parent_path(); +#endif +} + static int run_server(int argc,char**argv) { auto cfg=load_config(); // --- Portable PostgreSQL --- std::unique_ptr portable_pg; { - auto exe_dir = std::filesystem::canonical( - std::filesystem::path(argv[0]).parent_path()); - auto pg_path = exe_dir / "pg"; - if (std::filesystem::exists(pg_path) && cfg.memory.db_connection.empty()) { + auto exe = exe_dir_path(); + auto pg_path = exe / "pg"; + if (!exe.empty() && std::filesystem::exists(pg_path) && cfg.memory.db_connection.empty()) { portable_pg = std::make_unique(pg_path); if (portable_pg->start()) { cfg.memory.db_connection = portable_pg->connection_string(); @@ -172,13 +186,12 @@ static int run_server(int argc,char**argv) { metadata.tools = tools->all_tools(); metadata.mcp_servers = mcp_status; metadata.agents = runtime->agents(); - HttpServer server(runtime, metadata, merak_home()); + HttpServer server(runtime, metadata, merak_home(), llm); // Serve static WebUI files { - auto exe_dir = std::filesystem::canonical( - std::filesystem::path(argv[0]).parent_path()); - auto webui_path = exe_dir / "webui"; - if (std::filesystem::exists(webui_path)) { + auto exe = exe_dir_path(); + auto webui_path = exe / "webui"; + if (!exe.empty() && std::filesystem::exists(webui_path)) { server.serve_static_dir("/", webui_path.string()); std::cout << "Serving WebUI from " << webui_path << "\n"; } diff --git a/cli/src/tui/history_cell/welcome_cell.hpp b/cli/src/tui/history_cell/welcome_cell.hpp index 32f4b1a3..9053f1a9 100644 --- a/cli/src/tui/history_cell/welcome_cell.hpp +++ b/cli/src/tui/history_cell/welcome_cell.hpp @@ -39,16 +39,16 @@ class WelcomeCell final : public HistoryCell { auto info_line = "agent " + version_ + " · model " + model_ + " · branch " + branch_; static constexpr const char* tips_line = "/help commands · Shift+Enter newline · Ctrl+T tools · Ctrl+O transcript"; - auto content_width = std::max(info_line.size(), std::strlen(tips_line)) + 4; - auto box_width = width > 2 ? std::min(width - 2, content_width) : content_width; + size_t content_width = std::max(info_line.size(), std::strlen(tips_line)) + 4; + size_t box_width = width > 2 ? std::min(size_t(width - 2), content_width) : content_width; auto pad_right = [box_width](const std::string& text) { - auto inner = box_width > 4 ? box_width - 4 : 0; + size_t inner = box_width > 4 ? box_width - 4 : 0; if (text.size() >= inner) return text; return text + repeat_text(" ", inner - text.size()); }; - auto safe_box = std::max(box_width, size_t{2}); + size_t safe_box = std::max(box_width, size_t{2}); lines.push_back({Span{"┌" + repeat_text("─", safe_box - 2) + "┐", dim_fg}}); lines.push_back({Span{"│ ", dim_fg}, Span{pad_right(info_line), base}, Span{" │", dim_fg}}); lines.push_back({Span{"│ ", dim_fg}, Span{pad_right(std::string(tips_line)), dim_fg}, Span{" │", dim_fg}}); diff --git a/cli/src/win_launcher.cpp b/cli/src/win_launcher.cpp index f1158083..47602631 100644 --- a/cli/src/win_launcher.cpp +++ b/cli/src/win_launcher.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #define WM_TRAYICON (WM_APP + 1) @@ -22,7 +23,9 @@ static HINSTANCE g_hInst; static HWND g_hWnd; static NOTIFYICONDATA g_nid; static HANDLE g_hChildProcess = nullptr; -static bool g_serverRunning = false; +static std::atomic g_serverRunning{false}; +static std::atomic g_pollingStop{false}; +static std::thread g_pollThread; static std::wstring ExeDir() { WCHAR path[MAX_PATH]; @@ -49,9 +52,10 @@ static void StartServer() { static void StopServer() { if (g_hChildProcess) { - GenerateConsoleCtrlEvent(CTRL_C_EVENT, GetProcessId(g_hChildProcess)); - WaitForSingleObject(g_hChildProcess, 5000); - TerminateProcess(g_hChildProcess, 0); + // Wait briefly for graceful shutdown, then force-terminate + if (WaitForSingleObject(g_hChildProcess, 3000) == WAIT_TIMEOUT) { + TerminateProcess(g_hChildProcess, 0); + } CloseHandle(g_hChildProcess); g_hChildProcess = nullptr; } @@ -112,9 +116,11 @@ static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lPara wcscpy_s(g_nid.szTip, L"Merak Agent"); Shell_NotifyIconW(NIM_ADD, &g_nid); // Poll for server readiness, then open browser - std::thread([]() { - for (int i = 0; i < 60; ++i) { + g_pollingStop = false; + g_pollThread = std::thread([]() { + for (int i = 0; i < 60 && !g_pollingStop; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(500)); + if (g_pollingStop) break; SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); if (sock == INVALID_SOCKET) continue; struct sockaddr_in addr = {}; @@ -130,7 +136,7 @@ static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lPara } closesocket(sock); } - }).detach(); + }); return 0; } case WM_TRAYICON: @@ -139,6 +145,8 @@ static LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lPara return 0; case WM_DESTROY: Shell_NotifyIconW(NIM_DELETE, &g_nid); + g_pollingStop = true; + if (g_pollThread.joinable()) g_pollThread.join(); StopServer(); PostQuitMessage(0); return 0; diff --git a/libs/context/include/merak/tool_result_compactor.hpp b/libs/context/include/merak/tool_result_compactor.hpp index cc848df0..6962ef5d 100644 --- a/libs/context/include/merak/tool_result_compactor.hpp +++ b/libs/context/include/merak/tool_result_compactor.hpp @@ -9,15 +9,18 @@ namespace merak { // placeholder text. Never deletes messages — only replaces content. // Modeled after Astra's microcompact: preserves message identity and // tool_call_id pairing while freeing token budget. +struct ToolResultCompactorConfig { + int keep_recent = 6; + int max_result_chars = 8000; + double pressure_threshold = 0.6; +}; + class ToolResultCompactor { public: - struct Config { - int keep_recent = 6; // keep N most recent tool results intact - int max_result_chars = 8000; // compress results exceeding this length - double pressure_threshold = 0.6; // only compact when context pressure > this - }; + using Config = ToolResultCompactorConfig; - explicit ToolResultCompactor(Config config = {}) : config_(config) {} + ToolResultCompactor() = default; + explicit ToolResultCompactor(Config config) : config_(config) {} // Compress tool result messages in-place. Returns number compacted. int compact(std::vector& history, double context_pressure); diff --git a/libs/http/include/merak/http_server.hpp b/libs/http/include/merak/http_server.hpp index 25acbc79..1799b1ac 100644 --- a/libs/http/include/merak/http_server.hpp +++ b/libs/http/include/merak/http_server.hpp @@ -5,9 +5,12 @@ #include #include #include +#include namespace merak { +class LlmProvider; + struct McpServerStatus { std::string name; bool alive = false; }; struct RuntimeMetadata { std::string provider; @@ -24,7 +27,8 @@ struct HttpResult { int status; nlohmann::json body; }; class HttpServer { public: HttpServer(std::shared_ptr runtime, RuntimeMetadata metadata, - std::string merak_home = ""); + std::string merak_home = "", + std::shared_ptr llm_provider = nullptr); void serve_static_dir(const std::string& mount_point, const std::string& dir_path); void listen(int port); void stop(); @@ -44,6 +48,10 @@ class HttpServer { void install_routes(); void handle_config_get(const httplib::Request& req, httplib::Response& res); void handle_config_set(const httplib::Request& req, httplib::Response& res); + void handle_config_test(const httplib::Request& req, httplib::Response& res); + + std::shared_ptr llm_provider_; + nlohmann::json cached_config_; static void json(httplib::Response& response, const HttpResult& result); static HttpResult error(const std::string& code, const std::string& message, int status, bool retryable = false); diff --git a/libs/http/src/http_server.cpp b/libs/http/src/http_server.cpp index 62bad3de..7ba725ad 100644 --- a/libs/http/src/http_server.cpp +++ b/libs/http/src/http_server.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include namespace merak { @@ -15,8 +17,24 @@ DelegationRequest delegation_request_from_json(const nlohmann::json&body){ } } HttpServer::HttpServer(std::shared_ptrruntime,RuntimeMetadata metadata, - std::string merak_home):runtime_(std::move(runtime)),metadata_(std::move(metadata)), - merak_home_path_(std::move(merak_home)){install_routes();} + std::string merak_home,std::shared_ptrllm_provider) + :runtime_(std::move(runtime)),metadata_(std::move(metadata)), + merak_home_path_(std::move(merak_home)),llm_provider_(std::move(llm_provider)){ + auto cfg_result=ConfigLoader::load(); + if(cfg_result.has_value()){ + auto&cfg=cfg_result.value(); + cached_config_["provider"]=cfg.llm.provider; + cached_config_["api_base_url"]=cfg.llm.api_base_url; + cached_config_["default_model"]=cfg.llm.default_model; + cached_config_["max_output_tokens"]=cfg.llm.max_output_tokens; + const auto&key=cfg.llm.api_key; + if(key.length()>4){ + cached_config_["api_key_masked"]=std::string(key.length()-4,'*')+key.substr(key.length()-4); + }else{ + cached_config_["api_key_masked"]=key.empty()?"":"****"; + } + } + install_routes();} HttpResult HttpServer::error(const std::string&code,const std::string&message,int status,bool retryable){return{status,{{"error",{{"code",code},{"message",message},{"retryable",retryable}}}}};} void HttpServer::json(httplib::Response&r,const HttpResult&v){r.status=v.status;r.set_content(v.body.dump(),"application/json");} HttpResult HttpServer::handle_runtime_metadata()const{nlohmann::json tools=nlohmann::json::array(),mcp=nlohmann::json::array(),agents=nlohmann::json::array(),models=nlohmann::json::array();for(const auto&t:metadata_.tools)tools.push_back({{"name",t.name},{"description",t.description},{"source",t.source}});for(const auto&s:metadata_.mcp_servers)mcp.push_back({{"name",s.name},{"alive",s.alive}});for(const auto&a:metadata_.agents)agents.push_back({{"id",a.id},{"description",a.description}});for(const auto&m:metadata_.models)models.push_back({{"name",m.name},{"provider",m.provider},{"max_context_tokens",m.max_context_tokens}});if(models.empty())models.push_back({{"name",metadata_.model},{"provider",metadata_.provider},{"max_context_tokens",128000}});return{200,{{"provider",metadata_.provider},{"model",metadata_.model},{"models",models},{"permission_mode",metadata_.permission_mode},{"memory",{{"enabled",metadata_.memory_enabled}}},{"tools",tools},{"mcp_servers",mcp},{"agents",agents},{"delegation_patterns",{"fan_out","sequential","pipeline"}}}};} @@ -38,6 +56,7 @@ void HttpServer::install_routes(){ server_.Get(R"(/v1/sessions/([^/]+)/events/stream)",[this](const auto&req,auto&r){auto id=req.matches[1].str();auto cursor=after(req);try{auto subscription=runtime_->subscribe(id);auto backlog=runtime_->events_after(id,cursor);r.set_chunked_content_provider("text/event-stream",[subscription,backlog=std::move(backlog),cursor](size_t,httplib::DataSink&sink)mutable{auto send=[&](const RuntimeEvent&e){if(e.seq<=cursor||e.type=="message_appended"||e.type=="compaction_applied")return true;auto payload=nlohmann::json(e).dump();auto frame="id: "+std::to_string(e.seq)+"\nevent: "+e.type+"\ndata: "+payload+"\n\n";if(!sink.write(frame.data(),frame.size()))return false;cursor=e.seq;return true;};while(!backlog.empty()){auto e=backlog.front();backlog.erase(backlog.begin());if(!send(e))return false;}RuntimeEvent live;if(subscription->wait_next(live,std::chrono::milliseconds(1000)))return send(live);auto ping=std::string(": keepalive\n\n");return sink.write(ping.data(),ping.size());});}catch(const RuntimeError&e){json(r,error(e.code(),e.what(),404));}}); server_.Get("/api/config/llm", [this](const auto& req, auto& res) { handle_config_get(req, res); }); server_.Post("/api/config/llm", [this](const auto& req, auto& res) { handle_config_set(req, res); }); + server_.Post("/api/config/llm/test", [this](const auto& req, auto& res) { handle_config_test(req, res); }); } void HttpServer::listen(int port){server_.listen("127.0.0.1",port);} void HttpServer::stop(){server_.stop();} @@ -45,24 +64,11 @@ void HttpServer::serve_static_dir(const std::string& mount_point, const std::str server_.set_mount_point(mount_point.c_str(), dir_path.c_str()); } void HttpServer::handle_config_get(const httplib::Request&, httplib::Response& res) { - auto cfg_result = ConfigLoader::load(); - if (!cfg_result.has_value()) { - json(res, error("config_load_failed", cfg_result.error().what(), 500)); + if (cached_config_.is_null()) { + json(res, error("config_load_failed", "no config loaded", 500)); return; } - auto& cfg = cfg_result.value(); - nlohmann::json body; - body["provider"] = cfg.llm.provider; - body["api_base_url"] = cfg.llm.api_base_url; - body["default_model"] = cfg.llm.default_model; - body["max_output_tokens"] = cfg.llm.max_output_tokens; - const auto& key = cfg.llm.api_key; - if (key.length() > 4) { - body["api_key_masked"] = std::string(key.length() - 4, '*') + key.substr(key.length() - 4); - } else { - body["api_key_masked"] = key.empty() ? "" : "****"; - } - res.set_content(body.dump(), "application/json"); + res.set_content(cached_config_.dump(), "application/json"); } void HttpServer::handle_config_set(const httplib::Request& req, httplib::Response& res) { try { @@ -89,9 +95,28 @@ void HttpServer::handle_config_set(const httplib::Request& req, httplib::Respons std::ofstream out(local_path); out << existing.dump(2); - res.set_content("{\"ok\":true}", "application/json"); + nlohmann::json resp; + resp["ok"]=true; + resp["restart_required"]=true; + res.set_content(resp.dump(), "application/json"); } catch (const std::exception& e) { json(res, error("config_save_failed", e.what(), 400)); } } +void HttpServer::handle_config_test(const httplib::Request& req, httplib::Response& res) { + if (!llm_provider_) { + json(res, error("test_unavailable", "LLM provider not available", 503)); + return; + } + try { + auto test_result = llm_provider_->test_connection(); + if (test_result) { + res.set_content("{\"ok\":true,\"test\":\"passed\"}", "application/json"); + } else { + json(res, error("test_failed", "LLM connection test failed", 502)); + } + } catch (const std::exception& e) { + json(res, error("test_failed", e.what(), 502)); + } +} } // namespace merak diff --git a/libs/llm/include/merak/anthropic_provider.hpp b/libs/llm/include/merak/anthropic_provider.hpp index ed933e66..a23fb434 100644 --- a/libs/llm/include/merak/anthropic_provider.hpp +++ b/libs/llm/include/merak/anthropic_provider.hpp @@ -20,6 +20,7 @@ class AnthropicProvider : public LlmProvider { std::string name() const override { return "anthropic"; } bool supports_caching() const override { return true; } + bool test_connection() override; const CacheStats& cache_stats() const { return stats_; } private: diff --git a/libs/llm/include/merak/llm_provider.hpp b/libs/llm/include/merak/llm_provider.hpp index 7e82c6e8..7375a343 100644 --- a/libs/llm/include/merak/llm_provider.hpp +++ b/libs/llm/include/merak/llm_provider.hpp @@ -40,6 +40,7 @@ class LlmProvider { virtual std::string name() const = 0; virtual bool supports_caching() const { return false; } + virtual bool test_connection() { return false; } }; struct CacheStats { diff --git a/libs/llm/include/merak/openai_provider.hpp b/libs/llm/include/merak/openai_provider.hpp index cc85352f..9c1a2d10 100644 --- a/libs/llm/include/merak/openai_provider.hpp +++ b/libs/llm/include/merak/openai_provider.hpp @@ -20,6 +20,7 @@ class OpenAIProvider : public LlmProvider { std::string name() const override { return "openai"; } bool supports_caching() const override { return true; } + bool test_connection() override; const CacheStats& cache_stats() const { return stats_; } private: diff --git a/libs/llm/src/anthropic_provider.cpp b/libs/llm/src/anthropic_provider.cpp index 5715a7a3..a179ab3e 100644 --- a/libs/llm/src/anthropic_provider.cpp +++ b/libs/llm/src/anthropic_provider.cpp @@ -309,4 +309,27 @@ std::future AnthropicProvider::chat( }); } +bool AnthropicProvider::test_connection() { + std::string url = "https://api.anthropic.com/v1/messages"; + std::string body_str = R"({"model":")" + config_.default_model + + R"(","max_tokens":1,"messages":[{"role":"user","content":"Hi"}]})"; + CURL* curl = curl_easy_init(); + if (!curl) return false; + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, ("x-api-key: " + config_.api_key).c_str()); + headers = curl_slist_append(headers, "anthropic-version: 2023-06-01"); + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body_str.c_str()); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 10000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + long http_code = 0; + CURLcode res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return res == CURLE_OK && http_code >= 200 && http_code < 300; +} + } // namespace merak diff --git a/libs/llm/src/openai_provider.cpp b/libs/llm/src/openai_provider.cpp index a5615923..d469952b 100644 --- a/libs/llm/src/openai_provider.cpp +++ b/libs/llm/src/openai_provider.cpp @@ -215,4 +215,26 @@ nlohmann::json OpenAIProvider::build_tools(const std::vector& tools) c return arr; } +bool OpenAIProvider::test_connection() { + std::string url = config_.api_base_url + "/chat/completions"; + std::string body_str = R"({"model":")" + config_.default_model + + R"(","max_tokens":1,"messages":[{"role":"user","content":"Hi"}]})"; + CURL* curl = curl_easy_init(); + if (!curl) return false; + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, ("Authorization: Bearer " + config_.api_key).c_str()); + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body_str.c_str()); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 10000L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + long http_code = 0; + CURLcode res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return res == CURLE_OK && http_code >= 200 && http_code < 300; +} + } // namespace merak diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index 55e5f275..4cbe4a61 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -299,8 +299,8 @@ void AgentLoop::maybe_compact(RunControl& control) { double pressure = (double)total_tokens / budget; int compacted = tool_result_compactor_->compact(session_history_, pressure); if (compacted > 0) { - spdlog::info("Loop: microcompact compressed {} tool results (pressure={:.1%})", - compacted, pressure); + spdlog::info("Loop: microcompact compressed {} tool results (pressure={:.1f}%)", + compacted, pressure * 100); } auto compaction_info = context_->analyze_compaction(session_history_); diff --git a/libs/portable_pg/include/merak/portable_pg.hpp b/libs/portable_pg/include/merak/portable_pg.hpp index ab60542c..2a27a0cd 100644 --- a/libs/portable_pg/include/merak/portable_pg.hpp +++ b/libs/portable_pg/include/merak/portable_pg.hpp @@ -37,6 +37,7 @@ class PortablePg { std::filesystem::path data_dir() const { return pg_dir_ / "data"; } bool initdb(); bool start_server(); + bool wait_ready(int timeout_seconds = 30); bool create_database(); int find_free_port(); bool is_port_in_use(int port) const; diff --git a/libs/portable_pg/src/portable_pg.cpp b/libs/portable_pg/src/portable_pg.cpp index 8c65e644..45dcacdf 100644 --- a/libs/portable_pg/src/portable_pg.cpp +++ b/libs/portable_pg/src/portable_pg.cpp @@ -106,6 +106,22 @@ bool PortablePg::start_server() { return true; } +bool PortablePg::wait_ready(int timeout_seconds) { + for (int i = 0; i < timeout_seconds * 2; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock == INVALID_SOCKET) continue; + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(port_)); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + int result = connect(sock, reinterpret_cast(&addr), sizeof(addr)); + closesocket(sock); + if (result != SOCKET_ERROR) return true; + } + return false; +} + bool PortablePg::create_database() { auto psql_path = bin_dir() / "psql"; #ifdef _WIN32 @@ -135,6 +151,10 @@ bool PortablePg::start() { std::cerr << "PortablePg: pg_ctl start failed\n"; return false; } + if (!wait_ready()) { + std::cerr << "PortablePg: timed out waiting for PostgreSQL to accept connections\n"; + return false; + } create_database(); return true; } @@ -153,7 +173,10 @@ void PortablePg::stop() { #else << " -m fast > /dev/null 2>&1"; #endif - std::system(cmd.str().c_str()); + int ret = std::system(cmd.str().c_str()); + if (ret != 0) { + std::cerr << "PortablePg: pg_ctl stop returned " << ret << " (postgres may still be running)\n"; + } running_ = false; } diff --git a/libs/runtime/src/runtime_service.cpp b/libs/runtime/src/runtime_service.cpp index 0aeec28c..16fdd8df 100644 --- a/libs/runtime/src/runtime_service.cpp +++ b/libs/runtime/src/runtime_service.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include diff --git a/libs/tools/src/tool_registry.cpp b/libs/tools/src/tool_registry.cpp index 3a65c10b..9e251011 100644 --- a/libs/tools/src/tool_registry.cpp +++ b/libs/tools/src/tool_registry.cpp @@ -246,7 +246,7 @@ std::vector ToolRegistry::pinned_schemas() const { std::string ToolRegistry::search_tools(const std::string& query, size_t max_results) const { struct ScoredMeta { - const tools::ToolMeta* meta; + const ToolMeta* meta; int score; }; std::vector scored; @@ -298,7 +298,7 @@ std::string ToolRegistry::search_tools(const std::string& query, size_t max_resu } std::string ToolRegistry::select_tool(const std::string& name) const { - const tools::ToolMeta* meta = tools::find_meta(name); + const ToolMeta* meta = tools::find_meta(name); if (!meta) { return nlohmann::json{{"error", "tool not found: " + name}}.dump(); } diff --git a/libs/tools/src/tool_search_tool.cpp b/libs/tools/src/tool_search_tool.cpp index fc3fc959..8a7e9a08 100644 --- a/libs/tools/src/tool_search_tool.cpp +++ b/libs/tools/src/tool_search_tool.cpp @@ -18,7 +18,7 @@ ToolSpec ToolSearchTool::spec() const { "- Multi-select: {\"query\": \"select:tool_a,tool_b\"} -- returns full schemas for multiple tools"; s.source = "builtin"; s.category = Category::Consultative; - s.parameters_json = R"({ + s.parameters_json = R"JSON({ "type": "object", "properties": { "query": { @@ -31,7 +31,7 @@ ToolSpec ToolSearchTool::spec() const { } }, "required": ["query"] - })"; + })JSON"; return s; } diff --git a/libs/worldbuilding/CMakeLists.txt b/libs/worldbuilding/CMakeLists.txt index afda6a43..df7c42dc 100644 --- a/libs/worldbuilding/CMakeLists.txt +++ b/libs/worldbuilding/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(merak-worldbuilding STATIC src/narrative_store.cpp src/secret_store.cpp src/pg_helpers.cpp + src/sqlite_helpers.cpp src/world_store.cpp src/voice_analyzer.cpp src/scene_orchestrator.cpp @@ -23,6 +24,7 @@ target_link_libraries(merak-worldbuilding PUBLIC merak-tools nlohmann_json::nlohmann_json ${PostgreSQL_LIBRARIES} + SQLite::SQLite3 ) add_executable(worldbuilding_tests diff --git a/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp b/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp index ae529547..4439150b 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp @@ -18,6 +18,7 @@ class AgentStore { public: AgentStore(WorldStore& worlds, std::string_view pg_conninfo, std::filesystem::path data_root); + ~AgentStore(); AgentRecord create_manager(const std::string& world_id, AgentKind kind, std::string name, std::string instructions); diff --git a/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp b/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp index 7cd55fd4..5f76e3c4 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp @@ -16,6 +16,7 @@ class PgPool; class WorldStore { public: WorldStore(std::string_view pg_conninfo, std::filesystem::path data_root); + ~WorldStore(); void initialize(); WorldMeta create_world(const std::string& name, diff --git a/libs/worldbuilding/src/agent_store.cpp b/libs/worldbuilding/src/agent_store.cpp index 42ac439a..52e4f106 100644 --- a/libs/worldbuilding/src/agent_store.cpp +++ b/libs/worldbuilding/src/agent_store.cpp @@ -15,6 +15,9 @@ #include namespace merak::worldbuilding { + +AgentStore::~AgentStore() = default; + namespace { AgentKind agent_kind_from_string(const std::string& value) { diff --git a/libs/worldbuilding/src/world_store.cpp b/libs/worldbuilding/src/world_store.cpp index ba95474a..e956380b 100644 --- a/libs/worldbuilding/src/world_store.cpp +++ b/libs/worldbuilding/src/world_store.cpp @@ -76,6 +76,8 @@ WorldStore::WorldStore(std::string_view pg_conninfo, std::filesystem::path data_ : data_root_(std::move(data_root)), pool_(std::make_unique(pg_conninfo)) {} +WorldStore::~WorldStore() = default; + void WorldStore::initialize() { std::filesystem::create_directories(data_root_); std::filesystem::create_directories(data_root_ / "worlds"); diff --git a/libs/worldbuilding/tests/test_agent_store.cpp b/libs/worldbuilding/tests/test_agent_store.cpp index d0badb8e..cd7ff021 100644 --- a/libs/worldbuilding/tests/test_agent_store.cpp +++ b/libs/worldbuilding/tests/test_agent_store.cpp @@ -15,7 +15,7 @@ #include using namespace merak::worldbuilding; - +using namespace merak::worldbuilding::test; namespace { std::filesystem::path temp_dir() { diff --git a/libs/worldbuilding/tests/test_foreshadowing_secret.cpp b/libs/worldbuilding/tests/test_foreshadowing_secret.cpp index 338e2bf0..d5e21ece 100644 --- a/libs/worldbuilding/tests/test_foreshadowing_secret.cpp +++ b/libs/worldbuilding/tests/test_foreshadowing_secret.cpp @@ -15,6 +15,7 @@ #include using namespace merak::worldbuilding; +using namespace merak::worldbuilding::test; namespace { diff --git a/libs/worldbuilding/tests/test_narrative_store.cpp b/libs/worldbuilding/tests/test_narrative_store.cpp index a210d58f..13a4ee68 100644 --- a/libs/worldbuilding/tests/test_narrative_store.cpp +++ b/libs/worldbuilding/tests/test_narrative_store.cpp @@ -13,6 +13,7 @@ #include using namespace merak::worldbuilding; +using namespace merak::worldbuilding::test; namespace { diff --git a/libs/worldbuilding/tests/test_scene_orchestrator.cpp b/libs/worldbuilding/tests/test_scene_orchestrator.cpp index 28c1649f..64dd4231 100644 --- a/libs/worldbuilding/tests/test_scene_orchestrator.cpp +++ b/libs/worldbuilding/tests/test_scene_orchestrator.cpp @@ -17,6 +17,7 @@ #include using namespace merak::worldbuilding; +using namespace merak::worldbuilding::test; namespace { diff --git a/libs/worldbuilding/tests/test_world_store.cpp b/libs/worldbuilding/tests/test_world_store.cpp index 3a18569f..97c53424 100644 --- a/libs/worldbuilding/tests/test_world_store.cpp +++ b/libs/worldbuilding/tests/test_world_store.cpp @@ -12,6 +12,7 @@ #include using namespace merak::worldbuilding; +using namespace merak::worldbuilding::test; namespace { diff --git a/libs/worldbuilding/tests/test_worldbuilding_e2e.cpp b/libs/worldbuilding/tests/test_worldbuilding_e2e.cpp index 4a5cd091..a016b93c 100644 --- a/libs/worldbuilding/tests/test_worldbuilding_e2e.cpp +++ b/libs/worldbuilding/tests/test_worldbuilding_e2e.cpp @@ -1,4 +1,5 @@ #include +#include #include #include "test_helpers.hpp" @@ -8,6 +9,7 @@ #include using namespace merak::worldbuilding; +using namespace merak::worldbuilding::test; namespace { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7c1332e5..67a8f51b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,9 @@ add_executable(merak-tui-features-test ${CMAKE_SOURCE_DIR}/cli/src/theme/theme.cpp ${CMAKE_SOURCE_DIR}/cli/src/commands/command_registry.cpp ) -target_include_directories(merak-tui-features-test PRIVATE ${CMAKE_SOURCE_DIR}/cli/src) -target_link_libraries(merak-tui-features-test PRIVATE merak-core ftxui::component) +target_include_directories(merak-tui-features-test PRIVATE + ${CMAKE_SOURCE_DIR}/cli/src + ${tree-sitter_SOURCE_DIR}/lib/include +) +target_link_libraries(merak-tui-features-test PRIVATE merak-core ftxui::component tree-sitter) add_test(NAME merak-tui-features-test COMMAND merak-tui-features-test) diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index b4a610da..a713c4f8 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -67,4 +67,6 @@ export const api = { default_model?: string; max_output_tokens?: number; }) => request('POST', '/api/config/llm', config), + + testConfig: () => request('POST', '/api/config/llm/test'), }; diff --git a/webui/src/components/Sidebar/SettingsPanel.tsx b/webui/src/components/Sidebar/SettingsPanel.tsx index e9f4f8bb..3f0bfadb 100644 --- a/webui/src/components/Sidebar/SettingsPanel.tsx +++ b/webui/src/components/Sidebar/SettingsPanel.tsx @@ -19,7 +19,7 @@ export default function SettingsPanel() { const [showKey, setShowKey] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); - const [status, setStatus] = useState<'idle' | 'saved' | 'tested' | 'error'>('idle'); + const [status, setStatus] = useState<'idle' | 'saved' | 'test_ok' | 'test_fail' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(''); useEffect(() => { @@ -55,13 +55,11 @@ export default function SettingsPanel() { setTesting(true); setStatus('idle'); try { - const res = await api.metadata(); - if (res.provider) { - setStatus('tested'); - } - } catch (e) { - setStatus('error'); - setErrorMsg(e instanceof Error ? e.message : 'Connection test failed'); + await api.testConfig(); + setStatus('test_ok'); + } catch { + setStatus('test_fail'); + setErrorMsg('LLM connection test failed — check your API key and network.'); } finally { setTesting(false); } @@ -129,8 +127,13 @@ export default function SettingsPanel() { - {status === 'saved' &&
Configuration saved.
} - {status === 'tested' &&
Connection successful.
} + {status === 'saved' && ( +
Configuration saved. Restart server to apply changes.
+ )} + {status === 'test_ok' && ( +
LLM connection test passed.
+ )} + {status === 'test_fail' &&
{errorMsg}
} {status === 'error' &&
{errorMsg}
} ); From d4d23aa1ba188454c1b5e3bc2201d009ea347f41 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 5 Jun 2026 14:11:31 +0000 Subject: [PATCH 10/10] fix: don't trigger release workflow on PR events The release workflow now only runs on tag pushes (v*), not on every PR to main. --- .github/workflows/release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 117c64b1..2872cd9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,6 @@ name: Release on: push: tags: ['v*'] - pull_request: - branches: [main] jobs: build-linux: