From 247ea9101b5fc384e2289eb785c26ebffb8aad12 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 06:37:24 +0000 Subject: [PATCH 01/33] feat: add optional kind filter to WorldStore::list_agents --- .../include/merak/worldbuilding/world_store.hpp | 3 ++- libs/worldbuilding/src/world_store.cpp | 17 +++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp b/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp index eedc8898..d8bc2e51 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/world_store.hpp @@ -75,7 +75,8 @@ class WorldStore { const std::string& target_type, const std::string& target_id); nlohmann::json list_file_links(const std::string& world_id) const; - std::vector list_agents(const std::string& world_id) const; + std::vector list_agents(const std::string& world_id, + const std::optional& kind = std::nullopt) const; std::filesystem::path world_path(const std::string& world_id) const; private: diff --git a/libs/worldbuilding/src/world_store.cpp b/libs/worldbuilding/src/world_store.cpp index a291b99f..5f1dce9e 100644 --- a/libs/worldbuilding/src/world_store.cpp +++ b/libs/worldbuilding/src/world_store.cpp @@ -454,13 +454,18 @@ WorldStore::list_locations(const std::string& world_id) const { } std::vector -WorldStore::list_agents(const std::string& world_id) const { +WorldStore::list_agents(const std::string& world_id, + const std::optional& kind) const { PgConn conn(*pool_); - auto res = conn.query( - "SELECT id, world_id, name, display_name, kind, created_at, updated_at " - "FROM agents WHERE world_id = $1 " - "ORDER BY created_at ASC, id ASC", - {world_id}); + std::string sql = "SELECT id, world_id, name, display_name, kind, created_at, updated_at " + "FROM agents WHERE world_id = $1"; + std::vector params = {world_id}; + if (kind.has_value()) { + sql += " AND kind = $2"; + params.push_back(*kind); + } + sql += " ORDER BY created_at ASC, id ASC"; + auto res = conn.query(sql, params); std::vector agents; for (int i = 0; i < res.ntuples(); i++) { From 27e6e4e2d15c9b28d4d7a0addf95aa91ac90e2e6 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 06:47:44 +0000 Subject: [PATCH 02/33] feat: replace search_agents_by_traits with generalized search_agents --- .../merak/worldbuilding/agent_store.hpp | 12 +++-- libs/worldbuilding/src/agent_store.cpp | 44 ++++++++++--------- .../worldbuilding/src/worldbuilding_tools.cpp | 14 +++--- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp b/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp index ffe391dc..13746148 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp @@ -61,11 +61,15 @@ class AgentStore { int uncompressed_diary_count(const std::string& agent_id) const; std::vector uncompressed_diaries(const std::string& agent_id, int limit) const; + struct SearchCriteria { + std::string q; // full-text: name, display_name, background + std::vector traits; // match core_traits + std::string identity; // match identity + std::string race; // match race + int max_results = 50; + }; std::vector - search_agents_by_traits(const std::string& world_id, - const std::vector& traits, - const std::string& identity = "", - int max_results = 20) const; + search_agents(const std::string& world_id, const SearchCriteria& criteria) const; void update_agent_prompt(const std::string& agent_id, std::string prompt); std::string load_agent_prompt(const std::string& agent_id) const; diff --git a/libs/worldbuilding/src/agent_store.cpp b/libs/worldbuilding/src/agent_store.cpp index 17b9ead5..5de5b423 100644 --- a/libs/worldbuilding/src/agent_store.cpp +++ b/libs/worldbuilding/src/agent_store.cpp @@ -1033,10 +1033,7 @@ AgentStore::uncompressed_diaries(const std::string& agent_id, int limit) const { } std::vector -AgentStore::search_agents_by_traits(const std::string& world_id, - const std::vector& traits, - const std::string& identity, - int max_results) const { +AgentStore::search_agents(const std::string& world_id, const SearchCriteria& criteria) const { PgConn conn(*pool_); std::vector params; @@ -1049,29 +1046,34 @@ AgentStore::search_agents_by_traits(const std::string& world_id, int param_idx = 2; params.push_back(world_id); - if (!identity.empty()) { + if (!criteria.q.empty()) { + sql << " AND (a.name LIKE $" << param_idx + << " OR a.display_name LIKE $" << param_idx + 1 + << " OR c.background LIKE $" << param_idx + 2 << ")"; + params.push_back("%" + criteria.q + "%"); + params.push_back("%" + criteria.q + "%"); + params.push_back("%" + criteria.q + "%"); + param_idx += 3; + } + + if (!criteria.identity.empty()) { sql << " AND c.identity LIKE $" << param_idx++; - params.push_back("%" + identity + "%"); + params.push_back("%" + criteria.identity + "%"); } - if (!traits.empty()) { - for (const auto& trait : traits) { - sql << " AND (" - << " $" << param_idx << " = ANY(c.core_traits)" - << " OR c.background LIKE $" << param_idx + 1 - << " OR c.knowledge_scope LIKE $" << param_idx + 2 - << " OR c.appearance LIKE $" << param_idx + 3 - << ")"; - params.push_back(trait); - params.push_back("%" + trait + "%"); - params.push_back("%" + trait + "%"); - params.push_back("%" + trait + "%"); - param_idx += 4; - } + if (!criteria.race.empty()) { + sql << " AND c.race LIKE $" << param_idx++; + params.push_back("%" + criteria.race + "%"); + } + + for (const auto& trait : criteria.traits) { + sql << " AND $" << param_idx << " = ANY(c.core_traits)"; + params.push_back(trait); + param_idx++; } sql << " ORDER BY a.created_at ASC LIMIT $" << param_idx; - params.push_back(std::to_string(std::clamp(max_results, 0, 100))); + params.push_back(std::to_string(std::clamp(criteria.max_results, 0, 100))); auto res = conn.query(sql.str(), params); diff --git a/libs/worldbuilding/src/worldbuilding_tools.cpp b/libs/worldbuilding/src/worldbuilding_tools.cpp index f69a6a33..270b557c 100644 --- a/libs/worldbuilding/src/worldbuilding_tools.cpp +++ b/libs/worldbuilding/src/worldbuilding_tools.cpp @@ -1947,21 +1947,23 @@ std::future SearchAgentTool::execute(ToolCall call, ToolExecutionCon try { auto args = json::parse(call.arguments); - std::vector traits; + AgentStore::SearchCriteria criteria; + criteria.q = args.value("q", ""); if (args.contains("traits") && args["traits"].is_array()) { - for (auto& t : args["traits"]) traits.push_back(t.get()); + for (auto& t : args["traits"]) criteria.traits.push_back(t.get()); } - std::string identity = args.value("identity", ""); + criteria.identity = args.value("identity", ""); + criteria.race = args.value("race", ""); - if (traits.empty() && identity.empty()) { + if (criteria.q.empty() && criteria.traits.empty() && criteria.identity.empty() && criteria.race.empty()) { result.output = error_response(ToolErrorCode::INVALID_ARGUMENT, - "请至少提供 traits 或 identity 中的一个搜索条件。例如:search_agent(traits=[\"剑术\"])"); + "请至少提供 q、traits、identity 或 race 中的一个搜索条件。例如:search_agent(traits=[\"剑术\"])"); return result; } auto& svc = *static_cast(*self).svc_; - auto agents = svc.agents().search_agents_by_traits(exec_ctx.world_id, traits, identity); + auto agents = svc.agents().search_agents(exec_ctx.world_id, criteria); if (agents.empty()) { result.output = error_response(ToolErrorCode::EMPTY_RESULT, From 4ecef8b8d9bfa76fc875498bde98d166881dcf5b Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 06:54:40 +0000 Subject: [PATCH 03/33] fix: add q and race to SearchAgentTool parameter schema and descriptions --- libs/worldbuilding/src/worldbuilding_tools.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/libs/worldbuilding/src/worldbuilding_tools.cpp b/libs/worldbuilding/src/worldbuilding_tools.cpp index 270b557c..c2e0bbcf 100644 --- a/libs/worldbuilding/src/worldbuilding_tools.cpp +++ b/libs/worldbuilding/src/worldbuilding_tools.cpp @@ -1915,28 +1915,30 @@ std::future EndSceneTool::execute(ToolCall call, ToolExecutionContex ToolSpec SearchAgentTool::spec() const { ToolSpec s; s.name = "search_agent"; - s.description = R"(Search for characters by traits and/or identity. Returns matching agents with their basic info. Example: search_agent(traits=["剑术"], identity="骑士") or search_agent(traits=["勇敢","善良"]))"; + s.description = R"(Search for characters by full-text query, traits, identity, and/or race. Returns matching agents with their basic info. Example: search_agent(q="龙骑士", traits=["剑术"], identity="骑士") or search_agent(race="精灵", traits=["勇敢","善良"]))"; s.source = "builtin"; - s.parameters_json = R"({ + s.parameters_json = R"json({ "type": "object", "properties": { + "q": {"type": "string", "description": "Full-text search across name, display name, and background"}, "traits": {"type": "array", "items": {"type": "string"}, "description": "Traits to search for"}, - "identity": {"type": "string", "description": "Identity keyword to filter by"} + "identity": {"type": "string", "description": "Identity keyword to filter by"}, + "race": {"type": "string", "description": "Filter agents by race (e.g., 精灵, 人类, 兽人)"} }, "required": [] - })"; + })json"; return s; } ToolMeta SearchAgentTool::meta() const { ToolMeta m; m.name = "search_agent"; - m.description = "Search for characters by traits or identity"; + m.description = "Search for characters by full-text query, traits, identity, or race"; m.triggers = {"agent lookup", "find character", "search"}; m.pinned = false; m.intents = {IntentType::DomainRead}; m.scope = Scope::Local; - m.schema_tokens = 25; + m.schema_tokens = 45; return m; } From c7eb94d409cd0ba88c620098a71d8a2701c5d9b2 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:00:18 +0000 Subject: [PATCH 04/33] feat: add start_agent_run helper with result caching infrastructure --- .../merak/worldbuilding_http_handler.hpp | 13 +++++ libs/http/src/worldbuilding_http_handler.cpp | 52 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 94118be2..c3ca54f9 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace merak { @@ -103,6 +104,18 @@ class WorldbuildingHttpHandler { void handle_get_pending_creation(const httplib::Request&, httplib::Response&); void handle_resolve_creation(const httplib::Request&, httplib::Response&); + // Agent-driven generation infrastructure + struct PendingAgentRun { + std::string world_id; + std::string operation_type; + }; + void start_agent_run(const httplib::Request& req, httplib::Response& res, + const std::string& world_id, const std::string& task_description, + const std::string& operation_type); + void capture_agent_result(const std::string& run_id); + + std::unordered_map pending_agent_runs_; + // Agent prompt void handle_load_agent_prompt(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 1a609f0b..2de8009f 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -177,6 +177,58 @@ nlohmann::json image_json(const ImageRecord& img) { } // namespace +void WorldbuildingHttpHandler::start_agent_run( + const httplib::Request& req, httplib::Response& res, + const std::string& world_id, const std::string& task_description, + const std::string& operation_type) { + if (!runtime_) { + error_response(res, "Runtime service not available", 503, "runtime_unavailable"); + return; + } + try { + auto session = runtime_->create_session("", world_id, "god_agent"); + runtime_->set_session_ephemeral(session.id, 30); + auto run = runtime_->start_run(session.id, task_description, ""); + + pending_agent_runs_[run.id] = {world_id, operation_type}; + + json_response(res, { + {"ok", true}, + {"session_id", session.id}, + {"run_id", run.id} + }, 202); + } catch (const RuntimeError& e) { + int status = e.code() == "session_busy" ? 409 : 400; + error_response(res, e.what(), status, e.code()); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::capture_agent_result(const std::string& run_id) { + auto it = pending_agent_runs_.find(run_id); + if (it == pending_agent_runs_.end()) return; + try { + auto run = runtime_->get_run(run_id); + if (!run || run->status != RunStatus::Completed) return; + + nlohmann::json messages = nlohmann::json::array(); + for (const auto& e : runtime_->events_after(run->session_id, 0)) { + if (e.run_id == run_id && e.type == "message_appended") { + messages.push_back({ + {"role", e.payload.value("role", "")}, + {"content", e.payload.value("content", "")} + }); + } + } + service_->worlds().store_agent_result(it->second.world_id, + it->second.operation_type, messages); + } catch (...) { + // Best-effort caching; don't crash + } + pending_agent_runs_.erase(it); +} + WorldbuildingHttpHandler::WorldbuildingHttpHandler( std::shared_ptr service, std::shared_ptr runtime) From 0e70ce64b8f519f75138369f04e8a7b37d9ff813 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:05:55 +0000 Subject: [PATCH 05/33] fix: add mutex to pending_agent_runs_ and always clean up entries - Add std::mutex to protect pending_agent_runs_ from concurrent access across HTTP threads, eliminating data race in start_agent_run and capture_agent_result. - In capture_agent_result, always erase the map entry regardless of run completion status, preventing unbounded memory growth from abandoned runs. --- .../merak/worldbuilding_http_handler.hpp | 2 ++ libs/http/src/worldbuilding_http_handler.cpp | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index c3ca54f9..139ff046 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -115,6 +116,7 @@ class WorldbuildingHttpHandler { void capture_agent_result(const std::string& run_id); std::unordered_map pending_agent_runs_; + mutable std::mutex pending_runs_mutex_; // Agent prompt void handle_load_agent_prompt(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 2de8009f..d69becbd 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -190,7 +190,10 @@ void WorldbuildingHttpHandler::start_agent_run( runtime_->set_session_ephemeral(session.id, 30); auto run = runtime_->start_run(session.id, task_description, ""); - pending_agent_runs_[run.id] = {world_id, operation_type}; + { + std::lock_guard lock(pending_runs_mutex_); + pending_agent_runs_[run.id] = {world_id, operation_type}; + } json_response(res, { {"ok", true}, @@ -206,8 +209,14 @@ void WorldbuildingHttpHandler::start_agent_run( } void WorldbuildingHttpHandler::capture_agent_result(const std::string& run_id) { - auto it = pending_agent_runs_.find(run_id); - if (it == pending_agent_runs_.end()) return; + PendingAgentRun pending; + { + std::lock_guard lock(pending_runs_mutex_); + auto it = pending_agent_runs_.find(run_id); + if (it == pending_agent_runs_.end()) return; + pending = it->second; + pending_agent_runs_.erase(it); + } try { auto run = runtime_->get_run(run_id); if (!run || run->status != RunStatus::Completed) return; @@ -221,12 +230,11 @@ void WorldbuildingHttpHandler::capture_agent_result(const std::string& run_id) { }); } } - service_->worlds().store_agent_result(it->second.world_id, - it->second.operation_type, messages); + service_->worlds().store_agent_result(pending.world_id, + pending.operation_type, messages); } catch (...) { // Best-effort caching; don't crash } - pending_agent_runs_.erase(it); } WorldbuildingHttpHandler::WorldbuildingHttpHandler( From 4db94b56168343d7f9ac8a4e8bae76b50f0f0fe6 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:10:42 +0000 Subject: [PATCH 06/33] feat: add agent-driven suggestions endpoints (POST + GET) --- .../merak/worldbuilding_http_handler.hpp | 4 +++ libs/http/src/worldbuilding_http_handler.cpp | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 139ff046..74199a85 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -115,6 +115,10 @@ class WorldbuildingHttpHandler { const std::string& operation_type); void capture_agent_result(const std::string& run_id); + // Agent-driven: suggestions + void handle_start_suggestions(const httplib::Request&, httplib::Response&); + void handle_get_suggestions(const httplib::Request&, httplib::Response&); + std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index d69becbd..cd776825 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -435,6 +435,12 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Post(R"(/api/worldbuilding/([^/]+)/creations/([^/]+)/resolve)", [this](const auto& req, auto& res) { handle_resolve_creation(req, res); }); + // ─── Agent-driven generation ─── + server.Post(R"(/api/worldbuilding/([^/]+)/suggestions)", + [this](const auto& req, auto& res) { handle_start_suggestions(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/suggestions)", + [this](const auto& req, auto& res) { handle_get_suggestions(req, res); }); + // ─── Pipeline endpoints ─── server.Get(R"(/api/worldbuilding/([^/]+)/pipeline/state)", [this](const auto& req, auto& res) { @@ -2413,4 +2419,32 @@ void WorldbuildingHttpHandler::handle_resolve_creation(const httplib::Request& r } } +// --- Agent-driven: suggestions --- + +void WorldbuildingHttpHandler::handle_start_suggestions(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + std::string category = body.value("category", "all"); + std::string task = "分析世界设定,找出 " + category + " 类别的缺口,产出结构化建议列表。"; + start_agent_run(req, res, wid, task, "suggestions"); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_get_suggestions(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto result = service_->worlds().get_agent_result(wid, "suggestions"); + if (!result) { + error_response(res, "Suggestions not yet generated", 404, "result_not_found"); + return; + } + json_response(res, {{"ok", true}, {"suggestions", *result}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 3e581625e4669d33db4a5af3e9bbb75ebba5eb03 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:16:10 +0000 Subject: [PATCH 07/33] feat: add consistency check endpoints (POST + GET) --- .../merak/worldbuilding_http_handler.hpp | 4 +++ libs/http/src/worldbuilding_http_handler.cpp | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 74199a85..b950aafc 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -119,6 +119,10 @@ class WorldbuildingHttpHandler { void handle_start_suggestions(const httplib::Request&, httplib::Response&); void handle_get_suggestions(const httplib::Request&, httplib::Response&); + // Agent-driven: consistency check + void handle_start_consistency_check(const httplib::Request&, httplib::Response&); + void handle_get_consistency_check(const httplib::Request&, httplib::Response&); + std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index cd776825..9cfe82fa 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -441,6 +441,11 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Get(R"(/api/worldbuilding/([^/]+)/suggestions)", [this](const auto& req, auto& res) { handle_get_suggestions(req, res); }); + server.Post(R"(/api/worldbuilding/([^/]+)/check-consistency)", + [this](const auto& req, auto& res) { handle_start_consistency_check(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/check-consistency)", + [this](const auto& req, auto& res) { handle_get_consistency_check(req, res); }); + // ─── Pipeline endpoints ─── server.Get(R"(/api/worldbuilding/([^/]+)/pipeline/state)", [this](const auto& req, auto& res) { @@ -2447,4 +2452,33 @@ void WorldbuildingHttpHandler::handle_get_suggestions(const httplib::Request& re } } +// --- Agent-driven: consistency check --- + +void WorldbuildingHttpHandler::handle_start_consistency_check(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + std::string scope = body.value("scope", "all"); + std::string task = "检查世界一致性,范围:" + scope + "。按 narrative_rules.md 规则检查矛盾(如角色死亡后出场、时间线冲突、阵营关系矛盾),产出结构化冲突列表。"; + start_agent_run(req, res, wid, task, "consistency_check"); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_get_consistency_check(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto result = service_->worlds().get_agent_result(wid, "consistency_check"); + if (!result) { + error_response(res, "Consistency check not yet generated", 404, "result_not_found"); + return; + } + json_response(res, {{"ok", true}, {"conflicts", (*result)["conflicts"]}, + {"generated_at", (*result)["generated_at"]}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 8c9b9fda671a01a3fd1be5e7f054e79c0884e32f Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:21:33 +0000 Subject: [PATCH 08/33] fix: return raw agent result in consistency check GET handler --- libs/http/src/worldbuilding_http_handler.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 9cfe82fa..3291ad2f 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -2474,8 +2474,7 @@ void WorldbuildingHttpHandler::handle_get_consistency_check(const httplib::Reque error_response(res, "Consistency check not yet generated", 404, "result_not_found"); return; } - json_response(res, {{"ok", true}, {"conflicts", (*result)["conflicts"]}, - {"generated_at", (*result)["generated_at"]}}); + json_response(res, {{"ok", true}, {"conflicts", *result}}); } catch (const std::exception& e) { error_response(res, e.what(), 400); } From f59c8f4624b52f25bcda6815ab40fc798e130782 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:25:39 +0000 Subject: [PATCH 09/33] feat: add generate-scenes endpoints (POST + GET + apply) --- .../merak/worldbuilding_http_handler.hpp | 5 ++ libs/http/src/worldbuilding_http_handler.cpp | 81 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index b950aafc..5d481f32 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -123,6 +123,11 @@ class WorldbuildingHttpHandler { void handle_start_consistency_check(const httplib::Request&, httplib::Response&); void handle_get_consistency_check(const httplib::Request&, httplib::Response&); + // Agent-driven: generate scenes + void handle_start_generate_scenes(const httplib::Request&, httplib::Response&); + void handle_get_generated_scenes(const httplib::Request&, httplib::Response&); + void handle_apply_generated_scenes(const httplib::Request&, httplib::Response&); + std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 3291ad2f..d82d3726 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace merak { @@ -446,6 +447,14 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Get(R"(/api/worldbuilding/([^/]+)/check-consistency)", [this](const auto& req, auto& res) { handle_get_consistency_check(req, res); }); + // ─── Agent-driven: generate scenes ─── + server.Post(R"(/api/worldbuilding/([^/]+)/chapters/([^/]+)/generate-scenes)", + [this](const auto& req, auto& res) { handle_start_generate_scenes(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/chapters/([^/]+)/generated-scenes)", + [this](const auto& req, auto& res) { handle_get_generated_scenes(req, res); }); + server.Post(R"(/api/worldbuilding/([^/]+)/chapters/([^/]+)/generated-scenes/apply)", + [this](const auto& req, auto& res) { handle_apply_generated_scenes(req, res); }); + // ─── Pipeline endpoints ─── server.Get(R"(/api/worldbuilding/([^/]+)/pipeline/state)", [this](const auto& req, auto& res) { @@ -2480,4 +2489,76 @@ void WorldbuildingHttpHandler::handle_get_consistency_check(const httplib::Reque } } +// --- Agent-driven: generate scenes --- + +void WorldbuildingHttpHandler::handle_start_generate_scenes(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string cid = req.matches[2]; + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + int scene_count = body.value("scene_count", 0); + std::string task = "为本章节拆分场景("; + if (scene_count > 0) task += std::to_string(scene_count) + "个"; + task += "),产出每个场景的 plot_goal、emotional_goal、information_goal 和 suggested_participants。"; + start_agent_run(req, res, wid, task, "generated_scenes:" + cid); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_get_generated_scenes(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string cid = req.matches[2]; + auto result = service_->worlds().get_agent_result(wid, "generated_scenes:" + cid); + if (!result) { + error_response(res, "Scenes not yet generated", 404, "result_not_found"); + return; + } + json_response(res, {{"ok", true}, {"scenes", *result}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_apply_generated_scenes(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string cid = req.matches[2]; + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + auto result = service_->worlds().get_agent_result(wid, "generated_scenes:" + cid); + if (!result) { + error_response(res, "No generated scenes to apply", 404, "result_not_found"); + return; + } + nlohmann::json scenes = result->is_array() ? *result : (*result)["scenes"]; + std::set selected; + if (body.contains("selected_indices") && body["selected_indices"].is_array()) { + for (const auto& idx : body["selected_indices"]) selected.insert(idx.get()); + } + nlohmann::json scene_ids = nlohmann::json::array(); + int idx = 0; + for (const auto& scene_data : scenes) { + if (!selected.empty() && !selected.contains(idx)) { idx++; continue; } + worldbuilding::Scene scene; + scene.title = scene_data.value("title", "未命名场景"); + scene.chapter_id = cid; + scene.plot_goal = scene_data.value("plot_goal", ""); + scene.emotional_goal = scene_data.value("emotional_goal", ""); + scene.information_goal = scene_data.value("information_goal", ""); + scene.status = worldbuilding::SceneStatus::Draft; + if (scene_data.contains("suggested_participants") && scene_data["suggested_participants"].is_array()) { + for (const auto& p : scene_data["suggested_participants"]) + scene.participant_ids.push_back(p.get()); + } + auto created = service_->create_scene(wid, scene); + scene_ids.push_back(created.id); + idx++; + } + json_response(res, {{"ok", true}, {"scene_ids", scene_ids}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 01ce653c2b7efee564012062c9934ff6521a11ce Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:31:50 +0000 Subject: [PATCH 10/33] feat: add generate-outline endpoints (POST + GET + apply) --- .../merak/worldbuilding_http_handler.hpp | 5 ++ libs/http/src/worldbuilding_http_handler.cpp | 80 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 5d481f32..8ff56277 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -128,6 +128,11 @@ class WorldbuildingHttpHandler { void handle_get_generated_scenes(const httplib::Request&, httplib::Response&); void handle_apply_generated_scenes(const httplib::Request&, httplib::Response&); + // Agent-driven: generate outline + void handle_start_generate_outline(const httplib::Request&, httplib::Response&); + void handle_get_generated_outline(const httplib::Request&, httplib::Response&); + void handle_apply_generated_outline(const httplib::Request&, httplib::Response&); + std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index d82d3726..ee640b9c 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -455,6 +455,14 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Post(R"(/api/worldbuilding/([^/]+)/chapters/([^/]+)/generated-scenes/apply)", [this](const auto& req, auto& res) { handle_apply_generated_scenes(req, res); }); + // ─── Agent-driven: generate outline ─── + server.Post(R"(/api/worldbuilding/([^/]+)/generate-outline)", + [this](const auto& req, auto& res) { handle_start_generate_outline(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/generated-outline)", + [this](const auto& req, auto& res) { handle_get_generated_outline(req, res); }); + server.Post(R"(/api/worldbuilding/([^/]+)/generated-outline/apply)", + [this](const auto& req, auto& res) { handle_apply_generated_outline(req, res); }); + // ─── Pipeline endpoints ─── server.Get(R"(/api/worldbuilding/([^/]+)/pipeline/state)", [this](const auto& req, auto& res) { @@ -2561,4 +2569,76 @@ void WorldbuildingHttpHandler::handle_apply_generated_scenes(const httplib::Requ } } +// --- Agent-driven: generate outline --- + +void WorldbuildingHttpHandler::handle_start_generate_outline(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + int chapter_count = body.value("chapter_count", 8); + std::string tmpl = body.value("template", "three_act"); + std::string task = "生成故事大纲:" + std::to_string(chapter_count) + "章,模板=" + tmpl + + "。产出 arcs 数组,每个 arc 含 title、purpose、chapters(每章含 title、pitch、suggested_pov)。"; + start_agent_run(req, res, wid, task, "generated_outline"); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_get_generated_outline(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto result = service_->worlds().get_agent_result(wid, "generated_outline"); + if (!result) { + error_response(res, "Outline not yet generated", 404, "result_not_found"); + return; + } + json_response(res, {{"ok", true}, {"outline", *result}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_apply_generated_outline(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto result = service_->worlds().get_agent_result(wid, "generated_outline"); + if (!result) { + error_response(res, "No generated outline to apply", 404, "result_not_found"); + return; + } + nlohmann::json arcs_data = result->is_array() ? *result : (*result)["arcs"]; + if (!arcs_data.is_array()) { + error_response(res, "Outline result does not contain arcs array", 400); + return; + } + nlohmann::json arc_ids = nlohmann::json::array(); + nlohmann::json chapter_ids = nlohmann::json::array(); + int chapter_number = 1; + for (const auto& arc_data : arcs_data) { + worldbuilding::Arc arc; + arc.title = arc_data.value("title", "未命名弧"); + arc.purpose = arc_data.value("purpose", ""); + arc.status = "outline"; + auto created_arc = service_->create_arc(wid, arc); + arc_ids.push_back(created_arc.id); + if (arc_data.contains("chapters") && arc_data["chapters"].is_array()) { + for (const auto& ch_data : arc_data["chapters"]) { + worldbuilding::Chapter chapter; + chapter.title = ch_data.value("title", "未命名章节"); + chapter.pitch = ch_data.value("pitch", ""); + chapter.number = chapter_number++; + chapter.arc_id = created_arc.id; + chapter.status = worldbuilding::ChapterStatus::Outline; + auto created_ch = service_->create_chapter(wid, chapter); + chapter_ids.push_back(created_ch.id); + } + } + } + json_response(res, {{"ok", true}, {"arc_ids", arc_ids}, {"chapter_ids", chapter_ids}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From db6b5d94e4a2f992c6a05aef4faf0e5a3ed18449 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:37:27 +0000 Subject: [PATCH 11/33] feat: add scene rewrite endpoints (POST + GET) Add POST /api/worldbuilding/:wid/scenes/:sid/rewrite to start an agent-driven rewrite, and GET .../:sid/rewrite-result to retrieve it. --- .../merak/worldbuilding_http_handler.hpp | 4 ++ libs/http/src/worldbuilding_http_handler.cpp | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 8ff56277..f6abc2c6 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -133,6 +133,10 @@ class WorldbuildingHttpHandler { void handle_get_generated_outline(const httplib::Request&, httplib::Response&); void handle_apply_generated_outline(const httplib::Request&, httplib::Response&); + // Agent-driven: scene rewrite + void handle_start_rewrite_scene(const httplib::Request&, httplib::Response&); + void handle_get_rewrite_result(const httplib::Request&, httplib::Response&); + std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index ee640b9c..3a2d652c 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -463,6 +463,12 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Post(R"(/api/worldbuilding/([^/]+)/generated-outline/apply)", [this](const auto& req, auto& res) { handle_apply_generated_outline(req, res); }); + // ─── Agent-driven: scene rewrite ─── + server.Post(R"(/api/worldbuilding/([^/]+)/scenes/([^/]+)/rewrite)", + [this](const auto& req, auto& res) { handle_start_rewrite_scene(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/scenes/([^/]+)/rewrite-result)", + [this](const auto& req, auto& res) { handle_get_rewrite_result(req, res); }); + // ─── Pipeline endpoints ─── server.Get(R"(/api/worldbuilding/([^/]+)/pipeline/state)", [this](const auto& req, auto& res) { @@ -2641,4 +2647,36 @@ void WorldbuildingHttpHandler::handle_apply_generated_outline(const httplib::Req } } +// --- Agent-driven: scene rewrite --- + +void WorldbuildingHttpHandler::handle_start_rewrite_scene(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string sid = req.matches[2]; + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + std::string style = body.value("style", ""); + std::string focus = body.value("focus", ""); + std::string task = "重写场景,风格=" + style + ",重点=" + focus + + "。产出 rewritten_text 和 changes_summary,不修改原场景。"; + start_agent_run(req, res, wid, task, "rewrite_result:" + sid); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_get_rewrite_result(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string sid = req.matches[2]; + auto result = service_->worlds().get_agent_result(wid, "rewrite_result:" + sid); + if (!result) { + error_response(res, "Rewrite result not yet generated", 404, "result_not_found"); + return; + } + json_response(res, {{"ok", true}, {"rewrite_result", *result}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 51b5b026a5914d552d7526f26e75ec471c7ec3e0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:47:05 +0000 Subject: [PATCH 12/33] feat: add kind and faction filtering to agent list endpoint --- libs/http/src/worldbuilding_http_handler.cpp | 19 ++++++++++++++++++- .../merak/worldbuilding/agent_store.hpp | 3 ++- libs/worldbuilding/src/agent_store.cpp | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 3a2d652c..7fc6ed5e 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -756,7 +756,24 @@ void WorldbuildingHttpHandler::handle_list_agents(const httplib::Request& req, h error_response(res, "World not found", 404, "world_not_found"); return; } - auto agents = service_->agents().list_agents(wid); + // Optional kind filter (individual, group, god, etc.) + std::optional kind_filter; + if (req.has_param("kind") && !req.get_param_value("kind").empty()) { + kind_filter = req.get_param_value("kind"); + } + auto agents = service_->agents().list_agents(wid, kind_filter); + // Optional faction filter: intersect with faction members + if (req.has_param("faction") && !req.get_param_value("faction").empty()) { + std::string faction_id = req.get_param_value("faction"); + auto faction = service_->worlds().get_faction(wid, faction_id); + if (faction) { + std::set member_set(faction->member_agent_ids.begin(), + faction->member_agent_ids.end()); + auto new_end = std::remove_if(agents.begin(), agents.end(), + [&](const auto& a) { return member_set.find(a.id) == member_set.end(); }); + agents.erase(new_end, agents.end()); + } + } // Batch-fetch primary avatars to avoid N+1 queries std::unordered_map primary_avatars; if (image_service_) { diff --git a/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp b/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp index 13746148..18475711 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/agent_store.hpp @@ -29,7 +29,8 @@ class AgentStore { std::vector member_agent_ids); std::optional get_agent(const std::string& agent_id) const; - std::vector list_agents(const std::string& world_id) const; + std::vector list_agents(const std::string& world_id, + const std::optional& kind = std::nullopt) const; bool delete_agent(const std::string& agent_id); CharacterCard load_character_card(const std::string& agent_id) const; diff --git a/libs/worldbuilding/src/agent_store.cpp b/libs/worldbuilding/src/agent_store.cpp index 5de5b423..53c1674f 100644 --- a/libs/worldbuilding/src/agent_store.cpp +++ b/libs/worldbuilding/src/agent_store.cpp @@ -486,8 +486,8 @@ AgentStore::get_agent(const std::string& agent_id) const { } std::vector -AgentStore::list_agents(const std::string& world_id) const { - return worlds_.list_agents(world_id); +AgentStore::list_agents(const std::string& world_id, const std::optional& kind) const { + return worlds_.list_agents(world_id, kind); } bool AgentStore::delete_agent(const std::string& agent_id) { From 4a42db5fa40a542f96d143d7b3a828dc81470c76 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:51:16 +0000 Subject: [PATCH 13/33] feat: add agent search endpoint with full-text and trait filtering --- .../merak/worldbuilding_http_handler.hpp | 1 + libs/http/src/worldbuilding_http_handler.cpp | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index f6abc2c6..6ba13275 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -39,6 +39,7 @@ class WorldbuildingHttpHandler { // Agent void handle_list_agents(const httplib::Request&, httplib::Response&); + void handle_search_agents(const httplib::Request&, httplib::Response&); void handle_create_agent(const httplib::Request&, httplib::Response&); void handle_get_agent(const httplib::Request&, httplib::Response&); void handle_delete_agent(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 7fc6ed5e..d3675297 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace merak { @@ -267,6 +268,8 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_update_world(req, res); }); // Agents + server.Get(R"(/api/worldbuilding/([^/]+)/agents/search)", + [this](const auto& req, auto& res) { handle_search_agents(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/agents)", [this](const auto& req, auto& res) { handle_list_agents(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/agents)", @@ -2696,4 +2699,36 @@ void WorldbuildingHttpHandler::handle_get_rewrite_result(const httplib::Request& } } +void WorldbuildingHttpHandler::handle_search_agents(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + worldbuilding::AgentStore::SearchCriteria criteria; + criteria.q = req.has_param("q") ? req.get_param_value("q") : ""; + criteria.identity = req.has_param("identity") ? req.get_param_value("identity") : ""; + criteria.race = req.has_param("race") ? req.get_param_value("race") : ""; + if (req.has_param("traits") && !req.get_param_value("traits").empty()) { + std::string traits_str = req.get_param_value("traits"); + std::istringstream iss(traits_str); + std::string token; + while (std::getline(iss, token, ',')) { + if (!token.empty()) criteria.traits.push_back(token); + } + } + auto results = service_->agents().search_agents(wid, criteria); + nlohmann::json arr = nlohmann::json::array(); + for (const auto& a : results) { + nlohmann::json agent_obj = { + {"id", a.id}, + {"name", a.name}, + {"display_name", a.display_name}, + {"kind", worldbuilding::to_string(a.kind)} + }; + arr.push_back(agent_obj); + } + json_response(res, {{"ok", true}, {"agents", arr}, {"total", results.size()}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 8811a56cce55473febe4a008582dac32bd477ccc Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:54:45 +0000 Subject: [PATCH 14/33] feat: add character appearances endpoint --- .../merak/worldbuilding_http_handler.hpp | 3 ++ libs/http/src/worldbuilding_http_handler.cpp | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 6ba13275..1f84a2bb 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -136,6 +136,9 @@ class WorldbuildingHttpHandler { // Agent-driven: scene rewrite void handle_start_rewrite_scene(const httplib::Request&, httplib::Response&); + + // Character appearances + void handle_character_appearances(const httplib::Request&, httplib::Response&); void handle_get_rewrite_result(const httplib::Request&, httplib::Response&); std::unordered_map pending_agent_runs_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index d3675297..75b12b17 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace merak { @@ -289,6 +290,10 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Get(R"(/api/worldbuilding/agents/([^/]+)/prompt)", [this](const auto& req, auto& res) { handle_load_agent_prompt(req, res); }); + // Character appearances + server.Get(R"(/api/worldbuilding/([^/]+)/characters/([^/]+)/appearances)", + [this](const auto& req, auto& res) { handle_character_appearances(req, res); }); + // Image routes server.Get(R"(/api/worldbuilding/images/([^/]+))", [this](const auto& req, auto& res) { handle_serve_image(req, res); }); @@ -2731,4 +2736,50 @@ void WorldbuildingHttpHandler::handle_search_agents(const httplib::Request& req, } } +void WorldbuildingHttpHandler::handle_character_appearances(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + auto agent = service_->agents().get_agent(aid); + if (!agent || agent->world_id != wid) { + error_response(res, "Character not found", 404, "agent_not_found"); + return; + } + auto appearances = service_->narrative().find_character_appearances(wid, aid); + // Group scenes by chapter + std::map chapter_map; + std::map chapter_titles; + std::map chapter_numbers; + for (const auto& ch : appearances.chapters) { + std::string ch_id = ch["id"]; + chapter_titles[ch_id] = ch.value("title", ""); + chapter_numbers[ch_id] = ch.value("scene_count", 0); + chapter_map[ch_id] = nlohmann::json::array(); + } + for (const auto& sc : appearances.scenes) { + std::string ch_id = sc.value("chapter_id", ""); + // C++17: use find() instead of contains() + if (chapter_map.find(ch_id) != chapter_map.end()) { + chapter_map[ch_id].push_back({ + {"scene_id", sc["id"]}, + {"scene_title", sc["title"]} + }); + } + } + nlohmann::json chapter_list = nlohmann::json::array(); + for (auto& kv : chapter_map) { + const auto& ch_id = kv.first; + chapter_list.push_back({ + {"chapter_id", ch_id}, + {"chapter_title", chapter_titles[ch_id]}, + {"chapter_number", chapter_numbers[ch_id]}, + {"scenes", kv.second} + }); + } + json_response(res, {{"ok", true}, {"character_id", aid}, {"appearances", chapter_list}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 2903c5f0458eed58f0a679bcd973660d2c74484b Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 07:58:29 +0000 Subject: [PATCH 15/33] feat: add workspace file create endpoint --- libs/http/include/merak/http_server.hpp | 1 + libs/http/src/http_server.cpp | 46 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/libs/http/include/merak/http_server.hpp b/libs/http/include/merak/http_server.hpp index 4d654129..f6887ee4 100644 --- a/libs/http/include/merak/http_server.hpp +++ b/libs/http/include/merak/http_server.hpp @@ -61,6 +61,7 @@ class HttpServer { void handle_workspace_files_list(const httplib::Request& req, httplib::Response& res); void handle_workspace_file_content_get(const httplib::Request& req, httplib::Response& res); void handle_workspace_file_content_put(const httplib::Request& req, httplib::Response& res); + void handle_workspace_file_create(const httplib::Request&, httplib::Response&); std::shared_ptr llm_provider_; nlohmann::json cached_config_; diff --git a/libs/http/src/http_server.cpp b/libs/http/src/http_server.cpp index f667000f..9a153397 100644 --- a/libs/http/src/http_server.cpp +++ b/libs/http/src/http_server.cpp @@ -351,6 +351,7 @@ void HttpServer::install_routes(){ server_.Get("/api/workspace/files/content", [this](const auto& req, auto& res) { handle_workspace_file_content_get(req, res); }); server_.Put("/api/workspace/files/content", [this](const auto& req, auto& res) { handle_workspace_file_content_put(req, res); }); server_.Post("/api/workspace/open", [this](const auto& req, auto& res) { handle_workspace_open(req, res); }); + server_.Post("/api/workspace/files", [this](const auto& req, auto& res) { handle_workspace_file_create(req, res); }); } void HttpServer::handle_capabilities(const httplib::Request&, httplib::Response& res) { json(res, {200, { @@ -635,4 +636,49 @@ void HttpServer::handle_preferences_set(const httplib::Request& req, httplib::Re } } +void HttpServer::handle_workspace_file_create(const httplib::Request& req, httplib::Response& res) { + try { + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + auto path_value = body.value("path", ""); + if (path_value.empty()) { + json(res, error("invalid_request", "path is required", 400)); + return; + } + std::string type = body.value("type", "markdown"); + auto home = home_root(merak_home_path_); + auto raw = std::filesystem::u8path(path_value); + if (raw.is_relative()) raw = home / raw; + auto path = safe_existing_or_parent_canonical(raw); + if (!is_under(path, home)) { + json(res, error("invalid_path", "File must be under merak home", 403)); + return; + } + if (std::filesystem::exists(path)) { + json(res, error("file_exists", "File already exists", 409)); + return; + } + std::filesystem::create_directories(path.parent_path()); + std::string default_content; + if (type == "markdown" || type == "md") default_content = "# New Document\n\n"; + else if (type == "json") default_content = "{\n \n}\n"; + else if (type == "yaml" || type == "yml") default_content = "# YAML Document\n"; + else default_content = ""; + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << default_content; + out.close(); + + auto updated_at = file_updated_at(path); + auto version = file_version(path); + json(res, {201, {{"ok", true}, {"file", { + {"path", path.generic_string()}, + {"updated_at", updated_at}, + {"version", version} + }}}}); + } catch (const nlohmann::json::exception& e) { + json(res, error("invalid_request", e.what(), 400)); + } catch (const std::exception& e) { + json(res, error("file_create_failed", e.what(), 500)); + } +} + } // namespace merak From 836fe3fcd4fb2a151749dfadf9252da15e1d9e8b Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 08:01:50 +0000 Subject: [PATCH 16/33] feat: add workspace file delete endpoint --- libs/http/include/merak/http_server.hpp | 1 + libs/http/src/http_server.cpp | 31 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/libs/http/include/merak/http_server.hpp b/libs/http/include/merak/http_server.hpp index f6887ee4..637c22d8 100644 --- a/libs/http/include/merak/http_server.hpp +++ b/libs/http/include/merak/http_server.hpp @@ -62,6 +62,7 @@ class HttpServer { void handle_workspace_file_content_get(const httplib::Request& req, httplib::Response& res); void handle_workspace_file_content_put(const httplib::Request& req, httplib::Response& res); void handle_workspace_file_create(const httplib::Request&, httplib::Response&); + void handle_workspace_file_delete(const httplib::Request&, httplib::Response&); std::shared_ptr llm_provider_; nlohmann::json cached_config_; diff --git a/libs/http/src/http_server.cpp b/libs/http/src/http_server.cpp index 9a153397..fb4cfe6c 100644 --- a/libs/http/src/http_server.cpp +++ b/libs/http/src/http_server.cpp @@ -352,6 +352,7 @@ void HttpServer::install_routes(){ server_.Put("/api/workspace/files/content", [this](const auto& req, auto& res) { handle_workspace_file_content_put(req, res); }); server_.Post("/api/workspace/open", [this](const auto& req, auto& res) { handle_workspace_open(req, res); }); server_.Post("/api/workspace/files", [this](const auto& req, auto& res) { handle_workspace_file_create(req, res); }); + server_.Delete("/api/workspace/files", [this](const auto& req, auto& res) { handle_workspace_file_delete(req, res); }); } void HttpServer::handle_capabilities(const httplib::Request&, httplib::Response& res) { json(res, {200, { @@ -681,4 +682,34 @@ void HttpServer::handle_workspace_file_create(const httplib::Request& req, httpl } } +void HttpServer::handle_workspace_file_delete(const httplib::Request& req, httplib::Response& res) { + try { + auto path_value = req.has_param("path") ? req.get_param_value("path") : ""; + if (path_value.empty()) { + json(res, error("invalid_request", "path query parameter is required", 400)); + return; + } + auto home = home_root(merak_home_path_); + auto raw = std::filesystem::u8path(path_value); + if (raw.is_relative()) raw = home / raw; + auto path = safe_existing_or_parent_canonical(raw); + if (!is_under(path, home)) { + json(res, error("invalid_path", "File must be under merak home", 403)); + return; + } + if (has_hidden_or_runtime_part(path)) { + json(res, error("protected_file", "Cannot delete system files", 403)); + return; + } + if (!std::filesystem::exists(path)) { + json(res, error("file_not_found", "File does not exist", 404)); + return; + } + std::filesystem::remove(path); + json(res, {200, {{"ok", true}}}); + } catch (const std::exception& e) { + json(res, error("file_delete_failed", e.what(), 500)); + } +} + } // namespace merak From 5057569977bad812c29216bc8f91dfc81a8ecf12 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 08:05:05 +0000 Subject: [PATCH 17/33] feat: add workspace file rename/move endpoint --- libs/http/include/merak/http_server.hpp | 1 + libs/http/src/http_server.cpp | 46 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/libs/http/include/merak/http_server.hpp b/libs/http/include/merak/http_server.hpp index 637c22d8..bb6a0fe5 100644 --- a/libs/http/include/merak/http_server.hpp +++ b/libs/http/include/merak/http_server.hpp @@ -63,6 +63,7 @@ class HttpServer { void handle_workspace_file_content_put(const httplib::Request& req, httplib::Response& res); void handle_workspace_file_create(const httplib::Request&, httplib::Response&); void handle_workspace_file_delete(const httplib::Request&, httplib::Response&); + void handle_workspace_file_rename(const httplib::Request&, httplib::Response&); std::shared_ptr llm_provider_; nlohmann::json cached_config_; diff --git a/libs/http/src/http_server.cpp b/libs/http/src/http_server.cpp index fb4cfe6c..b227980e 100644 --- a/libs/http/src/http_server.cpp +++ b/libs/http/src/http_server.cpp @@ -353,6 +353,7 @@ void HttpServer::install_routes(){ server_.Post("/api/workspace/open", [this](const auto& req, auto& res) { handle_workspace_open(req, res); }); server_.Post("/api/workspace/files", [this](const auto& req, auto& res) { handle_workspace_file_create(req, res); }); server_.Delete("/api/workspace/files", [this](const auto& req, auto& res) { handle_workspace_file_delete(req, res); }); + server_.Post("/api/workspace/files/rename", [this](const auto& req, auto& res) { handle_workspace_file_rename(req, res); }); } void HttpServer::handle_capabilities(const httplib::Request&, httplib::Response& res) { json(res, {200, { @@ -712,4 +713,49 @@ void HttpServer::handle_workspace_file_delete(const httplib::Request& req, httpl } } +void HttpServer::handle_workspace_file_rename(const httplib::Request& req, httplib::Response& res) { + try { + auto body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body); + auto from_value = body.value("from_path", ""); + auto to_value = body.value("to_path", ""); + if (from_value.empty() || to_value.empty()) { + json(res, error("invalid_request", "from_path and to_path are required", 400)); + return; + } + auto home = home_root(merak_home_path_); + auto from_raw = std::filesystem::u8path(from_value); + if (from_raw.is_relative()) from_raw = home / from_raw; + auto from_path = safe_existing_or_parent_canonical(from_raw); + if (!is_under(from_path, home)) { + json(res, error("invalid_path", "Source must be under merak home", 403)); + return; + } + if (!std::filesystem::exists(from_path)) { + json(res, error("rename_source_not_found", "Source file does not exist", 404)); + return; + } + auto to_raw = std::filesystem::u8path(to_value); + if (to_raw.is_relative()) to_raw = home / to_raw; + auto to_path = safe_existing_or_parent_canonical(to_raw); + if (!is_under(to_path, home)) { + json(res, error("invalid_path", "Destination must be under merak home", 403)); + return; + } + std::filesystem::create_directories(to_path.parent_path()); + std::filesystem::rename(from_path, to_path); + + auto updated_at = file_updated_at(to_path); + auto version = file_version(to_path); + json(res, {200, {{"ok", true}, {"file", { + {"path", to_path.generic_string()}, + {"updated_at", updated_at}, + {"version", version} + }}}}); + } catch (const nlohmann::json::exception& e) { + json(res, error("invalid_request", e.what(), 400)); + } catch (const std::exception& e) { + json(res, error("file_rename_failed", e.what(), 500)); + } +} + } // namespace merak From a049ba27329b6c6f2b0299658a8da54a472d52d8 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 08:09:58 +0000 Subject: [PATCH 18/33] test: add integration tests for agent search, kind filter, and character appearances --- CMakeLists.txt | 2 +- libs/http/tests/test_agent_endpoints.cpp | 119 +++++++++++++++++++++++ tests/CMakeLists.txt | 10 ++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 libs/http/tests/test_agent_endpoints.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ffb20e8c..7df11885 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,7 +52,7 @@ add_subdirectory(libs/skills) add_subdirectory(libs/http) add_subdirectory(libs/app) add_subdirectory(cli) -# add_subdirectory(tests) +add_subdirectory(tests) # Install pipeline configuration files install(DIRECTORY config/pipelines/ diff --git a/libs/http/tests/test_agent_endpoints.cpp b/libs/http/tests/test_agent_endpoints.cpp new file mode 100644 index 00000000..07482035 --- /dev/null +++ b/libs/http/tests/test_agent_endpoints.cpp @@ -0,0 +1,119 @@ +#include +#include +#include +#include + +#include "../../worldbuilding/tests/test_helpers.hpp" + +#include +#include +#include + +using namespace merak::worldbuilding; +using namespace merak::worldbuilding::test; + +namespace { + +std::filesystem::path temp_dir() { + auto path = std::filesystem::temp_directory_path() / ("agent_endpoint_test_" + std::to_string(std::rand())); + std::filesystem::remove_all(path); + std::filesystem::create_directories(path); + return path; +} + +} // namespace + +TEST(AgentSearch, SearchByQueryMatchesNameAndDisplayName) { + WorldbuildingService svc(test_pg_conninfo(), temp_dir()); + svc.initialize(); + auto world = svc.create_world("SearchTest", "test"); + + CharacterCard card1; card1.name = "BraveKnight"; + card1.background = "Strongest warrior in the kingdom"; card1.race = "Human"; card1.identity = "Knight"; + svc.create_character(world.id, card1); + + CharacterCard card2; card2.name = "EvilMage"; + card2.background = "Dark forest hermit"; card2.race = "Elf"; card2.identity = "Mage"; + svc.create_character(world.id, card2); + + AgentStore::SearchCriteria criteria; + criteria.q = "Knight"; + auto results = svc.agents().search_agents(world.id, criteria); + ASSERT_GE(results.size(), 1); + EXPECT_EQ(results[0].name, "BraveKnight"); +} + +TEST(AgentSearch, SearchByTraitsFiltersCorrectly) { + WorldbuildingService svc(test_pg_conninfo(), temp_dir()); + svc.initialize(); + auto world = svc.create_world("TraitSearch", "test"); + + CharacterCard card; card.name = "LoyalGuard"; + card.core_traits = {"loyal", "brave"}; + card.race = "Human"; + svc.create_character(world.id, card); + + AgentStore::SearchCriteria criteria; + criteria.traits = {"loyal"}; + auto results = svc.agents().search_agents(world.id, criteria); + ASSERT_GE(results.size(), 1); + EXPECT_EQ(results[0].name, "LoyalGuard"); + + criteria.traits = {"nonexistent_trait"}; + auto empty = svc.agents().search_agents(world.id, criteria); + EXPECT_EQ(empty.size(), 0); +} + +TEST(AgentSearch, SearchByRaceAndIdentity) { + WorldbuildingService svc(test_pg_conninfo(), temp_dir()); + svc.initialize(); + auto world = svc.create_world("RaceSearch", "test"); + + CharacterCard card; card.name = "ElfArcher"; + card.race = "Elf"; card.identity = "Ranger"; + svc.create_character(world.id, card); + + AgentStore::SearchCriteria criteria; + criteria.race = "Elf"; + auto results = svc.agents().search_agents(world.id, criteria); + ASSERT_GE(results.size(), 1); + + criteria.race = "Orc"; + auto empty = svc.agents().search_agents(world.id, criteria); + EXPECT_EQ(empty.size(), 0); +} + +TEST(AgentList, KindFilterReturnsOnlyMatchingKind) { + WorldbuildingService svc(test_pg_conninfo(), temp_dir()); + svc.initialize(); + auto world = svc.create_world("KindFilter", "test"); + + auto agents = svc.worlds().list_agents(world.id, std::string{"individual"}); + for (const auto& a : agents) { + EXPECT_EQ(a.kind, AgentKind::Individual); + } +} + +TEST(CharacterAppearances, FindAppearancesReturnsSceneParticipation) { + WorldbuildingService svc(test_pg_conninfo(), temp_dir()); + svc.initialize(); + auto world = svc.create_world("AppearanceTest", "test"); + + CharacterCard card; card.name = "Hero"; + auto agent = svc.create_character(world.id, card); + + Chapter ch; ch.title = "Chapter One"; ch.number = 1; + auto chapter = svc.create_chapter(world.id, ch); + + Scene scene; scene.title = "Hero Enters"; scene.chapter_id = chapter.id; + scene.participant_ids = {agent.id}; + auto created_scene = svc.create_scene(world.id, scene); + + auto appearances = svc.narrative().find_character_appearances(world.id, agent.id); + ASSERT_GE(appearances.scenes.size(), 1); + bool found = false; + for (const auto& sc : appearances.scenes) { + if (sc["id"] == created_scene.id) { found = true; break; } + } + EXPECT_TRUE(found); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 67a8f51b..2ff12193 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -58,3 +58,13 @@ target_include_directories(merak-tui-features-test PRIVATE ) 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) + +# Agent endpoint integration tests +add_executable(merak-agent-endpoint-test + ${CMAKE_SOURCE_DIR}/libs/http/tests/test_agent_endpoints.cpp +) +target_include_directories(merak-agent-endpoint-test PRIVATE + ${CMAKE_SOURCE_DIR}/libs/worldbuilding/tests +) +target_link_libraries(merak-agent-endpoint-test PRIVATE merak-worldbuilding GTest::gtest_main) +add_test(NAME merak-agent-endpoint-test COMMAND merak-agent-endpoint-test) From 51f832edec12271071e6740ed3eb99d6009aa869 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 08:27:54 +0000 Subject: [PATCH 19/33] fix: wire result poller to capture agent results on run completion Adds a background poller thread that monitors pending agent runs and calls capture_agent_result when a run reaches Completed state. Terminal states (Failed, Cancelled, Interrupted) are cleaned up without capturing. The poller wakes immediately when a new run is started via start_agent_run, then falls back to a 2-second interval when idle. --- .../merak/worldbuilding_http_handler.hpp | 14 ++++ libs/http/src/worldbuilding_http_handler.cpp | 64 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 1f84a2bb..fb0d788e 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -5,9 +5,13 @@ #include #include #include +#include +#include +#include #include #include #include +#include #include namespace merak { @@ -19,6 +23,7 @@ class WorldbuildingHttpHandler { explicit WorldbuildingHttpHandler( std::shared_ptr service, std::shared_ptr runtime = nullptr); + ~WorldbuildingHttpHandler(); void install_routes(httplib::Server& server); void set_pipeline_manager(std::shared_ptr mgr); @@ -144,6 +149,15 @@ class WorldbuildingHttpHandler { std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; + // Background poller for capturing agent results on run completion + void start_result_poller(); + void stop_result_poller(); + std::thread result_poller_; + std::atomic poller_stop_{false}; + std::atomic poller_started_{false}; + std::condition_variable poller_started_cv_; + std::mutex poller_started_mutex_; + // Agent prompt void handle_load_agent_prompt(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 75b12b17..0669cde5 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -197,6 +197,7 @@ void WorldbuildingHttpHandler::start_agent_run( std::lock_guard lock(pending_runs_mutex_); pending_agent_runs_[run.id] = {world_id, operation_type}; } + poller_started_cv_.notify_one(); json_response(res, { {"ok", true}, @@ -240,10 +241,71 @@ void WorldbuildingHttpHandler::capture_agent_result(const std::string& run_id) { } } +void WorldbuildingHttpHandler::start_result_poller() { + { + std::lock_guard lock(poller_started_mutex_); + if (poller_started_) return; + poller_started_ = true; + } + poller_stop_ = false; + result_poller_ = std::thread([this] { + while (!poller_stop_) { + std::vector pending_ids; + { + std::lock_guard lock(pending_runs_mutex_); + for (const auto& kv : pending_agent_runs_) { + pending_ids.push_back(kv.first); + } + } + for (const auto& id : pending_ids) { + if (poller_stop_) break; + auto run = runtime_->get_run(id); + if (!run) { + std::lock_guard lock(pending_runs_mutex_); + pending_agent_runs_.erase(id); + continue; + } + switch (run->status) { + case RunStatus::Completed: + capture_agent_result(id); + break; + case RunStatus::Failed: + case RunStatus::Cancelled: + case RunStatus::Interrupted: + { + std::lock_guard lock(pending_runs_mutex_); + pending_agent_runs_.erase(id); + } + break; + default: + break; + } + } + std::unique_lock cv_lock(poller_started_mutex_); + poller_started_cv_.wait_for(cv_lock, std::chrono::seconds(2), + [this] { return poller_stop_.load(); }); + } + }); +} + +void WorldbuildingHttpHandler::stop_result_poller() { + poller_stop_ = true; + poller_started_cv_.notify_all(); + if (result_poller_.joinable()) { + result_poller_.join(); + } +} + WorldbuildingHttpHandler::WorldbuildingHttpHandler( std::shared_ptr service, std::shared_ptr runtime) - : service_(std::move(service)), runtime_(std::move(runtime)) {} + : service_(std::move(service)), runtime_(std::move(runtime)) { + if (runtime_) start_result_poller(); +} + +WorldbuildingHttpHandler::~WorldbuildingHttpHandler() { + stop_result_poller(); +} void WorldbuildingHttpHandler::set_pipeline_manager( std::shared_ptr mgr) { From 33299a9f48be31f44303ebb811dee9d29bf4b00e Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 08:43:06 +0000 Subject: [PATCH 20/33] docs: add P2 backend completion design spec 19 endpoints across 5 modules: Pipeline control, Diary/Memory/Relation APIs, World import/export, Knowledge Graph queries, Timeline API. All synchronous CRUD, no async agent-driven flows. --- ...2026-06-19-p2-backend-completion-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-19-p2-backend-completion-design.md diff --git a/docs/superpowers/specs/2026-06-19-p2-backend-completion-design.md b/docs/superpowers/specs/2026-06-19-p2-backend-completion-design.md new file mode 100644 index 00000000..de7bc7da --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-p2-backend-completion-design.md @@ -0,0 +1,213 @@ +# P2 后端补全设计 + +## 范围 + +补全 Inspector 面板所需的结构化数据查询端点 + Pipeline 控制 + World 导入/导出。共 5 个模块 19 个端点,全部同步 CRUD,直接调已有 Store 方法。 + +Merak 是对话驱动的创作工作台。创作行为(写作、生成、重写)走 Session → Run → AgentLoop,REST 端点服务于 WebUI Inspector 面板(展示结构化数据)和配置控制。 + +## 代码变更范围 + +- `libs/http/src/worldbuilding_http_handler.cpp` — 所有新 handler +- `libs/http/include/merak/worldbuilding_http_handler.hpp` — 新增 handler 声明 + +--- + +## 模块 B:Pipeline 补全(2 端点) + +### 设计 + +PipelineManager 已有 5 个端点(state、advance、workflows、history、activate)。补全 retreat 和 clear-error。 + +### 端点 + +``` +POST /api/worldbuilding/:wid/pipeline/retreat +Body: { to_phase: "outlining|drafting|revising|polishing" } +→ 200 { ok, state: { phase, label, conditions, ... } } +// 回退到指定阶段 + +POST /api/worldbuilding/:wid/pipeline/clear-error +→ 200 { ok } +// 清除上一步错误,恢复可推进状态 +``` + +### 实现 + +`PipelineManager` 已有 `handle_advance_failure` 和 `clear_last_error`。retreat 需新增 `retreat_to_phase` 方法:重置目标阶段的所有条件状态,回退 `current_phase`。 + +--- + +## 模块 C:日记/记忆/关系 API(8 端点) + +### 设计 + +AgentStore 已有完整 CRUD。暴露为 Inspector 数据查询端点。 + +### 端点 + +#### 日记 + +``` +GET /api/worldbuilding/:wid/agents/:aid/diaries/:did +→ 200 { ok, diary: { id, agent_id, scene_id, world_time, content, mood, status, leak_risk_level, tokens_used, created_at } } + +PATCH /api/worldbuilding/:wid/agents/:aid/diaries/:did +Body: { content?, mood?, status? } +→ 200 { ok } + +GET /api/worldbuilding/:wid/agents/:aid/diaries/search?q=... +→ 200 { ok, diaries: [...], total } +// 全文搜索日记内容 +``` + +#### 记忆摘要 + +``` +GET /api/worldbuilding/:wid/agents/:aid/memory-summaries +→ 200 { ok, summaries: [{ id, period_start, period_end, summary, source_diary_ids, created_at }] } + +GET /api/worldbuilding/:wid/agents/:aid/memory-summaries/:mid +→ 200 { ok, summary: { id, period_start, period_end, summary, source_diary_ids, created_at } } +``` + +#### 关系 + +``` +POST /api/worldbuilding/:wid/agents/:aid/relations +Body: { target_id, relation_type, description, intimacy? } +→ 201 { ok } + +PATCH /api/worldbuilding/:wid/agents/:aid/relations/:tid +Body: { relation_type?, description?, intimacy? } +→ 200 { ok } +// upsert_relations 已支持覆盖更新 +``` + +#### 声音指纹 + +``` +GET /api/worldbuilding/:wid/agents/:aid/voice +→ 200 { ok, voice: { avg_sentence_length, sentence_variance, question_frequency, modifier_ratio, sample_count, signature_words, tone_profile } } +``` + +### 实现 + +全部直接调 AgentStore 已有方法。无 Store 变更。 + +--- + +## 模块 D:World 导入/导出(2 端点) + +### 设计 + +全量快照导出为单一 JSON(`share-snapshot-v1` 格式),导入时全量 ID 重映射创建新 World。作为社区分享功能的前置基础。 + +### 端点 + +``` +POST /api/worldbuilding/:wid/export-full +Body: { include_diaries?: false, include_memories?: false } +→ 200 { ok, snapshot: { schema_version: "1.0", exported_at, snapshot_id, source: { world_id, name }, manifest: { agent_count, chapter_count, scene_count, ... }, payload: { world, agents: [{ record, card, group_profile?, manager_profile? }], chapters, scenes, arcs, locations, factions, knowledge, foreshadowing, secrets, timeline, images: [{ id, agent_id, image_type, mime_type, original_name, is_primary, data: "base64..." | null }] } } } + +POST /api/worldbuilding/import +Body: { snapshot: {...}, target_name? } +→ 201 { ok, world_id, id_mapping: { old_id: new_id, ... } } +``` + +### Snapshot 图片格式 + +- ≤256KB → `data` 字段 base64 内嵌 +- >256KB → `data` 为 null,标记 `"transfer": "external"` +- 导入时 base64 小图直接写本地 ImageService,大图跳过 + +### ID 重映射 + +- 导入时所有 ID 走 `make_id()` 生成新 ID +- 维护 `id_mapping` 表(旧 ID → 新 ID),存入 `world.config._import_meta` +- 交叉引用字段(`participant_ids`, `scene_ids`, `member_agent_ids`, `rival_faction_ids`, `foreshadowing_ids`, `related_ids` 等)全部重映射 +- `fork_chain` 数组记录派生链:`[{world_id, snapshot_id, author, imported_at}]` + +### 实现 + +`WorldbuildingService` 新增 `export_world_snapshot()` 和 `import_snapshot()` 方法。Store 层无变更(导出走现有 get/list 方法,导入走现有 create 方法 + ID 替换)。 + +--- + +## 模块 E:知识图谱查询(4 端点) + +### 设计 + +纯查询端点,从 KG Provider 读取实体和关系。不触发 extraction(extraction 由 GodAgent 在对话内完成)。 + +### 端点 + +``` +GET /api/worldbuilding/:wid/knowledge-graph/entities?type=agent|location|faction|knowledge +→ 200 { ok, entities: [{ id, name, type, properties }] } + +GET /api/worldbuilding/:wid/knowledge-graph/entities/:eid/relations +→ 200 { ok, entity: { id, name }, relations: [{ relation_type, target_id, target_name, target_type }] } + +GET /api/worldbuilding/:wid/knowledge-graph/search?q=...&type=... +→ 200 { ok, results: [{ id, name, type }] } + +GET /api/worldbuilding/:wid/scenes/:sid/extraction-result +→ 200 { ok, candidates: [{ relation: { subject_id, predicate, object_id }, status, evidence, change_summary }] } +// 某场戏的 KG extraction 候选结果 +``` + +### 实现 + +通过 `WorldbuildingService::kg_provider()` 访问 KG。ExtractionResult 已有 `ExtractionService` 产出并存储。 + +--- + +## 模块 F:时间线 API(3 端点) + +### 设计 + +整合现有的 `time_now` + `time_advance` 进入 timeline 框架,新增 event 查询。 + +### 端点 + +``` +GET /api/worldbuilding/:wid/timeline +→ 200 { ok, current_time: { day, period, label }, events: [{ id, world_time, description, recorded_by, affected_character_ids, related_scene_ids }] } + +GET /api/worldbuilding/:wid/timeline/events/:eid +→ 200 { ok, event: { id, world_time, description, recorded_by, recorded_by_name, affected_character_ids, related_scene_ids } } + +POST /api/worldbuilding/:wid/timeline/advance +Body: { time_label: "第3日晚" | "day3_evening" | "2d" | "4h" } +→ 200 { ok, new_time: { day, period, label } } +// 推进世界时间,替代当前 time_advance 端点 +``` + +### 实现 + +`NarrativeStore` 已有 `record_timeline_event`、`advance_time`。需新增 `list_timeline_events(world_id)` 和 `get_timeline_event(world_id, event_id)` 方法。 + +`WorldTime::parse()` 已实现,支持中英文格式 + duration 简写(`2d`, `4h`)。 + +--- + +## 汇总 + +| 模块 | 端点数 | Store 变更 | 基础设施依赖 | +|------|--------|-----------|-------------| +| B. Pipeline 补全 | 2 | 1 个新方法 `retreat_to_phase` | PipelineManager | +| C. 日记/记忆/关系 | 8 | 无 | AgentStore 已有方法 | +| D. World 导入/导出 | 2 | 2 个新方法 `export_world_snapshot` `import_snapshot` | WorldbuildingService | +| E. 知识图谱查询 | 4 | 无 | KG Provider | +| F. 时间线 | 3 | 2 个新方法 `list_timeline_events` `get_timeline_event` | NarrativeStore | +| **合计** | **19** | **5 个新方法** | — | + +P0(~30)+ P1(18)+ P2(19)= **67 个端点**。 + +## 设计决策 + +- 全部同步 CRUD,不走 GodAgent 异步流程 +- WriterAgent 增强不属于此次范围——它是对话层功能,非 REST 端点 +- Snapshot 格式预留 `fork_chain` 和 `id_mapping`,为后续社区分享打基础 +- 模块 E(KG 查询)仅查询,extraction 由 GodAgent 在对话内触发 From ea38cc01192131b2ce60b23724ba57b1a3186c21 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:09:04 +0000 Subject: [PATCH 21/33] feat: add pipeline retreat endpoint --- .../merak/worldbuilding_http_handler.hpp | 3 + libs/http/src/worldbuilding_http_handler.cpp | 58 ++++++++++++++ .../merak/worldbuilding/pipeline_manager.hpp | 1 + libs/worldbuilding/src/pipeline_manager.cpp | 78 +++++++++++++++++++ 4 files changed, 140 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index fb0d788e..f4276e13 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -88,6 +88,9 @@ class WorldbuildingHttpHandler { void handle_update_knowledge(const httplib::Request&, httplib::Response&); void handle_delete_knowledge(const httplib::Request&, httplib::Response&); + // Pipeline + void handle_pipeline_retreat(const httplib::Request&, httplib::Response&); + // Factions void handle_list_factions(const httplib::Request&, httplib::Response&); void handle_get_faction(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 0669cde5..03156de9 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -719,6 +719,9 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { } res.set_content(R"({"ok":true})", "application/json"); }); + + server.Post(R"(/api/worldbuilding/([^/]+)/pipeline/retreat)", + [this](const auto& req, auto& res) { handle_pipeline_retreat(req, res); }); } // --- World handlers --- @@ -2844,4 +2847,59 @@ void WorldbuildingHttpHandler::handle_character_appearances(const httplib::Reque } } +void WorldbuildingHttpHandler::handle_pipeline_retreat(const httplib::Request& req, httplib::Response& res) { + std::string world_id = req.matches[1]; + if (!pipeline_mgr_) { + res.status = 503; + res.set_content(R"({"error":"pipeline_not_available"})", "application/json"); + return; + } + + nlohmann::json body; + try { + body = nlohmann::json::parse(req.body); + } catch (...) { + error_response(res, "Invalid JSON body", 400, "invalid_json"); + return; + } + + if (!body.contains("to_phase") || !body["to_phase"].is_string()) { + error_response(res, "Missing required field: to_phase", 400, "missing_field"); + return; + } + + std::string to_phase = body["to_phase"]; + pipeline_mgr_->retreat_to_phase(world_id, to_phase); + + // Return state JSON matching pipeline/state handler format + auto data = pipeline_mgr_->get_view_data(world_id); + const auto* wf = pipeline_mgr_->get_workflow(data.active_workflow_name); + const auto* phase_def = wf ? wf->get_phase(data.state.current_phase) : nullptr; + + nlohmann::json response; + response["phase"] = worldbuilding::to_string(data.state.current_phase); + response["label"] = phase_def ? phase_def->label : ""; + response["active_workflow"] = data.active_workflow_name; + + nlohmann::json conds = nlohmann::json::array(); + for (auto& r : data.current_conditions.results) { + nlohmann::json cj; + cj["name"] = r.message; + cj["met"] = r.met; + if (r.current) cj["current"] = *r.current; + if (r.target) cj["target"] = *r.target; + conds.push_back(cj); + } + response["conditions"] = conds; + response["all_conditions_met"] = data.current_conditions.all_met; + + auto next_phases = worldbuilding::allowed_next_phases(data.state.current_phase); + nlohmann::json next_arr = nlohmann::json::array(); + for (auto& np : next_phases) next_arr.push_back(worldbuilding::to_string(np)); + response["next_allowed"] = next_arr; + response["allowed_retreat"] = phase_def ? nlohmann::json(phase_def->allowed_retreat) : nlohmann::json::array(); + + json_response(res, response); +} + } // namespace merak diff --git a/libs/worldbuilding/include/merak/worldbuilding/pipeline_manager.hpp b/libs/worldbuilding/include/merak/worldbuilding/pipeline_manager.hpp index 3b723d0a..de84df84 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/pipeline_manager.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/pipeline_manager.hpp @@ -86,6 +86,7 @@ class PipelineManager { // ─── Error handling ─── void handle_advance_failure(const std::string& world_id, const PipelineState& state, AdvanceResult result); void clear_last_error(const std::string& world_id); + void retreat_to_phase(const std::string& world_id, const std::string& to_phase); // ─── Metrics ─── struct PipelineMetrics { diff --git a/libs/worldbuilding/src/pipeline_manager.cpp b/libs/worldbuilding/src/pipeline_manager.cpp index 33958284..3bbd342b 100644 --- a/libs/worldbuilding/src/pipeline_manager.cpp +++ b/libs/worldbuilding/src/pipeline_manager.cpp @@ -970,6 +970,84 @@ void PipelineManager::clear_last_error(const std::string& world_id) { } } +// ─── Pipeline Retreat ─── +void PipelineManager::retreat_to_phase(const std::string& world_id, + const std::string& to_phase) { + // 1. Parse target phase string + auto target = creative_phase_from_string(to_phase); + if (!target) { + spdlog::warn("PipelineManager: retreat_to_phase - invalid phase string: {}", to_phase); + return; + } + + // 2. Load current state + auto state_opt = get_state(world_id); + if (!state_opt) { + spdlog::warn("PipelineManager: retreat_to_phase - no active state for world: {}", + world_id); + return; + } + + PipelineState state = *state_opt; + CreativePhase old_phase = state.current_phase; + + if (*target == old_phase) { + spdlog::info("PipelineManager: retreat_to_phase - already at target phase {}", + to_string(*target)); + return; + } + + // 3. Retreat current_phase to target phase + state.current_phase = *target; + state.last_updated = current_iso_timestamp(); + + // 4. Reset current_conditions stored in extra + state.extra["current_conditions"] = nlohmann::json::object(); + state.extra["current_conditions"]["phase_key"] = to_phase; + state.extra["current_conditions"]["results"] = nlohmann::json::array(); + + // 5. Save state + save_state(state); + + // 6. Create history record for this retreat + ConditionEvalSummary conditions; + conditions.phase_key = to_phase; + + PhaseTransitionRecord record{ + generate_uuid(), + world_id, + old_phase, + *target, + "manual_retreat", + std::string{"user_click"}, + conditions, + current_iso_timestamp() + }; + + // 7. Persist history + record_transition(record); + + // Emit SSE event for phase change + std::string wf_name; + { + std::shared_lock lock(world_mutex_); + auto it = worlds_.find(world_id); + if (it != worlds_.end()) { + wf_name = it->second.workflow_name; + } + } + const auto* wf = get_workflow(wf_name); + const auto* phase_def = wf ? wf->get_phase(*target) : nullptr; + if (deps_.event_emitter) { + AdvanceRequest dummy_req{world_id, *target, "manual_retreat", + std::string{"user_click"}, false, false}; + emit_phase_changed(state, phase_def, dummy_req); + } + + spdlog::info("PipelineManager: world {} retreated from {} to {}", + world_id, to_string(old_phase), to_string(*target)); +} + // ─── Metrics ─── PipelineManager::PipelineMetrics PipelineManager::get_metrics() const { PipelineMetrics m{}; From d63332ea2d409a64e848680bd5df789715972733 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:15:15 +0000 Subject: [PATCH 22/33] fix: pipeline retreat response format and validation - Wrap state fields under "state" key, add "ok": true at top level - Validate to_phase against creative_phase_from_string before retreating - Include recent_history in retreat response matching pipeline/state handler --- libs/http/src/worldbuilding_http_handler.cpp | 36 ++++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 03156de9..5d154898 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -2869,6 +2869,11 @@ void WorldbuildingHttpHandler::handle_pipeline_retreat(const httplib::Request& r } std::string to_phase = body["to_phase"]; + auto phase_opt = worldbuilding::creative_phase_from_string(to_phase); + if (!phase_opt) { + error_response(res, "Invalid to_phase: " + to_phase, 400, "invalid_phase"); + return; + } pipeline_mgr_->retreat_to_phase(world_id, to_phase); // Return state JSON matching pipeline/state handler format @@ -2877,9 +2882,12 @@ void WorldbuildingHttpHandler::handle_pipeline_retreat(const httplib::Request& r const auto* phase_def = wf ? wf->get_phase(data.state.current_phase) : nullptr; nlohmann::json response; - response["phase"] = worldbuilding::to_string(data.state.current_phase); - response["label"] = phase_def ? phase_def->label : ""; - response["active_workflow"] = data.active_workflow_name; + response["ok"] = true; + + nlohmann::json state; + state["phase"] = worldbuilding::to_string(data.state.current_phase); + state["label"] = phase_def ? phase_def->label : ""; + state["active_workflow"] = data.active_workflow_name; nlohmann::json conds = nlohmann::json::array(); for (auto& r : data.current_conditions.results) { @@ -2890,14 +2898,28 @@ void WorldbuildingHttpHandler::handle_pipeline_retreat(const httplib::Request& r if (r.target) cj["target"] = *r.target; conds.push_back(cj); } - response["conditions"] = conds; - response["all_conditions_met"] = data.current_conditions.all_met; + state["conditions"] = conds; + state["all_conditions_met"] = data.current_conditions.all_met; auto next_phases = worldbuilding::allowed_next_phases(data.state.current_phase); nlohmann::json next_arr = nlohmann::json::array(); for (auto& np : next_phases) next_arr.push_back(worldbuilding::to_string(np)); - response["next_allowed"] = next_arr; - response["allowed_retreat"] = phase_def ? nlohmann::json(phase_def->allowed_retreat) : nlohmann::json::array(); + state["next_allowed"] = next_arr; + state["allowed_retreat"] = phase_def ? nlohmann::json(phase_def->allowed_retreat) : nlohmann::json::array(); + + nlohmann::json history = nlohmann::json::array(); + for (auto& h : data.recent_history) { + nlohmann::json hj; + hj["id"] = h.id; + hj["from"] = worldbuilding::to_string(h.from_phase); + hj["to"] = worldbuilding::to_string(h.to_phase); + hj["trigger"] = h.trigger; + hj["timestamp"] = h.timestamp; + history.push_back(hj); + } + state["recent_history"] = history; + + response["state"] = state; json_response(res, response); } From 283213b8df4c4625fbb2172db68d92df28076659 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:21:33 +0000 Subject: [PATCH 23/33] fix: add allowed_retreat validation and scene cleanup to retreat_to_phase --- libs/worldbuilding/src/pipeline_manager.cpp | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/libs/worldbuilding/src/pipeline_manager.cpp b/libs/worldbuilding/src/pipeline_manager.cpp index 3bbd342b..ac2d1f6a 100644 --- a/libs/worldbuilding/src/pipeline_manager.cpp +++ b/libs/worldbuilding/src/pipeline_manager.cpp @@ -997,10 +997,41 @@ void PipelineManager::retreat_to_phase(const std::string& world_id, return; } + // 2b. Validate that retreat is allowed by the current phase definition + { + std::shared_lock lock(world_mutex_); + auto it = worlds_.find(world_id); + if (it != worlds_.end()) { + const auto* wf = get_workflow(it->second.workflow_name); + if (wf) { + const auto* phase_def = wf->get_phase(old_phase); + if (phase_def) { + auto target_str = to_string(*target); + if (std::find(phase_def->allowed_retreat.begin(), + phase_def->allowed_retreat.end(), target_str) == + phase_def->allowed_retreat.end()) { + spdlog::warn("PipelineManager: retreat_to_phase - phase '{}' from '{}' " + "not in allowed_retreat for world '{}'", + target_str, to_string(old_phase), world_id); + return; + } + } + } + } + } + // 3. Retreat current_phase to target phase state.current_phase = *target; state.last_updated = current_iso_timestamp(); + // Clear scene-level refs when retreating to pre-scene phases + if (*target == CreativePhase::DirectionSelection || + *target == CreativePhase::Worldbuilding || + *target == CreativePhase::CharacterCreation || + *target == CreativePhase::PlotArchitecture) { + state.active_scene_id = std::nullopt; + } + // 4. Reset current_conditions stored in extra state.extra["current_conditions"] = nlohmann::json::object(); state.extra["current_conditions"]["phase_key"] = to_phase; From 9aa39403689d439165440af31429c02cb390e60a Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:27:37 +0000 Subject: [PATCH 24/33] feat: add pipeline clear-error endpoint --- .../merak/worldbuilding_http_handler.hpp | 1 + libs/http/src/worldbuilding_http_handler.cpp | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index f4276e13..0ebdb2f0 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -90,6 +90,7 @@ class WorldbuildingHttpHandler { // Pipeline void handle_pipeline_retreat(const httplib::Request&, httplib::Response&); + void handle_pipeline_clear_error(const httplib::Request&, httplib::Response&); // Factions void handle_list_factions(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 5d154898..b1293067 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -722,6 +722,9 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Post(R"(/api/worldbuilding/([^/]+)/pipeline/retreat)", [this](const auto& req, auto& res) { handle_pipeline_retreat(req, res); }); + + server.Post(R"(/api/worldbuilding/([^/]+)/pipeline/clear-error)", + [this](const auto& req, auto& res) { handle_pipeline_clear_error(req, res); }); } // --- World handlers --- @@ -2924,4 +2927,20 @@ void WorldbuildingHttpHandler::handle_pipeline_retreat(const httplib::Request& r json_response(res, response); } +void WorldbuildingHttpHandler::handle_pipeline_clear_error(const httplib::Request& req, httplib::Response& res) { + std::string world_id = req.matches[1]; + if (!pipeline_mgr_) { + res.status = 503; + res.set_content(R"({"error":"pipeline_not_available"})", "application/json"); + return; + } + + try { + pipeline_mgr_->clear_last_error(world_id); + json_response(res, {{"ok", true}}); + } catch (const std::exception& e) { + error_response(res, std::string("Failed to clear error: ") + e.what(), 500, "clear_error_failed"); + } +} + } // namespace merak From 1a2b3f2bff2e0c8052cc6e63ea97ed23bb04f550 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:34:46 +0000 Subject: [PATCH 25/33] feat: add diary get, patch, and search endpoints --- .../merak/worldbuilding_http_handler.hpp | 3 + libs/http/src/worldbuilding_http_handler.cpp | 118 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 0ebdb2f0..43a26e79 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -169,6 +169,9 @@ class WorldbuildingHttpHandler { void handle_patch_agent(const httplib::Request&, httplib::Response&); void handle_agent_diary_list(const httplib::Request&, httplib::Response&); void handle_agent_diary_add(const httplib::Request&, httplib::Response&); + void handle_agent_diary_get(const httplib::Request&, httplib::Response&); + void handle_agent_diary_patch(const httplib::Request&, httplib::Response&); + void handle_agent_diary_search(const httplib::Request&, httplib::Response&); void handle_agent_relations(const httplib::Request&, httplib::Response&); // Chapter review diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index b1293067..1f9f41d3 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -347,6 +347,12 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_agent_diary_list(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/diaries)", [this](const auto& req, auto& res) { handle_agent_diary_add(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/diaries/search)", + [this](const auto& req, auto& res) { handle_agent_diary_search(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/diaries/([^/]+))", + [this](const auto& req, auto& res) { handle_agent_diary_get(req, res); }); + server.Patch(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/diaries/([^/]+))", + [this](const auto& req, auto& res) { handle_agent_diary_patch(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/relations)", [this](const auto& req, auto& res) { handle_agent_relations(req, res); }); server.Get(R"(/api/worldbuilding/agents/([^/]+)/prompt)", @@ -1525,6 +1531,118 @@ void WorldbuildingHttpHandler::handle_agent_diary_add(const httplib::Request& re } } +void WorldbuildingHttpHandler::handle_agent_diary_get(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + std::string did = req.matches[3]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + auto diary = service_->agents().get_diary(did); + if (!diary) { + error_response(res, "Diary not found", 404, "diary_not_found"); + return; + } + json_response(res, {{"ok", true}, {"diary", { + {"id", diary->id}, + {"agent_id", diary->agent_id}, + {"scene_id", diary->scene_id}, + {"world_time", diary->world_time}, + {"content", diary->content}, + {"mood", diary->mood}, + {"status", diary->status}, + {"leak_risk_level", diary->leak_risk_level}, + {"tokens_used", diary->tokens_used}, + {"created_at", diary->created_at} + }}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_agent_diary_patch(const httplib::Request& req, httplib::Response& res) { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + std::string did = req.matches[3]; + try { + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + auto diary = service_->agents().get_diary(did); + if (!diary) { + error_response(res, "Diary not found", 404, "diary_not_found"); + return; + } + auto body = nlohmann::json::parse(req.body); + std::string content = body.value("content", diary->content); + std::string mood = body.value("mood", diary->mood); + int leak_risk_level = body.value("leak_risk_level", diary->leak_risk_level); + int tokens_used = body.value("tokens_used", diary->tokens_used); + service_->agents().update_diary_content(did, content, mood, leak_risk_level, tokens_used); + json_response(res, {{"ok", true}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_agent_diary_search(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + if (!req.has_param("q")) { + error_response(res, "Missing required query parameter: q", 400, "missing_param"); + return; + } + std::string q = req.get_param_value("q"); + int limit = 20; + if (req.has_param("limit")) { + try { + limit = std::stoi(req.get_param_value("limit")); + } catch (...) { + error_response(res, "Invalid limit parameter", 400); + return; + } + } + auto diaries = service_->agents().search_diary(aid, q, limit); + nlohmann::json arr = nlohmann::json::array(); + for (auto& d : diaries) { + arr.push_back({ + {"id", d.id}, + {"agent_id", d.agent_id}, + {"scene_id", d.scene_id}, + {"content", d.content}, + {"world_time", d.world_time}, + {"created_at", d.created_at} + }); + } + json_response(res, {{"ok", true}, {"diaries", arr}, {"total", diaries.size()}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + // --- Agent relations --- void WorldbuildingHttpHandler::handle_agent_relations(const httplib::Request& req, httplib::Response& res) { From 7ff415d13f5fa97d36d27d1f4dd42ee28862c34c Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:36:00 +0000 Subject: [PATCH 26/33] feat: add memory summary list and get endpoints --- .../merak/worldbuilding_http_handler.hpp | 4 + libs/http/src/worldbuilding_http_handler.cpp | 85 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 43a26e79..48616769 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -174,6 +174,10 @@ class WorldbuildingHttpHandler { void handle_agent_diary_search(const httplib::Request&, httplib::Response&); void handle_agent_relations(const httplib::Request&, httplib::Response&); + // Memory summaries + void handle_memory_summaries_list(const httplib::Request&, httplib::Response&); + void handle_memory_summary_get(const httplib::Request&, httplib::Response&); + // Chapter review void handle_chapter_review(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 1f9f41d3..2430266b 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -355,6 +355,10 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_agent_diary_patch(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/relations)", [this](const auto& req, auto& res) { handle_agent_relations(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/memory-summaries)", + [this](const auto& req, auto& res) { handle_memory_summaries_list(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/memory-summaries/([^/]+))", + [this](const auto& req, auto& res) { handle_memory_summary_get(req, res); }); server.Get(R"(/api/worldbuilding/agents/([^/]+)/prompt)", [this](const auto& req, auto& res) { handle_load_agent_prompt(req, res); }); @@ -1677,6 +1681,87 @@ void WorldbuildingHttpHandler::handle_agent_relations(const httplib::Request& re } } +// --- Memory summaries --- + +void WorldbuildingHttpHandler::handle_memory_summaries_list(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + int limit = 50; + if (req.has_param("limit")) { + try { + limit = std::stoi(req.get_param_value("limit")); + } catch (...) { + error_response(res, "Invalid limit parameter", 400); + return; + } + } + auto summaries = service_->agents().recent_summaries(aid, limit); + nlohmann::json arr = nlohmann::json::array(); + for (auto& s : summaries) { + arr.push_back({ + {"id", s.id}, + {"period_start", s.period_start}, + {"period_end", s.period_end}, + {"summary", s.summary}, + {"source_diary_ids", s.source_diary_ids}, + {"created_at", s.created_at} + }); + } + json_response(res, {{"ok", true}, {"summaries", arr}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_memory_summary_get(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + std::string mid = req.matches[3]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + auto summaries = service_->agents().recent_summaries(aid, 200); + const worldbuilding::MemorySummary* found = nullptr; + for (auto& s : summaries) { + if (s.id == mid) { + found = &s; + break; + } + } + if (!found) { + error_response(res, "Memory summary not found", 404, "memory_summary_not_found"); + return; + } + json_response(res, {{"ok", true}, {"summary", { + {"id", found->id}, + {"period_start", found->period_start}, + {"period_end", found->period_end}, + {"summary", found->summary}, + {"source_diary_ids", found->source_diary_ids}, + {"created_at", found->created_at} + }}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + // --- PATCH scene / chapter --- void WorldbuildingHttpHandler::handle_patch_scene(const httplib::Request& req, httplib::Response& res) { From 0a59869cb7a8b3051ef730410452bd2d57e5d603 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 09:40:16 +0000 Subject: [PATCH 27/33] feat: add agent relation create/update and voice fingerprint endpoints --- .../merak/worldbuilding_http_handler.hpp | 3 + libs/http/src/worldbuilding_http_handler.cpp | 106 ++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 48616769..6acc5680 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -173,6 +173,9 @@ class WorldbuildingHttpHandler { void handle_agent_diary_patch(const httplib::Request&, httplib::Response&); void handle_agent_diary_search(const httplib::Request&, httplib::Response&); void handle_agent_relations(const httplib::Request&, httplib::Response&); + void handle_agent_relation_upsert(const httplib::Request&, httplib::Response&); + void handle_agent_relation_update(const httplib::Request&, httplib::Response&); + void handle_agent_voice(const httplib::Request&, httplib::Response&); // Memory summaries void handle_memory_summaries_list(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 2430266b..db87034a 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -355,6 +355,12 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_agent_diary_patch(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/relations)", [this](const auto& req, auto& res) { handle_agent_relations(req, res); }); + server.Post(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/relations)", + [this](const auto& req, auto& res) { handle_agent_relation_upsert(req, res); }); + server.Patch(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/relations/([^/]+))", + [this](const auto& req, auto& res) { handle_agent_relation_update(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/voice)", + [this](const auto& req, auto& res) { handle_agent_voice(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/memory-summaries)", [this](const auto& req, auto& res) { handle_memory_summaries_list(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/agents/([^/]+)/memory-summaries/([^/]+))", @@ -1681,6 +1687,106 @@ void WorldbuildingHttpHandler::handle_agent_relations(const httplib::Request& re } } +// --- Agent relation create --- + +void WorldbuildingHttpHandler::handle_agent_relation_upsert(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + auto body = nlohmann::json::parse(req.body); + if (!body.contains("target_id") || !body["target_id"].is_string()) { + error_response(res, "target_id is required", 400); + return; + } + worldbuilding::RelationEntry rel; + rel.agent_id = aid; + rel.target_id = body["target_id"].get(); + rel.relation_type = body.value("relation_type", ""); + rel.description = body.value("description", ""); + rel.intimacy = body.value("intimacy", 0); + service_->agents().upsert_relation(rel); + json_response(res, {{"ok", true}}, 201); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +// --- Agent relation update --- + +void WorldbuildingHttpHandler::handle_agent_relation_update(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + std::string tid = req.matches[3]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + auto body = nlohmann::json::parse(req.body); + worldbuilding::RelationEntry rel; + rel.agent_id = aid; + rel.target_id = tid; + if (body.contains("relation_type")) rel.relation_type = body["relation_type"].get(); + if (body.contains("description")) rel.description = body["description"].get(); + if (body.contains("intimacy")) rel.intimacy = body["intimacy"].get(); + service_->agents().upsert_relation(rel); + json_response(res, {{"ok", true}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +// --- Agent voice fingerprint --- + +void WorldbuildingHttpHandler::handle_agent_voice(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string aid = req.matches[2]; + auto agent = service_->agents().get_agent(aid); + if (!agent) { + error_response(res, "Agent not found", 404, "agent_not_found"); + return; + } + if (agent->world_id != wid) { + error_response(res, "Agent not found in this world", 404, "agent_not_found"); + return; + } + auto fp = service_->voice().fingerprint_for(aid); + if (!fp) { + error_response(res, "Voice fingerprint not found", 404, "not_found"); + return; + } + json_response(res, { + {"ok", true}, + {"voice", { + {"avg_sentence_length", fp->avg_sentence_length}, + {"sentence_variance", fp->sentence_variance}, + {"question_frequency", fp->question_frequency}, + {"modifier_ratio", fp->modifier_ratio}, + {"sample_count", fp->sample_count}, + {"signature_words", fp->signature_words}, + {"tone_profile", fp->tone_profile} + }} + }); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + // --- Memory summaries --- void WorldbuildingHttpHandler::handle_memory_summaries_list(const httplib::Request& req, httplib::Response& res) { From 1e15001b61109867c617035e9bb14ddf56827baa Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 10:41:59 +0000 Subject: [PATCH 28/33] feat: add world export-full endpoint --- .../merak/worldbuilding_http_handler.hpp | 1 + libs/http/src/worldbuilding_http_handler.cpp | 89 +++++ .../include/merak/worldbuilding/ids.hpp | 19 + .../worldbuilding/worldbuilding_service.hpp | 13 + .../src/worldbuilding_service.cpp | 324 ++++++++++++++++++ 5 files changed, 446 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 6acc5680..f66ebc88 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -186,6 +186,7 @@ class WorldbuildingHttpHandler { // Export void handle_export_chapters(const httplib::Request&, httplib::Response&); + void handle_export_full(const httplib::Request&, httplib::Response&); // PATCH routes for narrative entities void handle_patch_scene(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index db87034a..84f63859 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -415,6 +416,8 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_chapter_review(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/export)", [this](const auto& req, auto& res) { handle_export_chapters(req, res); }); + server.Post(R"(/api/worldbuilding/([^/]+)/export-full)", + [this](const auto& req, auto& res) { handle_export_full(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/scenes)", [this](const auto& req, auto& res) { handle_list_scenes(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/scenes)", @@ -2326,6 +2329,92 @@ void WorldbuildingHttpHandler::handle_export_chapters(const httplib::Request& re } } +void WorldbuildingHttpHandler::handle_export_full(const httplib::Request& req, httplib::Response& res) { + try { + std::string world_id = req.matches[1]; + + bool include_diaries = false; + bool include_memories = false; + if (!req.body.empty()) { + auto body = nlohmann::json::parse(req.body); + include_diaries = body.value("include_diaries", false); + include_memories = body.value("include_memories", false); + } + + auto snapshot = service_->export_world_snapshot(world_id, include_diaries, include_memories); + + // Enrich with image data if image_service_ is available + if (image_service_ && snapshot.payload.contains("agents") && snapshot.payload["agents"].is_array()) { + for (auto& agent_json : snapshot.payload["agents"]) { + if (!agent_json.contains("id")) continue; + std::string agent_id = agent_json["id"].get(); + + auto images = image_service_->list_images(agent_id); + nlohmann::json imgs_arr = nlohmann::json::array(); + for (const auto& img : images) { + nlohmann::json img_json; + img_json["id"] = img.id; + img_json["agent_id"] = img.agent_id; + img_json["image_type"] = img.image_type; + img_json["storage_key"] = img.storage_key; + img_json["mime_type"] = img.mime_type; + img_json["original_name"] = img.original_name; + img_json["file_size_bytes"] = img.file_size_bytes; + img_json["is_primary"] = img.is_primary; + img_json["sort_order"] = img.sort_order; + img_json["created_at"] = img.created_at; + + // Inline small images as base64, mark large ones as external + const int64_t kMaxInlineBytes = 256 * 1024; // 256 KB + if (img.file_size_bytes > 0 && img.file_size_bytes <= kMaxInlineBytes) { + try { + auto image_data = image_service_->store().load(img.storage_key); + if (!image_data.bytes.empty()) { + std::string b64 = worldbuilding::base64_encode(image_data.bytes); + img_json["data"] = "data:" + img.mime_type + ";base64," + b64; + } else { + img_json["data"] = nullptr; + } + } catch (...) { + img_json["data"] = nullptr; + img_json["transfer"] = "external"; + } + } else { + img_json["data"] = nullptr; + img_json["transfer"] = "external"; + } + imgs_arr.push_back(img_json); + } + agent_json["images"] = imgs_arr; + + // Update image count in manifest + if (!images.empty()) { + int existing = snapshot.manifest.value("image_count", 0); + snapshot.manifest["image_count"] = existing + static_cast(images.size()); + } + } + // Ensure image count is present even if zero + if (!snapshot.manifest.contains("image_count")) { + snapshot.manifest["image_count"] = 0; + } + } + + json_response(res, { + {"ok", true}, + {"snapshot", { + {"schema_version", snapshot.schema_version}, + {"exported_at", snapshot.exported_at}, + {"snapshot_id", snapshot.snapshot_id}, + {"source", snapshot.source}, + {"manifest", snapshot.manifest}, + {"payload", snapshot.payload} + }} + }); + } catch (const std::exception& e) { + error_response(res, e.what(), 500, "export_failed"); + } +} + // --- Delete agent --- void WorldbuildingHttpHandler::handle_delete_agent(const httplib::Request& req, httplib::Response& res) { diff --git a/libs/worldbuilding/include/merak/worldbuilding/ids.hpp b/libs/worldbuilding/include/merak/worldbuilding/ids.hpp index 38dba419..5135574b 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/ids.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/ids.hpp @@ -3,12 +3,31 @@ #include #include #include +#include namespace merak::worldbuilding { std::string make_id(std::string_view prefix); std::string now_iso_utc(); +inline std::string base64_encode(const std::vector& data) { + static const char kBase64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string b64; + b64.reserve(((data.size() + 2) / 3) * 4); + for (size_t i = 0; i < data.size(); i += 3) { + uint32_t n = static_cast(data[i]) << 16; + if (i + 1 < data.size()) n |= static_cast(data[i+1]) << 8; + if (i + 2 < data.size()) n |= static_cast(data[i+2]); + b64 += kBase64[(n >> 18) & 63]; + b64 += kBase64[(n >> 12) & 63]; + b64 += kBase64[(n >> 6) & 63]; + b64 += kBase64[n & 63]; + } + if (data.size() % 3) { b64[b64.size() - 1] = '='; } + if (data.size() % 3 == 1) { b64[b64.size() - 2] = '='; } + return b64; +} + inline void remove_all_no_throw(const std::filesystem::path& path) noexcept { try { std::filesystem::remove_all(path); } catch (...) {} } diff --git a/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp b/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp index f2eaf451..0688a2c9 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp @@ -168,6 +168,19 @@ class WorldbuildingService { const std::string& title, const std::string& author); + struct WorldSnapshot { + std::string schema_version = "1.0"; + std::string exported_at; + std::string snapshot_id; + nlohmann::json source; + nlohmann::json manifest; + nlohmann::json payload; + }; + + WorldSnapshot export_world_snapshot(const std::string& world_id, + bool include_diaries = false, + bool include_memories = false); + nlohmann::json get_dashboard(const std::string& world_id) const; private: diff --git a/libs/worldbuilding/src/worldbuilding_service.cpp b/libs/worldbuilding/src/worldbuilding_service.cpp index 6acb35ad..eebef46c 100644 --- a/libs/worldbuilding/src/worldbuilding_service.cpp +++ b/libs/worldbuilding/src/worldbuilding_service.cpp @@ -886,5 +886,329 @@ nlohmann::json WorldbuildingService::get_dashboard(const std::string& world_id) return dash; } +WorldbuildingService::WorldSnapshot +WorldbuildingService::export_world_snapshot(const std::string& world_id, + bool include_diaries, + bool include_memories) { + auto world = worlds_.get_world(world_id); + if (!world) { + throw std::runtime_error("World not found: " + world_id); + } + + WorldSnapshot snap; + snap.schema_version = "1.0"; + snap.snapshot_id = make_id("snap"); + snap.exported_at = now_iso_utc(); + + snap.source = { + {"world_id", world->id}, + {"name", world->name}, + {"description", world->description} + }; + + nlohmann::json& p = snap.payload; + + // ── Agents ── + auto agent_list = worlds_.list_agents(world_id); + nlohmann::json agents_arr = nlohmann::json::array(); + for (const auto& a : agent_list) { + nlohmann::json aj; + aj["id"] = a.id; + aj["world_id"] = a.world_id; + aj["name"] = a.name; + aj["display_name"] = a.display_name; + aj["kind"] = to_string(a.kind); + aj["created_at"] = a.created_at; + aj["updated_at"] = a.updated_at; + + // CharacterCard + auto card = agents_.load_character_card(a.id); + nlohmann::json card_json; + card_json["name"] = card.name; + card_json["gender"] = card.gender; + card_json["race"] = card.race; + card_json["identity"] = card.identity; + card_json["emotional_tendency"] = card.emotional_tendency; + card_json["speaking_style"] = card.speaking_style; + card_json["core_desire"] = card.core_desire; + card_json["deep_fear"] = card.deep_fear; + card_json["daily_goal"] = card.daily_goal; + card_json["background"] = card.background; + card_json["knowledge_scope"] = card.knowledge_scope; + card_json["appearance"] = card.appearance; + card_json["age"] = card.age; + card_json["version"] = card.version; + card_json["core_traits"] = card.core_traits; + card_json["taboo_topics"] = card.taboo_topics; + card_json["relations"] = card.relations; + card_json["updated_at"] = card.updated_at; + aj["card"] = card_json; + + // Diaries (if requested) + if (include_diaries) { + auto diaries = agents_.recent_diary(a.id, 1000); + nlohmann::json diary_arr = nlohmann::json::array(); + for (const auto& d : diaries) { + diary_arr.push_back({ + {"id", d.id}, + {"agent_id", d.agent_id}, + {"scene_id", d.scene_id}, + {"world_time", d.world_time}, + {"content", d.content}, + {"created_at", d.created_at}, + {"mood", d.mood}, + {"status", d.status}, + {"leak_risk_level", d.leak_risk_level}, + {"tokens_used", d.tokens_used} + }); + } + aj["diaries"] = diary_arr; + } + + // Memory summaries (if requested) + if (include_memories) { + auto summaries = agents_.recent_summaries(a.id, 100); + nlohmann::json summary_arr = nlohmann::json::array(); + for (const auto& s : summaries) { + summary_arr.push_back({ + {"id", s.id}, + {"agent_id", s.agent_id}, + {"period_start", s.period_start}, + {"period_end", s.period_end}, + {"summary", s.summary}, + {"created_at", s.created_at}, + {"source_diary_ids", s.source_diary_ids} + }); + } + aj["memory_summaries"] = summary_arr; + } + + // Relations + auto relations = agents_.relations_for(a.id); + nlohmann::json rel_arr = nlohmann::json::array(); + for (const auto& r : relations) { + rel_arr.push_back({ + {"agent_id", r.agent_id}, + {"target_id", r.target_id}, + {"relation_type", r.relation_type}, + {"description", r.description}, + {"intimacy", r.intimacy}, + {"key_events", r.key_events}, + {"updated_at", r.updated_at} + }); + } + aj["relations"] = rel_arr; + + agents_arr.push_back(aj); + } + p["agents"] = agents_arr; + + // ── Chapters with Scenes ── + auto chapter_list = narrative_.list_chapters(world_id); + nlohmann::json chapters_arr = nlohmann::json::array(); + for (const auto& ch_summary : chapter_list) { + auto ch = narrative_.get_chapter(world_id, ch_summary.id); + if (!ch) continue; + nlohmann::json cj; + cj["id"] = ch->id; + cj["title"] = ch->title; + cj["pitch"] = ch->pitch; + cj["notes"] = ch->notes; + cj["content"] = ch->content; + cj["number"] = ch->number; + cj["arc_id"] = ch->arc_id.has_value() ? nlohmann::json(*ch->arc_id) : nlohmann::json(nullptr); + cj["status"] = to_string(ch->status); + cj["emotional_curve"] = ch->emotional_curve; + cj["scene_ids"] = ch->scene_ids; + cj["foreshadowing_planted"] = ch->foreshadowing_planted; + cj["foreshadowing_paid"] = ch->foreshadowing_paid; + + // Scenes for this chapter + auto scene_list = narrative_.list_scenes(world_id, ch->id); + nlohmann::json scenes_arr = nlohmann::json::array(); + for (const auto& s_summary : scene_list) { + auto s = narrative_.get_scene(world_id, s_summary.id); + if (!s) continue; + nlohmann::json sj; + sj["id"] = s->id; + sj["title"] = s->title; + sj["chapter_id"] = s->chapter_id; + sj["world_time"] = s->world_time; + sj["narrative"] = s->narrative; + sj["section_id"] = s->section_id.has_value() ? nlohmann::json(*s->section_id) : nlohmann::json(nullptr); + sj["location_id"] = s->location_id.has_value() ? nlohmann::json(*s->location_id) : nlohmann::json(nullptr); + sj["participant_ids"] = s->participant_ids; + sj["status"] = to_string(s->status); + sj["pov_character_id"] = s->pov_character_id.has_value() ? nlohmann::json(*s->pov_character_id) : nlohmann::json(nullptr); + sj["plot_goal"] = s->plot_goal; + sj["emotional_goal"] = s->emotional_goal; + sj["information_goal"] = s->information_goal; + sj["external_conflict"] = s->external_conflict; + sj["internal_conflict"] = s->internal_conflict; + sj["hidden_conflict"] = s->hidden_conflict.has_value() ? nlohmann::json(*s->hidden_conflict) : nlohmann::json(nullptr); + sj["foreshadowing_ids"] = s->foreshadowing_ids; + sj["style_overrides"] = s->style_overrides; + scenes_arr.push_back(sj); + } + cj["scenes"] = scenes_arr; + chapters_arr.push_back(cj); + } + p["chapters"] = chapters_arr; + + // ── Arcs ── + auto arc_list = narrative_.list_arcs(world_id); + nlohmann::json arcs_arr = nlohmann::json::array(); + for (const auto& arc : arc_list) { + arcs_arr.push_back({ + {"id", arc.id}, + {"title", arc.title}, + {"purpose", arc.purpose}, + {"status", arc.status}, + {"updated_at", arc.updated_at} + }); + } + p["arcs"] = arcs_arr; + + // ── Locations ── + auto locations = worlds_.list_locations(world_id); + nlohmann::json loc_arr = nlohmann::json::array(); + for (const auto& loc : locations) { + nlohmann::json lj{ + {"id", loc.id}, + {"name", loc.name}, + {"description", loc.description}, + {"region", loc.region}, + {"created_at", loc.created_at} + }; + lj["parent_location_id"] = loc.parent_location_id.has_value() + ? nlohmann::json(*loc.parent_location_id) : nlohmann::json(nullptr); + loc_arr.push_back(lj); + } + p["locations"] = loc_arr; + + // ── Factions ── + auto factions = worlds_.list_factions(world_id); + nlohmann::json fac_arr = nlohmann::json::array(); + for (const auto& f : factions) { + fac_arr.push_back({ + {"id", f.id}, + {"world_id", f.world_id}, + {"name", f.name}, + {"description", f.description}, + {"goals", f.goals}, + {"member_agent_ids", f.member_agent_ids}, + {"rival_faction_ids", f.rival_faction_ids}, + {"created_at", f.created_at}, + {"updated_at", f.updated_at} + }); + } + p["factions"] = fac_arr; + + // ── Knowledge ── + auto knowledge = worlds_.get_world_knowledge(world_id, ""); + nlohmann::json kn_arr = nlohmann::json::array(); + for (const auto& k : knowledge) { + kn_arr.push_back({ + {"id", k.id}, + {"category", k.category}, + {"content", k.content}, + {"created_at", k.created_at}, + {"tags", k.tags}, + {"aliases", k.aliases}, + {"related_ids", k.related_ids} + }); + } + p["knowledge"] = kn_arr; + + // ── Foreshadowing ── + auto foreshadowings = foreshadowing_.list(world_id, std::nullopt); + nlohmann::json fs_arr = nlohmann::json::array(); + for (const auto& f : foreshadowings) { + nlohmann::json fj{ + {"id", f.id}, + {"content", f.content}, + {"pay_off_idea", f.pay_off_idea}, + {"status", to_string(f.status)}, + {"hint_level", to_string(f.hint_level)}, + {"tags", f.tags}, + {"related_foreshadowing_ids", f.related_foreshadowing_ids}, + {"related_secret_ids", f.related_secret_ids}, + {"created_by", to_string(f.created_by)} + }; + fj["planted_at"] = f.planted_at.has_value() ? nlohmann::json(*f.planted_at) : nlohmann::json(nullptr); + fj["paid_at"] = f.paid_at.has_value() ? nlohmann::json(*f.paid_at) : nlohmann::json(nullptr); + fs_arr.push_back(fj); + } + p["foreshadowing"] = fs_arr; + + // ── Secrets ── + auto active_secrets = secrets_.list(world_id, SecretStatus::Active); + auto exposed_secrets = secrets_.list(world_id, SecretStatus::Exposed); + auto abandoned_secrets = secrets_.list(world_id, SecretStatus::Abandoned); + nlohmann::json sec_arr = nlohmann::json::array(); + auto add_secret = [&](const auto& s) { + nlohmann::json sj{ + {"id", s.id}, + {"holder_id", s.holder_id}, + {"truth", s.truth}, + {"public_version", s.public_version}, + {"stakes", s.stakes}, + {"aware_character_ids", s.aware_character_ids}, + {"suspicious_character_ids", s.suspicious_character_ids}, + {"related_foreshadowing_ids", s.related_foreshadowing_ids}, + {"believed_truths", s.believed_truths}, + {"status", to_string(s.status)} + }; + sj["planted_at"] = s.planted_at.has_value() ? nlohmann::json(*s.planted_at) : nlohmann::json(nullptr); + sj["exposed_at"] = s.exposed_at.has_value() ? nlohmann::json(*s.exposed_at) : nlohmann::json(nullptr); + sec_arr.push_back(sj); + }; + for (const auto& s : active_secrets) add_secret(s); + for (const auto& s : exposed_secrets) add_secret(s); + for (const auto& s : abandoned_secrets) add_secret(s); + p["secrets"] = sec_arr; + + // ── Timeline ── + { + auto timeline_path = worlds_.world_path(world_id) / "timeline.json"; + nlohmann::json timeline_events = nlohmann::json::array(); + if (std::filesystem::exists(timeline_path)) { + try { + std::ifstream tf(timeline_path); + if (tf) { + auto timeline_data = nlohmann::json::parse(tf); + if (timeline_data.contains("events") && timeline_data["events"].is_array()) { + timeline_events = timeline_data["events"]; + } + } + } catch (...) { + // If timeline file is corrupted, export empty array + timeline_events = nlohmann::json::array(); + } + } + p["timeline"] = timeline_events; + } + + // ── File links ── + p["file_links"] = worlds_.list_file_links(world_id); + + // ── Manifest ── + snap.manifest = { + {"schema_version", snap.schema_version}, + {"exported_at", snap.exported_at}, + {"snapshot_id", snap.snapshot_id}, + {"agent_count", agents_arr.size()}, + {"chapter_count", chapters_arr.size()}, + {"arc_count", arcs_arr.size()}, + {"location_count", loc_arr.size()}, + {"faction_count", fac_arr.size()}, + {"knowledge_count", kn_arr.size()}, + {"foreshadowing_count", fs_arr.size()}, + {"secret_count", sec_arr.size()}, + {"timeline_event_count", p["timeline"].is_array() ? p["timeline"].size() : 0} + }; + + return snap; +} } // namespace merak::worldbuilding From 60ac4819ec45176bfcb87d0c615a4c16e4490793 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 11:04:32 +0000 Subject: [PATCH 29/33] feat: add world import endpoint Implements POST /api/worldbuilding/import to import a snapshot JSON exported by the export-full endpoint. Remaps all IDs to new IDs via make_id() and creates all entities in dependency order: - World, locations (two-pass parent fixup), agents (individuals, managers, groups), relations, arcs, chapters, scenes, diaries, memory summaries, factions (two-pass rival fixup), knowledge, foreshadowing (two-pass cross-refs), secrets, timeline events - Stores import metadata in world config - Imports inline base64 images (up to 256KB) via image_service_ - Adds base64_decode to ids.hpp for inline image decoding - Returns world_id, full id_mapping, and images_imported count --- .../merak/worldbuilding_http_handler.hpp | 3 +- libs/http/src/worldbuilding_http_handler.cpp | 87 +++ .../include/merak/worldbuilding/ids.hpp | 31 + .../worldbuilding/worldbuilding_service.hpp | 8 + .../src/worldbuilding_service.cpp | 631 ++++++++++++++++++ 5 files changed, 759 insertions(+), 1 deletion(-) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index f66ebc88..1322784a 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -184,9 +184,10 @@ class WorldbuildingHttpHandler { // Chapter review void handle_chapter_review(const httplib::Request&, httplib::Response&); - // Export + // Export & Import void handle_export_chapters(const httplib::Request&, httplib::Response&); void handle_export_full(const httplib::Request&, httplib::Response&); + void handle_import_snapshot(const httplib::Request&, httplib::Response&); // PATCH routes for narrative entities void handle_patch_scene(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 84f63859..fe305328 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -5,6 +5,8 @@ #include #include +#include + #include #include #include @@ -418,6 +420,8 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_export_chapters(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/export-full)", [this](const auto& req, auto& res) { handle_export_full(req, res); }); + server.Post("/api/worldbuilding/import", + [this](const auto& req, auto& res) { handle_import_snapshot(req, res); }); server.Get(R"(/api/worldbuilding/([^/]+)/scenes)", [this](const auto& req, auto& res) { handle_list_scenes(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/scenes)", @@ -2415,6 +2419,89 @@ void WorldbuildingHttpHandler::handle_export_full(const httplib::Request& req, h } } +void WorldbuildingHttpHandler::handle_import_snapshot(const httplib::Request& req, httplib::Response& res) { + try { + auto body = nlohmann::json::parse(req.body); + if (!body.contains("snapshot")) { + error_response(res, "missing required field: snapshot", 400, "missing_field"); + return; + } + const auto& snapshot = body["snapshot"]; + std::optional target_name; + if (body.contains("target_name") && body["target_name"].is_string()) { + target_name = body["target_name"].get(); + } + + auto result = service_->import_snapshot(snapshot, target_name); + + // Import images if image_service_ is available and snapshot has agents + int images_imported = 0; + if (image_service_ && snapshot.contains("payload")) { + const auto& payload = snapshot["payload"]; + if (payload.contains("agents") && payload["agents"].is_array()) { + for (const auto& agent_json : payload["agents"]) { + if (!agent_json.contains("id") || !agent_json.contains("images")) continue; + if (!agent_json["images"].is_array()) continue; + + std::string old_agent_id = agent_json["id"].get(); + auto it = result.id_mapping.find(old_agent_id); + if (it == result.id_mapping.end()) continue; + std::string new_agent_id = it->second; + + for (const auto& img_json : agent_json["images"]) { + if (!img_json.contains("data") || img_json["data"].is_null()) continue; + std::string data_uri = img_json["data"].get(); + // Only import inline images (skip external references) + if (data_uri.empty() || data_uri.find("data:") != 0) continue; + + // Parse data URI: data:;base64, + auto comma_pos = data_uri.find(','); + if (comma_pos == std::string::npos) continue; + std::string header = data_uri.substr(0, comma_pos); + std::string b64_data = data_uri.substr(comma_pos + 1); + + std::string mime_type = img_json.value("mime_type", "image/png"); + // Extract mime from header: "data:image/png;base64" + auto colon_pos = header.find(':'); + auto semi_pos = header.find(';'); + if (colon_pos != std::string::npos && semi_pos != std::string::npos) { + mime_type = header.substr(colon_pos + 1, semi_pos - colon_pos - 1); + } + + auto decoded = worldbuilding::base64_decode(b64_data); + if (!decoded || decoded->empty()) continue; + + try { + image_service_->upload( + result.world_id, + new_agent_id, + img_json.value("image_type", "avatar"), + img_json.value("original_name", "imported"), + mime_type, + *decoded); + images_imported++; + } catch (const std::exception& e) { + spdlog::warn("import_snapshot: failed to import image for agent {}: {}", + new_agent_id, e.what()); + } + } + } + } + } + + nlohmann::json response = { + {"ok", true}, + {"world_id", result.world_id}, + {"id_mapping", result.id_mapping}, + {"images_imported", images_imported} + }; + json_response(res, response, 201); + + } catch (const std::exception& e) { + error_response(res, e.what(), 500, "import_failed"); + } +} + // --- Delete agent --- void WorldbuildingHttpHandler::handle_delete_agent(const httplib::Request& req, httplib::Response& res) { diff --git a/libs/worldbuilding/include/merak/worldbuilding/ids.hpp b/libs/worldbuilding/include/merak/worldbuilding/ids.hpp index 5135574b..8cf95edf 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/ids.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/ids.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -28,6 +30,35 @@ inline std::string base64_encode(const std::vector& data) { return b64; } +inline std::optional> base64_decode(std::string_view input) { + static const auto kDecodeTable = []() { + std::array t; + t.fill(-1); + for (int i = 0; i < 64; i++) { + t[static_cast( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[i])] = i; + } + return t; + }(); + + std::vector out; + out.reserve((input.size() / 4) * 3); + + int val = 0, valb = -8; + for (unsigned char c : input) { + if (c == '=') break; + int idx = kDecodeTable[c]; + if (idx == -1) continue; // skip non-base64 chars (e.g. whitespace) + val = (val << 6) | idx; + valb += 6; + if (valb >= 0) { + out.push_back(static_cast((val >> valb) & 0xFF)); + valb -= 8; + } + } + return out; +} + inline void remove_all_no_throw(const std::filesystem::path& path) noexcept { try { std::filesystem::remove_all(path); } catch (...) {} } diff --git a/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp b/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp index 0688a2c9..76437551 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp @@ -181,6 +181,14 @@ class WorldbuildingService { bool include_diaries = false, bool include_memories = false); + struct ImportResult { + std::string world_id; + std::unordered_map id_mapping; // old_id -> new_id + }; + + ImportResult import_snapshot(const nlohmann::json& snapshot, + const std::optional& target_name = std::nullopt); + nlohmann::json get_dashboard(const std::string& world_id) const; private: diff --git a/libs/worldbuilding/src/worldbuilding_service.cpp b/libs/worldbuilding/src/worldbuilding_service.cpp index eebef46c..57fddf36 100644 --- a/libs/worldbuilding/src/worldbuilding_service.cpp +++ b/libs/worldbuilding/src/worldbuilding_service.cpp @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -1211,4 +1212,634 @@ WorldbuildingService::export_world_snapshot(const std::string& world_id, return snap; } +WorldbuildingService::ImportResult +WorldbuildingService::import_snapshot(const nlohmann::json& snapshot, + const std::optional& target_name) { + ImportResult result; + auto& id_map = result.id_mapping; + + // Remap helpers + auto remap = [&](const std::string& old_id) -> std::string { + if (old_id.empty()) return old_id; + auto it = id_map.find(old_id); + return it != id_map.end() ? it->second : old_id; + }; + + auto remap_vec = [&](const std::vector& ids) -> std::vector { + std::vector out; + for (const auto& id : ids) out.push_back(remap(id)); + return out; + }; + + auto remap_json_arr = [&](const nlohmann::json& arr) -> std::vector { + std::vector out; + if (arr.is_array()) { + for (const auto& id : arr) { + if (id.is_string()) out.push_back(remap(id.get())); + } + } + return out; + }; + + const auto& source = snapshot.value("source", nlohmann::json::object()); + const auto& payload = snapshot.value("payload", nlohmann::json::object()); + + // ═══════════════════════════════════════════════════════════════ + // Step 1: Create world + // ═══════════════════════════════════════════════════════════════ + std::string imported_name = source.value("name", "Imported World"); + std::string world_name = target_name.value_or(imported_name + " (imported)"); + std::string world_desc = source.value("description", ""); + auto world = worlds_.create_world(world_name, world_desc); + result.world_id = world.id; + + std::string old_world_id = source.value("world_id", ""); + if (!old_world_id.empty()) id_map[old_world_id] = world.id; + + spdlog::info("import_snapshot: created world {} <- {}", world.id, old_world_id); + + // ═══════════════════════════════════════════════════════════════ + // Step 2: Create locations (two-pass for parent_location_id) + // ═══════════════════════════════════════════════════════════════ + std::vector> loc_parent_fixup; + + if (payload.contains("locations") && payload["locations"].is_array()) { + for (const auto& lj : payload["locations"]) { + Location loc; + loc.name = lj.value("name", "Unnamed Location"); + loc.description = lj.value("description", ""); + loc.region = lj.value("region", ""); + + auto created = worlds_.add_location(world.id, std::move(loc)); + std::string old_id = lj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + + if (lj.contains("parent_location_id") && !lj["parent_location_id"].is_null() + && lj["parent_location_id"].is_string()) { + loc_parent_fixup.emplace_back(created.id, lj["parent_location_id"].get()); + } + } + + for (const auto& [new_id, old_parent] : loc_parent_fixup) { + std::string new_parent = remap(old_parent); + if (!new_parent.empty()) { + worlds_.update_location(world.id, new_id, + {{"parent_location_id", new_parent}}); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 3: Create individual agents + managers (groups deferred) + // ═══════════════════════════════════════════════════════════════ + struct PendingGroup { + nlohmann::json data; + std::string old_id; + }; + std::vector pending_groups; + + if (payload.contains("agents") && payload["agents"].is_array()) { + for (const auto& aj : payload["agents"]) { + std::string kind_str = aj.value("kind", "individual"); + std::string name = aj.value("name", "Unnamed"); + std::string old_id = aj.value("id", ""); + + if (kind_str == "group") { + pending_groups.push_back({aj, old_id}); + continue; + } + + AgentRecord agent; + // Determine if this is a manager kind (not individual/group) + bool is_manager = + kind_str == "god" || kind_str == "writer" || + kind_str == "map_manager" || kind_str == "history_manager" || + kind_str == "magic_system_manager" || kind_str == "faction_manager" || + kind_str == "relation_manager"; + + if (!is_manager) { + // Individual or unknown — create as character + CharacterCard card; + if (aj.contains("card") && aj["card"].is_object()) { + const auto& cj = aj["card"]; + card.name = cj.value("name", name); + card.gender = cj.value("gender", ""); + card.race = cj.value("race", ""); + card.identity = cj.value("identity", ""); + card.emotional_tendency = cj.value("emotional_tendency", ""); + card.speaking_style = cj.value("speaking_style", ""); + card.core_desire = cj.value("core_desire", ""); + card.deep_fear = cj.value("deep_fear", ""); + card.daily_goal = cj.value("daily_goal", ""); + card.background = cj.value("background", ""); + card.knowledge_scope = cj.value("knowledge_scope", ""); + card.appearance = cj.value("appearance", ""); + card.age = cj.value("age", 0); + card.version = cj.value("version", 1); + if (cj.contains("core_traits") && cj["core_traits"].is_array()) { + for (const auto& t : cj["core_traits"]) + if (t.is_string()) card.core_traits.push_back(t.get()); + } + if (cj.contains("taboo_topics") && cj["taboo_topics"].is_array()) { + for (const auto& t : cj["taboo_topics"]) + if (t.is_string()) card.taboo_topics.push_back(t.get()); + } + if (cj.contains("relations")) + card.relations = cj["relations"]; + } else { + card.name = name; + } + agent = agents_.create_character(world.id, std::move(card)); + sync_entity_to_kg({agent.name, merak::kg::EntityType::Agent, + agent.id, agent.world_id, agent.created_at}); + } else { + // Manager kind + AgentKind kind = AgentKind::God; + if (kind_str == "god") kind = AgentKind::God; + else if (kind_str == "writer") kind = AgentKind::Writer; + else if (kind_str == "map_manager") kind = AgentKind::MapManager; + else if (kind_str == "history_manager") kind = AgentKind::HistoryManager; + else if (kind_str == "magic_system_manager") kind = AgentKind::MagicSystemManager; + else if (kind_str == "faction_manager") kind = AgentKind::FactionManager; + else if (kind_str == "relation_manager") kind = AgentKind::RelationManager; + + agent = agents_.create_manager(world.id, kind, name, ""); + } + + if (!old_id.empty()) id_map[old_id] = agent.id; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 4: Create groups + // ═══════════════════════════════════════════════════════════════ + for (auto& pg : pending_groups) { + std::string name = pg.data.value("name", "Unnamed Group"); + std::string culture_card; + if (pg.data.contains("card") && pg.data["card"].is_object()) { + culture_card = pg.data["card"].value("background", ""); + } + // Empty member list — group members are not serialized in the snapshot + auto agent = agents_.create_group(world.id, name, culture_card, {}); + if (!pg.old_id.empty()) id_map[pg.old_id] = agent.id; + sync_entity_to_kg({agent.name, merak::kg::EntityType::Organization, + agent.id, agent.world_id, agent.created_at}); + } + + // ═══════════════════════════════════════════════════════════════ + // Step 5: Import relations + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("agents") && payload["agents"].is_array()) { + for (const auto& aj : payload["agents"]) { + std::string old_agent_id = aj.value("id", ""); + if (!aj.contains("relations") || !aj["relations"].is_array()) continue; + + for (const auto& rj : aj["relations"]) { + RelationEntry rel; + rel.agent_id = remap(rj.value("agent_id", "")); + rel.target_id = remap(rj.value("target_id", "")); + rel.relation_type = rj.value("relation_type", ""); + rel.description = rj.value("description", ""); + rel.intimacy = rj.value("intimacy", 0); + if (rj.contains("key_events") && rj["key_events"].is_array()) { + for (const auto& ke : rj["key_events"]) + if (ke.is_string()) rel.key_events.push_back(ke.get()); + } + if (!rel.agent_id.empty() && !rel.target_id.empty()) { + try { + agents_.upsert_relation(std::move(rel)); + } catch (const std::exception& e) { + spdlog::warn("import_snapshot: failed to upsert relation {}->{}: {}", + rel.agent_id, rel.target_id, e.what()); + } + } + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 6: Import arcs + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("arcs") && payload["arcs"].is_array()) { + for (const auto& aj : payload["arcs"]) { + Arc arc; + arc.title = aj.value("title", ""); + arc.purpose = aj.value("purpose", ""); + arc.status = aj.value("status", ""); + + auto created = narrative_.create_arc(world.id, std::move(arc)); + std::string old_id = aj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 7: Import chapters + // ═══════════════════════════════════════════════════════════════ + struct ChapterSceneBatch { + std::string new_chapter_id; + nlohmann::json scenes_json; + }; + std::vector chapter_scenes; + + if (payload.contains("chapters") && payload["chapters"].is_array()) { + for (const auto& cj : payload["chapters"]) { + Chapter ch; + ch.title = cj.value("title", "Untitled Chapter"); + ch.pitch = cj.value("pitch", ""); + ch.notes = cj.value("notes", ""); + ch.content = cj.value("content", ""); + ch.number = cj.value("number", 0); + + // Remap arc_id + if (cj.contains("arc_id") && !cj["arc_id"].is_null() && cj["arc_id"].is_string()) { + ch.arc_id = remap(cj["arc_id"].get()); + } + + // Parse status + std::string status_str = cj.value("status", "outline"); + if (status_str == "drafting") ch.status = ChapterStatus::Drafting; + else if (status_str == "completed") ch.status = ChapterStatus::Completed; + else if (status_str == "revised") ch.status = ChapterStatus::Revised; + else ch.status = ChapterStatus::Outline; + + if (cj.contains("emotional_curve")) + ch.emotional_curve = cj["emotional_curve"]; + + auto created = narrative_.create_chapter(world.id, std::move(ch)); + std::string old_id = cj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + + // Collect scenes for later creation + if (cj.contains("scenes") && cj["scenes"].is_array()) { + chapter_scenes.push_back({created.id, cj["scenes"]}); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 8: Import scenes + // ═══════════════════════════════════════════════════════════════ + for (const auto& cs : chapter_scenes) { + for (const auto& sj : cs.scenes_json) { + Scene scene; + scene.title = sj.value("title", "Untitled Scene"); + scene.chapter_id = cs.new_chapter_id; + scene.world_time = sj.value("world_time", ""); + scene.narrative = sj.value("narrative", ""); + + // Remap location_id + if (sj.contains("location_id") && !sj["location_id"].is_null() + && sj["location_id"].is_string()) { + scene.location_id = remap(sj["location_id"].get()); + } + // Remap section_id + if (sj.contains("section_id") && !sj["section_id"].is_null() + && sj["section_id"].is_string()) { + scene.section_id = remap(sj["section_id"].get()); + } + // Remap participant_ids + scene.participant_ids = remap_json_arr(sj.value("participant_ids", nlohmann::json::array())); + // Remap pov_character_id + if (sj.contains("pov_character_id") && !sj["pov_character_id"].is_null() + && sj["pov_character_id"].is_string()) { + scene.pov_character_id = remap(sj["pov_character_id"].get()); + } + + scene.plot_goal = sj.value("plot_goal", ""); + scene.emotional_goal = sj.value("emotional_goal", ""); + scene.information_goal = sj.value("information_goal", ""); + scene.external_conflict = sj.value("external_conflict", ""); + scene.internal_conflict = sj.value("internal_conflict", ""); + if (sj.contains("hidden_conflict") && !sj["hidden_conflict"].is_null() + && sj["hidden_conflict"].is_string()) { + scene.hidden_conflict = sj["hidden_conflict"].get(); + } + + // Parse status + std::string s_status_str = sj.value("status", "draft"); + if (s_status_str == "writing") scene.status = SceneStatus::Writing; + else if (s_status_str == "completed") scene.status = SceneStatus::Completed; + else scene.status = SceneStatus::Draft; + + if (sj.contains("style_overrides")) + scene.style_overrides = sj["style_overrides"]; + + try { + auto created = narrative_.create_scene(world.id, std::move(scene)); + std::string old_id = sj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + } catch (const std::exception& e) { + spdlog::warn("import_snapshot: failed to create scene: {}", e.what()); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 9: Import diaries + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("agents") && payload["agents"].is_array()) { + for (const auto& aj : payload["agents"]) { + if (!aj.contains("diaries") || !aj["diaries"].is_array()) continue; + std::string new_agent_id = remap(aj.value("id", "")); + if (new_agent_id.empty()) continue; + + for (const auto& dj : aj["diaries"]) { + std::string old_diary_id = dj.value("id", ""); + std::string new_diary_id = make_id("diary"); + + DiaryEntry entry; + entry.id = new_diary_id; + entry.agent_id = new_agent_id; + entry.scene_id = remap(dj.value("scene_id", "")); + entry.world_time = dj.value("world_time", ""); + entry.content = dj.value("content", ""); + entry.mood = dj.value("mood", ""); + entry.status = dj.value("status", "completed"); + entry.leak_risk_level = dj.value("leak_risk_level", 0); + entry.tokens_used = dj.value("tokens_used", 0); + + try { + agents_.append_diary_entry(std::move(entry)); + if (!old_diary_id.empty()) id_map[old_diary_id] = new_diary_id; + } catch (const std::exception& e) { + spdlog::warn("import_snapshot: failed to import diary: {}", e.what()); + } + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 10: Import memory summaries + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("agents") && payload["agents"].is_array()) { + for (const auto& aj : payload["agents"]) { + if (!aj.contains("memory_summaries") || !aj["memory_summaries"].is_array()) continue; + std::string new_agent_id = remap(aj.value("id", "")); + if (new_agent_id.empty()) continue; + + for (const auto& mj : aj["memory_summaries"]) { + std::string new_summary_id = make_id("summary"); + + MemorySummary summary; + summary.id = new_summary_id; + summary.agent_id = new_agent_id; + summary.period_start = mj.value("period_start", ""); + summary.period_end = mj.value("period_end", ""); + summary.summary = mj.value("summary", ""); + + // Remap source diary IDs if present + if (mj.contains("source_diary_ids") && mj["source_diary_ids"].is_array()) { + for (const auto& did : mj["source_diary_ids"]) + if (did.is_string()) summary.source_diary_ids.push_back(remap(did.get())); + } + + try { + agents_.write_memory_summary(std::move(summary)); + std::string old_summary_id = mj.value("id", ""); + if (!old_summary_id.empty()) id_map[old_summary_id] = new_summary_id; + } catch (const std::exception& e) { + spdlog::warn("import_snapshot: failed to import memory summary: {}", e.what()); + } + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 11: Import factions (two-pass for rival_faction_ids) + // ═══════════════════════════════════════════════════════════════ + std::vector>> faction_rival_fixup; + + if (payload.contains("factions") && payload["factions"].is_array()) { + for (const auto& fj : payload["factions"]) { + Faction faction; + faction.name = fj.value("name", "Unnamed Faction"); + faction.description = fj.value("description", ""); + faction.goals = fj.value("goals", ""); + faction.member_agent_ids = remap_json_arr(fj.value("member_agent_ids", nlohmann::json::array())); + + auto created = worlds_.add_faction(world.id, std::move(faction)); + std::string old_id = fj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + + // Remember rival fixup + if (fj.contains("rival_faction_ids") && fj["rival_faction_ids"].is_array()) { + std::vector old_rivals; + for (const auto& rid : fj["rival_faction_ids"]) + if (rid.is_string()) old_rivals.push_back(rid.get()); + if (!old_rivals.empty()) + faction_rival_fixup.emplace_back(created.id, std::move(old_rivals)); + } + } + + // Fixup rival_faction_ids + for (const auto& [new_id, old_rivals] : faction_rival_fixup) { + std::vector new_rivals = remap_vec(old_rivals); + // Remove empty strings from remapping failures + new_rivals.erase(std::remove(new_rivals.begin(), new_rivals.end(), ""), new_rivals.end()); + if (!new_rivals.empty()) { + worlds_.update_faction(world.id, new_id, + {{"rival_faction_ids", new_rivals}}); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 12: Import knowledge + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("knowledge") && payload["knowledge"].is_array()) { + for (const auto& kj : payload["knowledge"]) { + WorldKnowledge wk; + wk.id = make_id("wk"); + wk.category = kj.value("category", "other"); + wk.content = kj.value("content", ""); + wk.created_at = kj.value("created_at", now_iso_utc()); + + if (kj.contains("tags") && kj["tags"].is_array()) { + for (const auto& t : kj["tags"]) + if (t.is_string()) wk.tags.push_back(t.get()); + } + if (kj.contains("aliases") && kj["aliases"].is_array()) { + for (const auto& a : kj["aliases"]) + if (a.is_string()) wk.aliases.push_back(a.get()); + } + wk.related_ids = remap_json_arr(kj.value("related_ids", nlohmann::json::array())); + + worlds_.add_world_knowledge(world.id, wk); + std::string old_id = kj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = wk.id; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 13: Import foreshadowing + // ═══════════════════════════════════════════════════════════════ + // Two-pass: create first, then fixup cross-references + struct ForeshadowFixup { + std::string new_id; + std::vector old_related_fs_ids; + std::vector old_related_secret_ids; + }; + std::vector fs_fixups; + + if (payload.contains("foreshadowing") && payload["foreshadowing"].is_array()) { + for (const auto& fj : payload["foreshadowing"]) { + Foreshadowing fs; + fs.content = fj.value("content", ""); + fs.pay_off_idea = fj.value("pay_off_idea", ""); + + // Remap planted_at (chapter/scene ID) + if (fj.contains("planted_at") && !fj["planted_at"].is_null() + && fj["planted_at"].is_string()) { + fs.planted_at = remap(fj["planted_at"].get()); + } + if (fj.contains("paid_at") && !fj["paid_at"].is_null() + && fj["paid_at"].is_string()) { + fs.paid_at = remap(fj["paid_at"].get()); + } + + // Parse status + std::string fs_status = fj.value("status", "open"); + if (fs_status == "paid") fs.status = ForeshadowStatus::Paid; + else if (fs_status == "abandoned") fs.status = ForeshadowStatus::Abandoned; + else fs.status = ForeshadowStatus::Open; + + // Parse hint_level + std::string hl = fj.value("hint_level", "visible"); + if (hl == "subtle") fs.hint_level = ForeshadowHintLevel::Subtle; + else if (hl == "obvious") fs.hint_level = ForeshadowHintLevel::Obvious; + else fs.hint_level = ForeshadowHintLevel::Visible; + + // Parse created_by + std::string cb = fj.value("created_by", "author"); + if (cb == "god_agent_detected") fs.created_by = ForeshadowCreatedBy::GodAgentDetected; + else fs.created_by = ForeshadowCreatedBy::Author; + + if (fj.contains("tags") && fj["tags"].is_array()) { + for (const auto& t : fj["tags"]) + if (t.is_string()) fs.tags.push_back(t.get()); + } + + auto created = foreshadowing_.plant(world.id, std::move(fs)); + std::string old_id = fj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + + // Collect cross-refs for fixup + ForeshadowFixup ff; + ff.new_id = created.id; + if (fj.contains("related_foreshadowing_ids") && fj["related_foreshadowing_ids"].is_array()) { + for (const auto& rid : fj["related_foreshadowing_ids"]) { + if (rid.is_string()) ff.old_related_fs_ids.push_back(rid.get()); + } + } + if (fj.contains("related_secret_ids") && fj["related_secret_ids"].is_array()) { + for (const auto& sid : fj["related_secret_ids"]) { + if (sid.is_string()) ff.old_related_secret_ids.push_back(sid.get()); + } + } + if (!ff.old_related_fs_ids.empty() || !ff.old_related_secret_ids.empty()) { + fs_fixups.push_back(std::move(ff)); + } + } + + // Fixup foreshadowing cross-refs (secrets may not be imported yet, so this is best-effort) + for (const auto& ff : fs_fixups) { + nlohmann::json patch; + if (!ff.old_related_fs_ids.empty()) { + patch["related_foreshadowing_ids"] = remap_vec(ff.old_related_fs_ids); + } + if (!ff.old_related_secret_ids.empty()) { + patch["related_secret_ids"] = remap_vec(ff.old_related_secret_ids); + } + if (!patch.empty()) { + foreshadowing_.patch(world.id, ff.new_id, patch); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 14: Import secrets + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("secrets") && payload["secrets"].is_array()) { + for (const auto& sj : payload["secrets"]) { + Secret secret; + secret.truth = sj.value("truth", ""); + secret.public_version = sj.value("public_version", ""); + secret.stakes = sj.value("stakes", ""); + + // Remap holder_id (agent) + secret.holder_id = remap(sj.value("holder_id", "")); + + // Remap character vectors + secret.aware_character_ids = + remap_json_arr(sj.value("aware_character_ids", nlohmann::json::array())); + secret.suspicious_character_ids = + remap_json_arr(sj.value("suspicious_character_ids", nlohmann::json::array())); + secret.related_foreshadowing_ids = + remap_json_arr(sj.value("related_foreshadowing_ids", nlohmann::json::array())); + + if (sj.contains("planted_at") && !sj["planted_at"].is_null() + && sj["planted_at"].is_string()) { + secret.planted_at = remap(sj["planted_at"].get()); + } + if (sj.contains("exposed_at") && !sj["exposed_at"].is_null() + && sj["exposed_at"].is_string()) { + secret.exposed_at = remap(sj["exposed_at"].get()); + } + + // Parse status + std::string sec_status = sj.value("status", "active"); + if (sec_status == "exposed") secret.status = SecretStatus::Exposed; + else if (sec_status == "abandoned") secret.status = SecretStatus::Abandoned; + else secret.status = SecretStatus::Active; + + if (sj.contains("believed_truths")) + secret.believed_truths = sj["believed_truths"]; + + auto created = secrets_.create(world.id, std::move(secret)); + std::string old_id = sj.value("id", ""); + if (!old_id.empty()) id_map[old_id] = created.id; + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 15: Import timeline events + // ═══════════════════════════════════════════════════════════════ + if (payload.contains("timeline") && payload["timeline"].is_array()) { + for (const auto& tj : payload["timeline"]) { + TimelineEvent event; + event.world_time = tj.value("world_time", ""); + event.description = tj.value("description", ""); + event.recorded_by = remap(tj.value("recorded_by", "")); + event.affected_character_ids = + remap_json_arr(tj.value("affected_character_ids", nlohmann::json::array())); + event.related_scene_ids = + remap_json_arr(tj.value("related_scene_ids", nlohmann::json::array())); + + try { + narrative_.record_timeline_event(world.id, std::move(event)); + } catch (const std::exception& e) { + spdlog::warn("import_snapshot: failed to record timeline event: {}", e.what()); + } + } + } + + // ═══════════════════════════════════════════════════════════════ + // Step 16: Store import metadata in world config + // ═══════════════════════════════════════════════════════════════ + { + nlohmann::json meta; + meta["imported_from"] = old_world_id; + meta["imported_at"] = now_iso_utc(); + meta["schema_version"] = snapshot.value("schema_version", "unknown"); + meta["snapshot_id"] = snapshot.value("snapshot_id", "unknown"); + worlds_.update_world_config(world.id, meta); + } + + spdlog::info("import_snapshot: imported {} entities into world {}", + id_map.size(), world.id); + + return result; +} + } // namespace merak::worldbuilding From 465297a6c3046a0150ab7cbf06338c84df2fb4e0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 11:36:43 +0000 Subject: [PATCH 30/33] feat: add knowledge graph entity list, relations, and search endpoints --- .../merak/worldbuilding_http_handler.hpp | 5 + libs/http/src/worldbuilding_http_handler.cpp | 160 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 1322784a..80cc3c1c 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -209,6 +209,11 @@ class WorldbuildingHttpHandler { void handle_chunked_status(const httplib::Request&, httplib::Response&); void handle_complete_chunked(const httplib::Request&, httplib::Response&); void handle_cancel_chunked(const httplib::Request&, httplib::Response&); + + // Knowledge Graph + void handle_kg_entities(const httplib::Request&, httplib::Response&); + void handle_kg_entity_relations(const httplib::Request&, httplib::Response&); + void handle_kg_search(const httplib::Request&, httplib::Response&); }; } // namespace merak diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index fe305328..bb52e4a3 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -8,6 +9,7 @@ #include #include +#include #include #include #include @@ -748,6 +750,14 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Post(R"(/api/worldbuilding/([^/]+)/pipeline/clear-error)", [this](const auto& req, auto& res) { handle_pipeline_clear_error(req, res); }); + + // Knowledge Graph + server.Get(R"(/api/worldbuilding/([^/]+)/knowledge-graph/entities)", + [this](const auto& req, auto& res) { handle_kg_entities(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/knowledge-graph/entities/([^/]+)/relations)", + [this](const auto& req, auto& res) { handle_kg_entity_relations(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/knowledge-graph/search)", + [this](const auto& req, auto& res) { handle_kg_search(req, res); }); } // --- World handlers --- @@ -3428,4 +3438,154 @@ void WorldbuildingHttpHandler::handle_pipeline_clear_error(const httplib::Reques } } +// --- Knowledge Graph handlers --- + +void WorldbuildingHttpHandler::handle_kg_entities(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto* kg = service_->kg_provider(); + if (!kg) { + res.status = 503; + res.set_content(R"({"ok":false,"error":{"code":"kg_unavailable","message":"Knowledge Graph not available","retryable":false}})", "application/json"); + return; + } + + auto entities = kg->list_entities(wid); + + // Optional type filter + std::optional type_filter; + if (req.has_param("type")) { + type_filter = merak::kg::entity_type_from_string(req.get_param_value("type")); + } + + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : entities) { + if (type_filter && e.type != *type_filter) continue; + arr.push_back({ + {"id", e.source_id}, + {"name", e.name}, + {"type", merak::kg::to_string(e.type)} + }); + } + + json_response(res, {{"ok", true}, {"entities", arr}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_kg_entity_relations(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string eid = req.matches[2]; + + auto* kg = service_->kg_provider(); + if (!kg) { + res.status = 503; + res.set_content(R"({"ok":false,"error":{"code":"kg_unavailable","message":"Knowledge Graph not available","retryable":false}})", "application/json"); + return; + } + + // Find the entity by source_id + auto entities = kg->list_entities(wid); + const merak::kg::GraphEntity* found = nullptr; + for (const auto& e : entities) { + if (e.source_id == eid) { + found = &e; + break; + } + } + + if (!found) { + error_response(res, "Entity not found", 404, "entity_not_found"); + return; + } + + // Query subgraph for this entity's relations + auto subgraph = kg->query_subgraph(wid, {found->name}, merak::kg::QueryFilters{}); + + nlohmann::json rel_arr = nlohmann::json::array(); + for (const auto& rel : subgraph.relations) { + // Determine which side is the target (the other entity) + bool is_source = (rel.source_name == found->name); + std::string target_id = is_source ? rel.target_id : rel.source_id; + std::string target_name = is_source ? rel.target_name : rel.source_name; + std::string target_type = is_source + ? merak::kg::to_string(rel.target_type) + : merak::kg::to_string(rel.source_type); + + rel_arr.push_back({ + {"relation_type", rel.kind_en}, + {"target_id", target_id}, + {"target_name", target_name}, + {"target_type", target_type} + }); + } + + nlohmann::json entity_obj = { + {"id", found->source_id}, + {"name", found->name} + }; + + json_response(res, {{"ok", true}, {"entity", entity_obj}, {"relations", rel_arr}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_kg_search(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + + // Require 'q' query parameter + if (!req.has_param("q") || req.get_param_value("q").empty()) { + error_response(res, "Missing required query parameter: q", 400, "missing_param"); + return; + } + std::string query = req.get_param_value("q"); + + auto* kg = service_->kg_provider(); + if (!kg) { + res.status = 503; + res.set_content(R"({"ok":false,"error":{"code":"kg_unavailable","message":"Knowledge Graph not available","retryable":false}})", "application/json"); + return; + } + + // Optional type filter + std::optional type_filter; + if (req.has_param("type")) { + type_filter = merak::kg::entity_type_from_string(req.get_param_value("type")); + } + + auto entities = kg->list_entities(wid); + + // Case-insensitive substring helper + auto icontains = [](const std::string& haystack, const std::string& needle) -> bool { + if (needle.empty()) return true; + auto it = std::search( + haystack.begin(), haystack.end(), + needle.begin(), needle.end(), + [](unsigned char a, unsigned char b) { + return std::tolower(a) == std::tolower(b); + }); + return it != haystack.end(); + }; + + nlohmann::json arr = nlohmann::json::array(); + for (const auto& e : entities) { + if (type_filter && e.type != *type_filter) continue; + if (!icontains(e.name, query)) continue; + arr.push_back({ + {"id", e.source_id}, + {"name", e.name}, + {"type", merak::kg::to_string(e.type)} + }); + } + + json_response(res, {{"ok", true}, {"results", arr}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + } // namespace merak From 8cbb9a841412c865d7997e934d9c5621ec922a4f Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 11:38:16 +0000 Subject: [PATCH 31/33] feat: add scene extraction result endpoint --- .../merak/worldbuilding_http_handler.hpp | 3 ++ libs/http/src/worldbuilding_http_handler.cpp | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 80cc3c1c..0ec5ebe9 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -150,6 +150,9 @@ class WorldbuildingHttpHandler { void handle_character_appearances(const httplib::Request&, httplib::Response&); void handle_get_rewrite_result(const httplib::Request&, httplib::Response&); + // KG extraction result + void handle_scene_extraction_result(const httplib::Request&, httplib::Response&); + std::unordered_map pending_agent_runs_; mutable std::mutex pending_runs_mutex_; diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index bb52e4a3..14e4c128 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -436,6 +436,8 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { [this](const auto& req, auto& res) { handle_delete_scene(req, res); }); server.Post(R"(/api/worldbuilding/([^/]+)/scenes/([^/]+)/end)", [this](const auto& req, auto& res) { handle_scene_end(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/scenes/([^/]+)/extraction-result)", + [this](const auto& req, auto& res) { handle_scene_extraction_result(req, res); }); // Time server.Get(R"(/api/worldbuilding/([^/]+)/time)", @@ -3267,6 +3269,41 @@ void WorldbuildingHttpHandler::handle_get_rewrite_result(const httplib::Request& } } +void WorldbuildingHttpHandler::handle_scene_extraction_result(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string sid = req.matches[2]; + + auto world = service_->worlds().get_world(wid); + if (!world) { + error_response(res, "World not found", 404); + return; + } + + auto scene = service_->narrative().get_scene(wid, sid); + if (!scene) { + error_response(res, "Scene not found", 404); + return; + } + + auto result = service_->worlds().get_agent_result(wid, "extraction_" + sid); + nlohmann::json candidates = nlohmann::json::array(); + if (result && result->contains("candidates") && (*result)["candidates"].is_array()) { + for (const auto& c : (*result)["candidates"]) { + nlohmann::json candidate; + if (c.contains("relation")) candidate["relation"] = c["relation"]; + if (c.contains("status")) candidate["status"] = c["status"]; + if (c.contains("evidence")) candidate["evidence"] = c["evidence"]; + if (c.contains("change_summary")) candidate["change_summary"] = c["change_summary"]; + candidates.push_back(candidate); + } + } + json_response(res, {{"ok", true}, {"candidates", candidates}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + void WorldbuildingHttpHandler::handle_search_agents(const httplib::Request& req, httplib::Response& res) { try { std::string wid = req.matches[1]; From 007f78d8465f8173d3be3f219a2eaced6b2002f6 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 11:53:11 +0000 Subject: [PATCH 32/33] feat: add timeline store methods and endpoints Add list_timeline_events and get_timeline_event to NarrativeStore, plus three HTTP endpoints: timeline list, timeline event get, and timeline advance (using time_label field). --- .../merak/worldbuilding_http_handler.hpp | 5 + libs/http/src/worldbuilding_http_handler.cpp | 154 ++++++++++++++++++ .../merak/worldbuilding/narrative_store.hpp | 3 + libs/worldbuilding/src/narrative_store.cpp | 45 +++++ 4 files changed, 207 insertions(+) diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 0ec5ebe9..ba02b182 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -66,6 +66,11 @@ class WorldbuildingHttpHandler { void handle_time_now(const httplib::Request&, httplib::Response&); void handle_time_advance(const httplib::Request&, httplib::Response&); + // Timeline + void handle_timeline_list(const httplib::Request&, httplib::Response&); + void handle_timeline_event_get(const httplib::Request&, httplib::Response&); + void handle_timeline_advance(const httplib::Request&, httplib::Response&); + // Foreshadowing void handle_foreshadow_list(const httplib::Request&, httplib::Response&); void handle_foreshadow_plant(const httplib::Request&, httplib::Response&); diff --git a/libs/http/src/worldbuilding_http_handler.cpp b/libs/http/src/worldbuilding_http_handler.cpp index 14e4c128..3bf7ebdd 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -445,6 +445,14 @@ void WorldbuildingHttpHandler::install_routes(httplib::Server& server) { server.Post(R"(/api/worldbuilding/([^/]+)/time/advance)", [this](const auto& req, auto& res) { handle_time_advance(req, res); }); + // Timeline + server.Get(R"(/api/worldbuilding/([^/]+)/timeline/events/([^/]+))", + [this](const auto& req, auto& res) { handle_timeline_event_get(req, res); }); + server.Get(R"(/api/worldbuilding/([^/]+)/timeline)", + [this](const auto& req, auto& res) { handle_timeline_list(req, res); }); + server.Post(R"(/api/worldbuilding/([^/]+)/timeline/advance)", + [this](const auto& req, auto& res) { handle_timeline_advance(req, res); }); + // Foreshadowing server.Get(R"(/api/worldbuilding/([^/]+)/foreshadowing)", [this](const auto& req, auto& res) { handle_foreshadow_list(req, res); }); @@ -1369,6 +1377,152 @@ void WorldbuildingHttpHandler::handle_time_advance(const httplib::Request& req, } } +// --- Timeline handlers --- + +void WorldbuildingHttpHandler::handle_timeline_list(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto world = service_->worlds().get_world(wid); + if (!world) { + error_response(res, "World not found", 404, "world_not_found"); + return; + } + auto events = service_->narrative().list_timeline_events(wid); + + // Determine current time from last event + int day = 1; + int period = 0; + std::string label = "\xe7\xac\xac\xe4\xb8\x80\xe6\x97\xa5\xe6\x99\xa8"; // "first day morning" + if (!events.empty()) { + auto last_time = worldbuilding::WorldTime::parse(events.back().world_time); + if (last_time.has_value()) { + day = last_time->day; + period = last_time->period; + label = last_time->label; + } + } + + nlohmann::json events_arr = nlohmann::json::array(); + for (const auto& e : events) { + events_arr.push_back({ + {"id", e.id}, + {"world_time", e.world_time}, + {"description", e.description}, + {"recorded_by", e.recorded_by}, + {"affected_character_ids", e.affected_character_ids}, + {"related_scene_ids", e.related_scene_ids} + }); + } + + json_response(res, { + {"ok", true}, + {"current_time", { + {"day", day}, + {"period", period}, + {"label", label} + }}, + {"events", events_arr} + }); + } catch (const std::exception& e) { + error_response(res, e.what()); + } +} + +void WorldbuildingHttpHandler::handle_timeline_event_get(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + std::string eid = req.matches[2]; + auto world = service_->worlds().get_world(wid); + if (!world) { + error_response(res, "World not found", 404, "world_not_found"); + return; + } + auto event = service_->narrative().get_timeline_event(wid, eid); + if (!event.has_value()) { + error_response(res, "Timeline event not found", 404, "event_not_found"); + return; + } + + // Resolve recorded_by_name + std::string recorded_by_name = event->recorded_by; + if (!event->recorded_by.empty()) { + auto agent = service_->agents().get_agent(event->recorded_by); + if (agent.has_value()) { + recorded_by_name = agent->name; + } + } + + nlohmann::json event_json = { + {"id", event->id}, + {"world_time", event->world_time}, + {"description", event->description}, + {"recorded_by", event->recorded_by}, + {"recorded_by_name", recorded_by_name}, + {"affected_character_ids", event->affected_character_ids}, + {"related_scene_ids", event->related_scene_ids} + }; + + json_response(res, {{"ok", true}, {"event", event_json}}); + } catch (const std::exception& e) { + error_response(res, e.what()); + } +} + +void WorldbuildingHttpHandler::handle_timeline_advance(const httplib::Request& req, httplib::Response& res) { + try { + std::string wid = req.matches[1]; + auto body = nlohmann::json::parse(req.body); + std::string time_label = body.at("time_label"); + + // Validate world exists + auto world = service_->worlds().get_world(wid); + if (!world) { + error_response(res, "World not found", 404, "world_not_found"); + return; + } + + // Parse and validate the new time format + auto parsed_new = worldbuilding::WorldTime::parse(time_label); + if (!parsed_new.has_value()) { + error_response(res, "Invalid time_label format: " + time_label, 400, "invalid_time_format"); + return; + } + + // Forward-only validation: new_time must be strictly after current world time + auto events = service_->narrative().list_timeline_events(wid); + if (!events.empty()) { + const auto& last_event = events.back(); + auto parsed_current = worldbuilding::WorldTime::parse(last_event.world_time); + if (parsed_current.has_value() && *parsed_new <= *parsed_current) { + error_response(res, + "Time can only advance forward. Current world time: " + + last_event.world_time + ", requested: " + time_label, + 400, "time_not_forward"); + return; + } + } + + worldbuilding::TimelineEvent event; + event.world_time = parsed_new->label; + event.description = body.value("description", "Time advanced"); + event.recorded_by = body.value("recorded_by", "user"); + + auto recorded = service_->narrative().advance_time(wid, std::move(event)); + + json_response(res, { + {"ok", true}, + {"new_time", { + {"day", parsed_new->day}, + {"period", parsed_new->period}, + {"label", parsed_new->label} + }} + }); + } catch (const std::exception& e) { + error_response(res, e.what()); + } +} + + // --- Foreshadowing handlers --- void WorldbuildingHttpHandler::handle_foreshadow_list(const httplib::Request& req, httplib::Response& res) { diff --git a/libs/worldbuilding/include/merak/worldbuilding/narrative_store.hpp b/libs/worldbuilding/include/merak/worldbuilding/narrative_store.hpp index a1953924..f1b61739 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/narrative_store.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/narrative_store.hpp @@ -62,6 +62,9 @@ class NarrativeStore { TimelineEvent event); TimelineEvent advance_time(const std::string& world_id, TimelineEvent event); + std::vector list_timeline_events(const std::string& world_id) const; + std::optional get_timeline_event(const std::string& world_id, + const std::string& event_id) const; std::vector insert_flashback_scene(const std::string& world_id, Scene scene); diff --git a/libs/worldbuilding/src/narrative_store.cpp b/libs/worldbuilding/src/narrative_store.cpp index 1d6ae1b0..201a76b4 100644 --- a/libs/worldbuilding/src/narrative_store.cpp +++ b/libs/worldbuilding/src/narrative_store.cpp @@ -233,6 +233,21 @@ nlohmann::json timeline_event_json(const TimelineEvent& event) { {"related_scene_ids", event.related_scene_ids}}; } + +TimelineEvent timeline_event_from_json(const nlohmann::json& json) { + TimelineEvent event; + event.id = json.at("id").get(); + event.world_time = json.at("world_time").get(); + event.description = json.value("description", ""); + event.recorded_by = json.value("recorded_by", ""); + if (json.contains("affected_character_ids") && json["affected_character_ids"].is_array()) { + event.affected_character_ids = json["affected_character_ids"].get>(); + } + if (json.contains("related_scene_ids") && json["related_scene_ids"].is_array()) { + event.related_scene_ids = json["related_scene_ids"].get>(); + } + return event; +} std::string first_line_summary(const std::string& markdown) { std::istringstream input(markdown); std::string line; @@ -553,6 +568,36 @@ TimelineEvent NarrativeStore::advance_time(const std::string& world_id, return record_timeline_event(world_id, std::move(event)); } +std::vector +NarrativeStore::list_timeline_events(const std::string& world_id) const { + ensure_world_exists(worlds_, world_id); + auto path = worlds_.world_path(world_id) / "timeline.json"; + if (!std::filesystem::exists(path)) { + return {}; + } + auto timeline = read_json(path); + if (!timeline.contains("events") || !timeline["events"].is_array()) { + return {}; + } + std::vector out; + for (const auto& j : timeline["events"]) { + out.push_back(timeline_event_from_json(j)); + } + return out; +} + +std::optional +NarrativeStore::get_timeline_event(const std::string& world_id, + const std::string& event_id) const { + auto events = list_timeline_events(world_id); + for (const auto& e : events) { + if (e.id == event_id) { + return e; + } + } + return std::nullopt; +} + std::vector NarrativeStore::insert_flashback_scene(const std::string& world_id, Scene scene) { From af070f42e82c2fcfa53cb2394f0d6012613fd6d0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 11:57:13 +0000 Subject: [PATCH 33/33] docs: add P2 endpoint API reference --- docs/api-reference.md | 1216 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1199 insertions(+), 17 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index f9db6b99..acdc4f07 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,6 +1,6 @@ # Merak WebUI API Reference -> 基于 `main` 分支源码严格生成,2026-06-16。 +> 基于 `main` 分支源码严格生成,2026-06-19(更新:P0 后端补全新增 30 个端点)。 > 所有接口通过 `httplib::Server` 注册在 `127.0.0.1`,返回 JSON(SSE 端点返回 `text/event-stream`)。 --- @@ -74,6 +74,12 @@ | `preferences_save_failed` | 400 | 偏好保存失败 | | `export_failed` | 500 | 导出失败 | | `review_failed` | 500 | 章节评审失败 | +| `location_not_found` | 404 | 地点不存在 | +| `knowledge_not_found` | 404 | 知识条目不存在 | +| `faction_not_found` | 404 | 势力不存在 | +| `file_link_not_found` | 404 | 文件关联不存在 | +| `creation_not_found` | 404 | 待定创建不存在 | +| `unknown_tool` | 400 | 不支持的 tool_name | --- @@ -1121,6 +1127,23 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `DELETE /api/worldbuilding/:wid/agents/:aid` — 删除角色 + +删除角色及其全部关联数据(角色卡、日记、关系)。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`agent_not_found` — Agent 不存在或不属于该世界 + +--- + #### `GET /api/worldbuilding/:wid/agents/:aid/diaries` — 获取角色日记 返回最近 50 条日记。 @@ -1174,6 +1197,144 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `GET /api/worldbuilding/:wid/agents/:aid/diaries/:did` — 获取单篇日记 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | +| `did` | 日记 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `diary.id` | string | 日记 ID | +| `diary.agent_id` | string | Agent ID | +| `diary.scene_id` | string | 关联场景 ID | +| `diary.world_time` | string | 世界时间 | +| `diary.content` | string | 日记内容 | +| `diary.mood` | string | 心情 | +| `diary.status` | string | 状态 | +| `diary.leak_risk_level` | number | 泄露风险等级 | +| `diary.tokens_used` | number | token 用量 | +| `diary.created_at` | string | 创建时间 | + +**`404`**:`agent_not_found` — Agent 不存在或不属于该世界;日记不存在 + +--- + +#### `PATCH /api/worldbuilding/:wid/agents/:aid/diaries/:did` — 更新日记 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | +| `did` | 日记 ID | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `content` | string | 否 | 日记内容 | +| `mood` | string | 否 | 心情 | +| `status` | string | 否 | 状态 | + +**响应 `200`**:`{"ok": true}` + +**`400`**:`invalid_request` — JSON 解析失败或缺少必填字段 +**`404`**:`agent_not_found` — Agent 不存在或不属于该世界 + +--- + +#### `GET /api/worldbuilding/:wid/agents/:aid/diaries/search` — 搜索日记 + +全文搜索日记内容。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | + +**查询参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `q` | string | 是 | 搜索关键词 | +| `limit` | number | 否 | 返回条数上限,默认 20 | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `diaries` | array | 匹配的日记列表 | +| `total` | number | 总匹配数 | + +**`400`**:`invalid_request` — 缺少 q 参数 +**`404`**:`agent_not_found` + +--- + +#### `GET /api/worldbuilding/:wid/agents/:aid/memory-summaries` — 列出记忆摘要 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `summaries` | array | 记忆摘要列表 | +| `summaries[].id` | string | 摘要 ID | +| `summaries[].period_start` | string | 周期起始时间 | +| `summaries[].period_end` | string | 周期结束时间 | +| `summaries[].summary` | string | 摘要内容 | +| `summaries[].source_diary_ids` | string[] | 来源日记 ID | +| `summaries[].created_at` | string | 创建时间 | + +**`404`**:`agent_not_found` + +--- + +#### `GET /api/worldbuilding/:wid/agents/:aid/memory-summaries/:mid` — 获取单个记忆摘要 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | +| `mid` | 记忆摘要 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `summary.id` | string | 摘要 ID | +| `summary.period_start` | string | 周期起始时间 | +| `summary.period_end` | string | 周期结束时间 | +| `summary.summary` | string | 摘要内容 | +| `summary.source_diary_ids` | string[] | 来源日记 ID | +| `summary.created_at` | string | 创建时间 | + +**`404`**:`agent_not_found`;摘要不存在 + +--- + #### `GET /api/worldbuilding/:wid/agents/:aid/relations` — 获取角色关系 **路径参数**: @@ -1202,6 +1363,87 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `POST /api/worldbuilding/:wid/agents/:aid/relations` — 创建或更新角色关系 + +通过 upsert 机制,同一对角色只保留一条关系记录。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID(源角色) | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `target_id` | string | 是 | 目标角色 ID | +| `relation_type` | string | 否 | 关系类型,默认空字符串 | +| `description` | string | 否 | 关系描述,默认空字符串 | +| `intimacy` | number | 否 | 亲密度 (0-100),默认 0 | + +**响应 `201`**:`{"ok": true}` + +**`400`**:`invalid_request` — JSON 解析失败或缺少 target_id +**`404`**:`agent_not_found` + +--- + +#### `PATCH /api/worldbuilding/:wid/agents/:aid/relations/:tid` — 更新角色关系 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID(源角色) | +| `tid` | 目标角色 ID | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `relation_type` | string | 否 | 关系类型 | +| `description` | string | 否 | 关系描述 | +| `intimacy` | number | 否 | 亲密度 (0-100) | + +**响应 `200`**:`{"ok": true}` + +**`400`**:`invalid_request` — JSON 解析失败 +**`404`**:`agent_not_found` — Agent 不存在或不属于该世界 + +--- + +#### `GET /api/worldbuilding/:wid/agents/:aid/voice` — 获取角色声音指纹 + +返回基于角色对话文本分析的声音特征。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `aid` | Agent ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `voice.avg_sentence_length` | number | 平均句子长度 | +| `voice.sentence_variance` | number | 句子长度方差 | +| `voice.question_frequency` | number | 问句频率 | +| `voice.modifier_ratio` | number | 修饰词比率 | +| `voice.sample_count` | number | 分析样本数量 | +| `voice.signature_words` | string[] | 标志性用词 | +| `voice.tone_profile` | object | 语气特征 | + +**`400`**:`invalid_request` — 内部异常 +**`404`**:`agent_not_found` + +--- + #### `GET /api/worldbuilding/agents/:aid/prompt` — 加载角色 Prompt 用于 LLM 角色扮演的 system prompt。注意:此路径不含 world_id。 @@ -1531,6 +1773,34 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `GET /api/worldbuilding/:wid/tree` — 章节目录树 + +返回按弧线组织的章节目录树,用于侧边栏导航。未归类的章节归入 `"Uncategorized"` 节点(`id` 为空字符串)。 + +**路径参数**:`wid` — 世界 ID + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `tree` | array | 弧线节点列表 | +| `tree[].id` | string | 弧线 ID(未归类为空字符串) | +| `tree[].title` | string | 弧线标题 | +| `tree[].status` | string | 弧线状态 | +| `tree[].chapters` | array | 该弧线下的章节列表(按编号排序) | +| `tree[].chapters[].id` | string | 章节 ID | +| `tree[].chapters[].title` | string | 章节标题 | +| `tree[].chapters[].number` | number | 章节编号 | +| `tree[].chapters[].status` | string | 章节状态 | +| `tree[].chapters[].scene_count` | number | 场景数量 | +| `tree[].chapters[].updated_at` | string | 更新时间 | +| `tree[].chapters[].arc_id` | string\|null | 关联弧线 ID | + +**`404`**:`world_not_found` + +--- + #### `GET /api/worldbuilding/:wid/chapters` — 列出章节 **路径参数**:`wid` — 世界 ID @@ -1604,6 +1874,39 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `DELETE /api/worldbuilding/:wid/chapters/:cid` — 删除章节 + +同时清理关联的场景和父弧线的 `chapter_numbers` 引用。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `cid` | 章节 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`chapter_not_found` + +--- + +#### `POST /api/worldbuilding/:wid/chapters/reorder` — 重新排序章节 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `order` | string[] | 是 | 按新顺序排列的章节 ID 列表 | + +**响应 `200`**:`{"ok": true}` + +**`400`**:`invalid_request` — 排序失败或内部异常 + +--- + #### `GET /api/worldbuilding/:wid/chapters/:cid/review` — 章节写作评审 返回章节的量化分析和写作建议。 @@ -1654,6 +1957,43 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `GET /api/worldbuilding/:wid/scenes/:sid` — 获取单个场景 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `sid` | 场景 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `scene.id` | string | 场景 ID | +| `scene.title` | string | 场景标题 | +| `scene.chapter_id` | string | 所属章节 ID | +| `scene.world_time` | string | 世界时间 | +| `scene.narrative` | string | 叙述内容 | +| `scene.status` | string | 状态:`"draft"` / `"writing"` / `"completed"` | +| `scene.participant_ids` | string[] | 参与角色 ID | +| `scene.plot_goal` | string | 情节目标 | +| `scene.emotional_goal` | string | 情感目标 | +| `scene.information_goal` | string | 信息目标 | +| `scene.external_conflict` | string | 外部冲突 | +| `scene.internal_conflict` | string | 内部冲突 | +| `scene.foreshadowing_ids` | string[] | 关联伏笔 ID | +| `scene.style_overrides` | object | 风格覆盖 | +| `scene.section_id` | string\|null | 关联区域 ID | +| `scene.location_id` | string\|null | 关联地点 ID | +| `scene.pov_character_id` | string\|null | 视角角色 ID | +| `scene.hidden_conflict` | string\|null | 隐藏冲突 | + +**`404`**:`scene_not_found` + +--- + #### `POST /api/worldbuilding/:wid/scenes` — 创建场景 创建成功后通过 SSE 广播 `story_context_updated` 事件(若提供了 `session_id`)。初始状态固定为 `draft`。 @@ -1707,6 +2047,23 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `DELETE /api/worldbuilding/:wid/scenes/:sid` — 删除场景 + +同时清理关联父章节和区域的 `scene_ids` 引用。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `sid` | 场景 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`scene_not_found` + +--- + #### `POST /api/worldbuilding/:wid/scenes/:sid/end` — 结束场景 触发日记撰写、关系更新和伏笔建议。结束后通过 SSE 广播 `story_context_updated` 事件(若提供了 `session_id`)。 @@ -1865,6 +2222,21 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `DELETE /api/worldbuilding/:wid/foreshadowing/:id` — 删除伏笔 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `id` | 伏笔 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`foreshadow_not_found` + +--- + ### 4.7 秘密 #### `GET /api/worldbuilding/:wid/secrets` — 列出秘密 @@ -1943,11 +2315,430 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- -### 4.8 导出 - -#### `POST /api/worldbuilding/:wid/export` — 导出章节 +#### `DELETE /api/worldbuilding/:wid/secrets/:id` — 删除秘密 -将指定章节拼接导出为单个 Markdown 文件。 +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `id` | 秘密 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`secret_not_found` + +--- + +### 4.8 导出 + +#### `POST /api/worldbuilding/:wid/export` — 导出章节 + +将指定章节拼接导出为单个 Markdown 文件。 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `chapter_ids` | string[] | 是 | 要导出的章节 ID 列表,不能为空 | +| `title` | string | 否 | 导出文档标题,默认空字符串 | +| `author` | string | 否 | 作者名,默认空字符串 | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `file_path` | string | 导出文件的绝对路径 | +| `total_chars` | number | 导出文件的总字符数 | + +**`400`**:`missing_chapter_ids` — 缺少或无效的 chapter_ids +**`500`**:`export_failed` + +--- + +#### `POST /api/worldbuilding/:wid/export-full` — 导出世界完整快照 + +将世界所有数据导出为单一 JSON 快照(`share-snapshot-v1` 格式),用于备份或分享。 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `include_diaries` | boolean | 否 | 是否包含角色日记,默认 false | +| `include_memories` | boolean | 否 | 是否包含记忆摘要,默认 false | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `snapshot.schema_version` | string | 快照格式版本(`"1.0"`) | +| `snapshot.exported_at` | string | 导出时间 | +| `snapshot.snapshot_id` | string | 快照唯一标识 | +| `snapshot.source.world_id` | string | 源世界 ID | +| `snapshot.source.name` | string | 源世界名称 | +| `snapshot.manifest` | object | 清单(agent_count、chapter_count、scene_count 等) | +| `snapshot.payload` | object | 全量数据(world、agents、chapters、scenes、arcs、locations、factions、knowledge、foreshadowing、secrets、timeline、images) | + +> 图片处理:≤256KB 的图片 base64 内嵌在 `data` 字段;更大的图片 `data` 为 null 并标记 `"transfer": "external"`。 + +**`404`**:`world_not_found` +**`500`**:`export_failed` + +--- + +#### `POST /api/worldbuilding/import` — 导入世界快照 + +从 JSON 快照创建新世界,所有 ID 重映射。注意:此路径不含 `:wid`。 + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `snapshot` | object | 是 | 快照对象(export-full 产出的完整 JSON) | +| `target_name` | string | 否 | 目标世界名称,默认使用源世界名称 | + +**响应 `201`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `world_id` | string | 新创建的世界 ID | +| `id_mapping` | object | 旧 ID → 新 ID 的映射表 | + +> ID 重映射覆盖所有交叉引用字段(`participant_ids`、`scene_ids`、`member_agent_ids`、`rival_faction_ids`、`foreshadowing_ids`、`related_ids` 等)。`fork_chain` 数组记录派生链。内嵌的 base64 图片写入本地 ImageService,>256KB 的大图跳过。 + +**`400`**:`invalid_request` — 快照解析失败 +**`500`**:`export_failed` + +--- + +### 4.9 地点 + +#### `GET /api/worldbuilding/:wid/locations` — 列出地点 + +**路径参数**:`wid` — 世界 ID + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `locations` | array | 地点列表 | +| `locations[].id` | string | 地点 ID | +| `locations[].name` | string | 地点名称 | +| `locations[].description` | string | 地点描述 | +| `locations[].region` | string | 所属区域 | +| `locations[].parent_location_id` | string\|null | 父地点 ID | +| `locations[].created_at` | string | 创建时间 | + +--- + +#### `GET /api/worldbuilding/:wid/locations/:lid` — 获取单个地点 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `lid` | 地点 ID | + +**响应 `200`**:同列表中的 location 对象。 + +**`404`**:`location_not_found` + +--- + +#### `POST /api/worldbuilding/:wid/locations` — 创建地点 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | 地点名称 | +| `description` | string | 否 | 地点描述,默认空字符串 | +| `region` | string | 否 | 所属区域,默认空字符串 | +| `parent_location_id` | string | 否 | 父地点 ID | + +**响应 `201`**:`{"ok": true, "location_id": ""}` + +**`400`**:`invalid_request` — 缺少 name + +--- + +#### `PATCH /api/worldbuilding/:wid/locations/:lid` — 更新地点 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `lid` | 地点 ID | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `fields` | object | 是 | 要更新的字段(name, description, region, parent_location_id) | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`location_not_found` + +--- + +#### `DELETE /api/worldbuilding/:wid/locations/:lid` — 删除地点 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `lid` | 地点 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`location_not_found` + +--- + +### 4.10 知识条目 + +#### `GET /api/worldbuilding/:wid/knowledge` — 列出知识条目 + +**路径参数**:`wid` — 世界 ID + +**查询参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `category` | string | 否 | 按类别过滤,默认返回全部 | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `knowledge` | array | 知识条目列表 | +| `knowledge[].id` | string | 条目 ID | +| `knowledge[].category` | string | 类别 | +| `knowledge[].content` | string | 内容 | +| `knowledge[].tags` | string[] | 标签 | +| `knowledge[].aliases` | string[] | 别名 | +| `knowledge[].related_ids` | string[] | 关联条目 ID | +| `knowledge[].created_at` | string | 创建时间 | + +--- + +#### `POST /api/worldbuilding/:wid/knowledge` — 创建知识条目 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `category` | string | 是 | 类别 | +| `content` | string | 是 | 内容 | +| `tags` | string[] | 否 | 标签,默认空数组 | +| `aliases` | string[] | 否 | 别名,默认空数组 | +| `related_ids` | string[] | 否 | 关联条目 ID,默认空数组 | + +**响应 `201`**:`{"ok": true}` + +**`400`**:`invalid_request` — 缺少必填字段 + +--- + +#### `PATCH /api/worldbuilding/:wid/knowledge/:kid` — 更新知识条目 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `kid` | 知识条目 ID | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `fields` | object | 是 | 要更新的字段(category, content, tags, aliases, related_ids) | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`knowledge_not_found` + +--- + +#### `DELETE /api/worldbuilding/:wid/knowledge/:kid` — 删除知识条目 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `kid` | 知识条目 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`knowledge_not_found` + +--- + +### 4.11 势力 + +#### `GET /api/worldbuilding/:wid/factions` — 列出势力 + +**路径参数**:`wid` — 世界 ID + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `factions` | array | 势力列表 | +| `factions[].id` | string | 势力 ID | +| `factions[].world_id` | string | 所属世界 ID | +| `factions[].name` | string | 势力名称 | +| `factions[].description` | string | 势力描述 | +| `factions[].goals` | string | 势力目标 | +| `factions[].member_agent_ids` | string[] | 成员角色 ID | +| `factions[].rival_faction_ids` | string[] | 敌对势力 ID | +| `factions[].created_at` | string | 创建时间 | +| `factions[].updated_at` | string | 更新时间 | + +--- + +#### `GET /api/worldbuilding/:wid/factions/:fid` — 获取单个势力 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `fid` | 势力 ID | + +**响应 `200`**:同列表中的 faction 对象。 + +**`404`**:`faction_not_found` + +--- + +#### `POST /api/worldbuilding/:wid/factions` — 创建势力 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 是 | 势力名称 | +| `description` | string | 否 | 势力描述,默认空字符串 | +| `goals` | string | 否 | 势力目标,默认空字符串 | +| `member_agent_ids` | string[] | 否 | 成员角色 ID,默认空数组 | +| `rival_faction_ids` | string[] | 否 | 敌对势力 ID,默认空数组 | + +**响应 `201`**:`{"ok": true, "faction_id": ""}` + +**`400`**:`invalid_request` — 缺少 name + +--- + +#### `PATCH /api/worldbuilding/:wid/factions/:fid` — 更新势力 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `fid` | 势力 ID | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `fields` | object | 是 | 要更新的字段(name, description, goals, member_agent_ids, rival_faction_ids) | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`faction_not_found` + +--- + +#### `DELETE /api/worldbuilding/:wid/factions/:fid` — 删除势力 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `fid` | 势力 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`faction_not_found` + +--- + +### 4.12 仪表盘 + +#### `POST /api/worldbuilding/:wid/dashboard` — 世界创作仪表盘 + +聚合世界所有维度的统计数据,用于创作进度总览。 + +**路径参数**:`wid` — 世界 ID + +**请求体**:无 + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `dashboard.world` | object | 世界基本信息(id, name, description, created_at, updated_at) | +| `dashboard.agents` | object | Agent 统计:`total`(总数)、`by_kind`(按类型分布) | +| `dashboard.chapters` | object | 章节统计:`total`, `completed`, `drafting`, `outline`, `revised` | +| `dashboard.scenes` | object | 场景统计:`total`, `completed`, `writing`, `draft` | +| `dashboard.arcs` | object | 弧线:`total`(总数)、`items`(列表) | +| `dashboard.locations` | object | 地点:`total` | +| `dashboard.knowledge` | object | 知识条目:`total`、`by_category`(按类别计数) | +| `dashboard.factions` | object | 势力:`total` | +| `dashboard.secrets` | object | 秘密统计:`active`, `exposed`, `abandoned`, `total` | +| `dashboard.foreshadowing` | object | 伏笔统计:`open`, `paid`, `abandoned` | +| `dashboard.file_links` | object | 文件关联:`total` | +| `dashboard.reminders` | array | 待处理提醒列表(含 `type`, `id`, `content`/`holder_id` 等) | +| `dashboard.progress` | object | 进度百分比:`chapter_completion_pct`, `scene_completion_pct`, `foreshadowing_paid_pct`, `secret_exposed_pct` | + +**`404`**:`world_not_found` + +--- + +### 4.13 文件关联 + +用于管理世界资源与工作区文件的关联关系。 + +#### `GET /api/worldbuilding/:wid/files` — 列出文件关联 + +**路径参数**:`wid` — 世界 ID + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `files` | array | 文件关联列表(由 `list_file_links` 返回的原始 JSON) | + +--- + +#### `POST /api/worldbuilding/:wid/files` — 创建文件关联 **路径参数**:`wid` — 世界 ID @@ -1955,24 +2746,132 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `chapter_ids` | string[] | 是 | 要导出的章节 ID 列表,不能为空 | -| `title` | string | 否 | 导出文档标题,默认空字符串 | -| `author` | string | 否 | 作者名,默认空字符串 | +| `file_path` | string | 是 | 文件路径 | +| `target_type` | string | 是 | 关联目标类型 | +| `target_id` | string | 是 | 关联目标 ID | + +**响应 `201`**:`{"ok": true}` + +**`400`**:`invalid_request` — 缺少必填字段 + +--- + +#### `DELETE /api/worldbuilding/:wid/files/:file_path` — 删除文件关联 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `file_path` | 文件路径(URL 编码) | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `target_type` | string | 是 | 关联目标类型 | +| `target_id` | string | 是 | 关联目标 ID | + +**响应 `200`**:`{"ok": true}` + +**`404`**:`file_link_not_found` + +--- + +### 4.14 Agent 驱动的预览与创建 + +Agent 在执行创建操作时,先生成预览供用户确认,确认后存储为待定创建,最终由用户决定 allow/modify/deny。 + +#### `POST /api/worldbuilding/:wid/previews/:tool_name` — 构建创建预览 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `tool_name` | 工具名:`create_scene` / `create_chapter` / `create_arc` / `create_secret` / `add_world_knowledge` / `add_location` | + +**请求体**:以 `params` 字段嵌套或与顶层平铺的参数对象。 **响应 `200`**: | 字段 | 类型 | 说明 | |------|------|------| | `ok` | boolean | 操作是否成功 | -| `file_path` | string | 导出文件的绝对路径 | -| `total_chars` | number | 导出文件的总字符数 | +| `tool_name` | string | 工具名 | +| `preview` | object | 预览内容(结构因 tool_name 而异,含建议的字段值) | -**`400`**:`missing_chapter_ids` — 缺少或无效的 chapter_ids -**`500`**:`export_failed` +**`400`**:`unknown_tool` — 不支持的 tool_name + +--- + +#### `POST /api/worldbuilding/:wid/creations` — 存储待定创建 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `tool_name` | string | 是 | 工具名 | +| `params` | object | 是 | 创建参数 | +| `preview` | object | 是 | 预览内容 | + +**响应 `201`**:`{"ok": true, "creation_id": ""}` + +**`400`**:`invalid_request` — 缺少必填字段 + +--- + +#### `GET /api/worldbuilding/:wid/creations/:creation_id` — 获取待定创建 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `creation_id` | 创建请求 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `creation.creation_id` | string | 创建请求 ID | +| `creation.tool_name` | string | 工具名 | +| `creation.world_id` | string | 世界 ID | +| `creation.params` | object | 创建参数 | +| `creation.preview` | object | 预览内容 | + +**`404`**:`creation_not_found` + +--- + +#### `POST /api/worldbuilding/:wid/creations/:creation_id/resolve` — 处理创建请求 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `creation_id` | 创建请求 ID | + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `decision` | string | 是 | `"allow"` / `"modify"` / `"deny"` | +| `modifications` | object | 否 | 修改内容(`decision="modify"` 时使用),默认空对象 | + +**响应 `200`**: +- `deny`:`{"ok": true, "decision": "deny", "creation_id": ""}` +- `allow` / `modify`:`{"ok": true, "result": {...}}`,result 中根据 tool_name 包含 `scene_id` / `chapter_id` / `arc_id` / `secret_id` 等 + +**`400`**:待定创建不存在 --- -### 4.9 Pipeline +### 4.15 Pipeline #### `GET /api/worldbuilding/:wid/pipeline/state` — 获取 Pipeline 状态 @@ -2100,6 +2999,240 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` --- +#### `POST /api/worldbuilding/:wid/pipeline/retreat` — 回退 Pipeline 阶段 + +回退到指定阶段,重置目标阶段的所有条件状态。 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `to_phase` | string | 是 | 目标阶段(如 `"outlining"`、`"drafting"`、`"revising"`、`"polishing"`) | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `state` | object | 更新后的 Pipeline 状态(同 state 接口响应) | + +**`400`**:回退失败,返回: +```json +{"ok": false, "error": "<失败原因字符串>"} +``` + +**`503`**:Pipeline Manager 未初始化 + +--- + +#### `POST /api/worldbuilding/:wid/pipeline/clear-error` — 清除 Pipeline 错误 + +清除上一步错误状态,恢复可推进状态。 + +**路径参数**:`wid` — 世界 ID + +**请求体**:无 + +**响应 `200`**:`{"ok": true}` + +**`400`**:清除失败,返回 `{"ok": false, "error": "<失败原因>"}` +**`503`**:Pipeline Manager 未初始化 + +--- + +### 4.16 Knowledge Graph + +用于查询世界知识图谱中的实体、关系及场景抽取结果。注意:此处为只读查询端点,extraction 由 GodAgent 在对话中完成。 + +#### `GET /api/worldbuilding/:wid/knowledge-graph/entities` — 列出 KG 实体 + +**路径参数**:`wid` — 世界 ID + +**查询参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `type` | string | 否 | 实体类型过滤:`"agent"`、`"location"`、`"faction"`、`"knowledge"`,默认返回全部 | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `entities` | array | 实体列表 | +| `entities[].id` | string | 实体 ID | +| `entities[].name` | string | 实体名称 | +| `entities[].type` | string | 实体类型 | +| `entities[].properties` | object | 实体属性 | + +**`404`**:`world_not_found` + +--- + +#### `GET /api/worldbuilding/:wid/knowledge-graph/entities/:eid/relations` — 获取实体关系 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `eid` | 实体 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `entity.id` | string | 实体 ID | +| `entity.name` | string | 实体名称 | +| `relations` | array | 关系列表 | +| `relations[].relation_type` | string | 关系类型 | +| `relations[].target_id` | string | 目标实体 ID | +| `relations[].target_name` | string | 目标实体名称 | +| `relations[].target_type` | string | 目标实体类型 | + +**`404`**:`world_not_found`;实体不存在 + +--- + +#### `GET /api/worldbuilding/:wid/knowledge-graph/search` — 搜索 KG 实体 + +**路径参数**:`wid` — 世界 ID + +**查询参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `q` | string | 是 | 搜索关键词 | +| `type` | string | 否 | 实体类型过滤 | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `results` | array | 匹配结果列表 | +| `results[].id` | string | 实体 ID | +| `results[].name` | string | 实体名称 | +| `results[].type` | string | 实体类型 | + +**`400`**:`invalid_request` — 缺少 q 参数 +**`404`**:`world_not_found` + +--- + +#### `GET /api/worldbuilding/:wid/scenes/:sid/extraction-result` — 获取场景抽取结果 + +返回某场戏的 KG extraction 候选结果。 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `sid` | 场景 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `candidates` | array | 候选关系列表 | +| `candidates[].relation.subject_id` | string | 主体 ID | +| `candidates[].relation.predicate` | string | 谓语/关系 | +| `candidates[].relation.object_id` | string | 客体 ID | +| `candidates[].status` | string | 候选状态 | +| `candidates[].evidence` | string | 证据文本 | +| `candidates[].change_summary` | string | 变更摘要 | + +**`404`**:`scene_not_found` + +--- + +### 4.17 Timeline + +世界时间线管理,包括事件记录和世界时间推进。 + +#### `GET /api/worldbuilding/:wid/timeline` — 获取时间线 + +**路径参数**:`wid` — 世界 ID + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `current_time.day` | number | 当前天数 | +| `current_time.period` | number | 当前时段(0=晨, 1=昼, 2=午, 3=晚, 4=夜) | +| `current_time.label` | string | 当前时间可读标签 | +| `events` | array | 时间线事件列表 | +| `events[].id` | string | 事件 ID | +| `events[].world_time` | string | 世界时间 | +| `events[].description` | string | 事件描述 | +| `events[].recorded_by` | string | 记录者 ID | +| `events[].affected_character_ids` | string[] | 受影响角色 ID | +| `events[].related_scene_ids` | string[] | 关联场景 ID | + +**`404`**:`world_not_found` + +--- + +#### `GET /api/worldbuilding/:wid/timeline/events/:eid` — 获取单个时间线事件 + +**路径参数**: + +| 参数 | 说明 | +|------|------| +| `wid` | 世界 ID | +| `eid` | 事件 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `event.id` | string | 事件 ID | +| `event.world_time` | string | 世界时间 | +| `event.description` | string | 事件描述 | +| `event.recorded_by` | string | 记录者 ID | +| `event.recorded_by_name` | string | 记录者名称 | +| `event.affected_character_ids` | string[] | 受影响角色 ID | +| `event.related_scene_ids` | string[] | 关联场景 ID | + +**`404`**:`world_not_found`;事件不存在 + +--- + +#### `POST /api/worldbuilding/:wid/timeline/advance` — 推进世界时间 + +推进世界时间并可选记录时间线事件。替代旧的 `/api/worldbuilding/:wid/time/advance` 端点。 + +**路径参数**:`wid` — 世界 ID + +**请求体**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `time_label` | string | 是 | 时间描述,支持中英文格式和 duration 简写(如 `"第3日晚"`、`"day3_evening"`、`"2d"`、`"4h"`) | +| `description` | string | 否 | 事件描述 | +| `recorded_by` | string | 否 | 记录者 ID | + +**响应 `200`**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | boolean | 操作是否成功 | +| `new_time.day` | number | 新天数 | +| `new_time.period` | number | 新时段 | +| `new_time.label` | string | 新时间可读标签 | + +**`400`**:`time_not_forward` — 新时间不晚于当前世界时间 +**`404`**:`world_not_found` + +--- + ## 附录 A:乐观锁与版本控制 以下接口支持乐观锁,通过 `version` 字段防止并发写入冲突: @@ -2130,7 +3263,7 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` | DelegationPattern | `fan_out`, `sequential`, `pipeline` | | ApprovalDecision | `allow`, `deny` | | CreationDecision | `allow`, `deny`, `modify` | -| AgentKind | `individual`, `group`, `relation_manager` | +| AgentKind | `god`, `map_manager`, `history_manager`, `magic_system_manager`, `faction_manager`, `relation_manager`, `individual`, `group`, `writer` | | TimePeriod | 0=晨, 1=昼, 2=午, 3=晚, 4=夜 | | PreferredStyle | `轻松`, `严肃`, `诗意`, `简洁` | @@ -2171,7 +3304,7 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` | PUT | `/api/workspace/files/content` | 保存文件内容 | | POST | `/api/workspace/open` | 打开文件/目录 | -### 世界观(46 个) +### 世界观(95 个) | 方法 | 路径 | 分类 | |------|------|------| @@ -2184,9 +3317,18 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` | POST | `/api/worldbuilding/:wid/agents` | Agent | | GET | `/api/worldbuilding/:wid/agents/:aid` | Agent | | PATCH | `/api/worldbuilding/:wid/agents/:aid` | Agent | +| DELETE | `/api/worldbuilding/:wid/agents/:aid` | Agent | | GET | `/api/worldbuilding/:wid/agents/:aid/diaries` | Agent | | POST | `/api/worldbuilding/:wid/agents/:aid/diaries` | Agent | +| GET | `/api/worldbuilding/:wid/agents/:aid/diaries/:did` | Agent | +| PATCH | `/api/worldbuilding/:wid/agents/:aid/diaries/:did` | Agent | +| GET | `/api/worldbuilding/:wid/agents/:aid/diaries/search` | Agent | +| GET | `/api/worldbuilding/:wid/agents/:aid/memory-summaries` | Agent | +| GET | `/api/worldbuilding/:wid/agents/:aid/memory-summaries/:mid` | Agent | | GET | `/api/worldbuilding/:wid/agents/:aid/relations` | Agent | +| POST | `/api/worldbuilding/:wid/agents/:aid/relations` | Agent | +| PATCH | `/api/worldbuilding/:wid/agents/:aid/relations/:tid` | Agent | +| GET | `/api/worldbuilding/:wid/agents/:aid/voice` | Agent | | GET | `/api/worldbuilding/agents/:aid/prompt` | Agent | | GET | `/api/worldbuilding/images/:iid` | 图片 | | GET | `/api/worldbuilding/:wid/agents/:aid/images` | 图片 | @@ -2199,30 +3341,70 @@ Linux 使用 `xdg-open`,macOS 使用 `open`,Windows 使用 `ShellExecuteW` | POST | `/api/worldbuilding/:wid/agents/:aid/images/chunked/:uid/complete` | 图片 | | DELETE | `/api/worldbuilding/:wid/agents/:aid/images/chunked/:uid` | 图片 | | GET | `/api/worldbuilding/:wid/overview` | 叙事 | +| GET | `/api/worldbuilding/:wid/tree` | 叙事 | | GET | `/api/worldbuilding/:wid/chapters` | 叙事 | | GET | `/api/worldbuilding/:wid/chapters/:cid` | 叙事 | | PATCH | `/api/worldbuilding/:wid/chapters/:cid` | 叙事 | +| DELETE | `/api/worldbuilding/:wid/chapters/:cid` | 叙事 | +| POST | `/api/worldbuilding/:wid/chapters/reorder` | 叙事 | | GET | `/api/worldbuilding/:wid/chapters/:cid/review` | 叙事 | | GET | `/api/worldbuilding/:wid/scenes` | 叙事 | +| GET | `/api/worldbuilding/:wid/scenes/:sid` | 叙事 | | POST | `/api/worldbuilding/:wid/scenes` | 叙事 | | PATCH | `/api/worldbuilding/:wid/scenes/:sid` | 叙事 | +| DELETE | `/api/worldbuilding/:wid/scenes/:sid` | 叙事 | | POST | `/api/worldbuilding/:wid/scenes/:sid/end` | 叙事 | | GET | `/api/worldbuilding/:wid/time` | 时间 | | POST | `/api/worldbuilding/:wid/time/advance` | 时间 | | GET | `/api/worldbuilding/:wid/foreshadowing` | 伏笔 | | POST | `/api/worldbuilding/:wid/foreshadowing` | 伏笔 | | PATCH | `/api/worldbuilding/:wid/foreshadowing/:id` | 伏笔 | +| DELETE | `/api/worldbuilding/:wid/foreshadowing/:id` | 伏笔 | | GET | `/api/worldbuilding/:wid/secrets` | 秘密 | | POST | `/api/worldbuilding/:wid/secrets` | 秘密 | | PATCH | `/api/worldbuilding/:wid/secrets/:id` | 秘密 | +| DELETE | `/api/worldbuilding/:wid/secrets/:id` | 秘密 | | POST | `/api/worldbuilding/:wid/export` | 导出 | +| POST | `/api/worldbuilding/:wid/export-full` | 导出 | +| POST | `/api/worldbuilding/import` | 导出 | +| GET | `/api/worldbuilding/:wid/locations` | 地点 | +| GET | `/api/worldbuilding/:wid/locations/:lid` | 地点 | +| POST | `/api/worldbuilding/:wid/locations` | 地点 | +| PATCH | `/api/worldbuilding/:wid/locations/:lid` | 地点 | +| DELETE | `/api/worldbuilding/:wid/locations/:lid` | 地点 | +| GET | `/api/worldbuilding/:wid/knowledge` | 知识 | +| POST | `/api/worldbuilding/:wid/knowledge` | 知识 | +| PATCH | `/api/worldbuilding/:wid/knowledge/:kid` | 知识 | +| DELETE | `/api/worldbuilding/:wid/knowledge/:kid` | 知识 | +| GET | `/api/worldbuilding/:wid/factions` | 势力 | +| GET | `/api/worldbuilding/:wid/factions/:fid` | 势力 | +| POST | `/api/worldbuilding/:wid/factions` | 势力 | +| PATCH | `/api/worldbuilding/:wid/factions/:fid` | 势力 | +| DELETE | `/api/worldbuilding/:wid/factions/:fid` | 势力 | +| POST | `/api/worldbuilding/:wid/dashboard` | 仪表盘 | +| GET | `/api/worldbuilding/:wid/files` | 文件关联 | +| POST | `/api/worldbuilding/:wid/files` | 文件关联 | +| DELETE | `/api/worldbuilding/:wid/files/:file_path` | 文件关联 | +| POST | `/api/worldbuilding/:wid/previews/:tool_name` | Agent 驱动 | +| POST | `/api/worldbuilding/:wid/creations` | Agent 驱动 | +| GET | `/api/worldbuilding/:wid/creations/:creation_id` | Agent 驱动 | +| POST | `/api/worldbuilding/:wid/creations/:creation_id/resolve` | Agent 驱动 | | GET | `/api/worldbuilding/:wid/pipeline/state` | Pipeline | | POST | `/api/worldbuilding/:wid/pipeline/advance` | Pipeline | | GET | `/api/worldbuilding/pipeline/workflows` | Pipeline | | GET | `/api/worldbuilding/pipeline/history` | Pipeline | | POST | `/api/worldbuilding/:wid/pipeline/activate` | Pipeline | - -**总计:74 个 API 端点**(28 core + 46 worldbuilding),加 1 个 SSE 流端点,加 1 个静态文件挂载(`/`)。 +| POST | `/api/worldbuilding/:wid/pipeline/retreat` | Pipeline | +| POST | `/api/worldbuilding/:wid/pipeline/clear-error` | Pipeline | +| GET | `/api/worldbuilding/:wid/knowledge-graph/entities` | KG | +| GET | `/api/worldbuilding/:wid/knowledge-graph/entities/:eid/relations` | KG | +| GET | `/api/worldbuilding/:wid/knowledge-graph/search` | KG | +| GET | `/api/worldbuilding/:wid/scenes/:sid/extraction-result` | KG | +| GET | `/api/worldbuilding/:wid/timeline` | Timeline | +| GET | `/api/worldbuilding/:wid/timeline/events/:eid` | Timeline | +| POST | `/api/worldbuilding/:wid/timeline/advance` | Timeline | + +**总计:123 个 API 端点**(28 core + 95 worldbuilding),加 1 个 SSE 流端点,加 1 个静态文件挂载(`/`)。 ---