From 3cb21a592bad8c91e3d4d039745c5de473344d33 Mon Sep 17 00:00:00 2001 From: ULookup Date: Fri, 19 Jun 2026 12:11:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20P2=20backend=20completion=20=E2=80=94?= =?UTF-8?q?=2019=20endpoints=20across=205=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pipeline: retreat (with allowed_retreat validation + scene cleanup), clear-error - Diary/Memory/Relations/Voice: diary get/patch/search, memory summary list/get, relation create/update, voice fingerprint - World Export/Import: full snapshot export (share-snapshot-v1), import with ID remapping - Knowledge Graph: entity list, entity relations, search, scene extraction result - Timeline: timeline list, event get, time advance (Chinese/English/duration formats) - Docs: 5 new API reference sections, endpoint count 104 → 123 --- docs/api-reference.md | 1216 ++++++++++++- .../merak/worldbuilding_http_handler.hpp | 86 +- libs/http/src/worldbuilding_http_handler.cpp | 1502 ++++++++++++++++- .../include/merak/worldbuilding/ids.hpp | 50 + .../merak/worldbuilding/narrative_store.hpp | 3 + .../merak/worldbuilding/pipeline_manager.hpp | 1 + .../worldbuilding/worldbuilding_service.hpp | 21 + libs/worldbuilding/src/narrative_store.cpp | 45 + libs/worldbuilding/src/pipeline_manager.cpp | 109 ++ .../src/worldbuilding_service.cpp | 955 +++++++++++ 10 files changed, 3932 insertions(+), 56 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 个静态文件挂载(`/`)。 --- diff --git a/libs/http/include/merak/worldbuilding_http_handler.hpp b/libs/http/include/merak/worldbuilding_http_handler.hpp index 94118be2..ba02b182 100644 --- a/libs/http/include/merak/worldbuilding_http_handler.hpp +++ b/libs/http/include/merak/worldbuilding_http_handler.hpp @@ -5,8 +5,14 @@ #include #include #include +#include +#include +#include #include +#include #include +#include +#include namespace merak { @@ -17,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); @@ -37,6 +44,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&); @@ -58,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&); @@ -80,6 +93,10 @@ 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&); + void handle_pipeline_clear_error(const httplib::Request&, httplib::Response&); + // Factions void handle_list_factions(const httplib::Request&, httplib::Response&); void handle_get_faction(const httplib::Request&, httplib::Response&); @@ -103,6 +120,56 @@ 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); + + // Agent-driven: suggestions + 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&); + + // 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&); + + // 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&); + + // 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&); + + // 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_; + + // 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&); @@ -110,13 +177,25 @@ 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&); + 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&); + void handle_memory_summary_get(const httplib::Request&, httplib::Response&); // 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&); @@ -138,6 +217,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 1a609f0b..3bf7ebdd 100644 --- a/libs/http/src/worldbuilding_http_handler.cpp +++ b/libs/http/src/worldbuilding_http_handler.cpp @@ -1,13 +1,21 @@ #include +#include #include #include #include #include +#include + +#include #include +#include #include #include #include +#include +#include +#include #include namespace merak { @@ -177,10 +185,132 @@ 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, ""); + + { + 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}, + {"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) { + 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; + + 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(pending.world_id, + pending.operation_type, messages); + } catch (...) { + // Best-effort caching; don't crash + } +} + +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) { @@ -206,6 +336,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)", @@ -220,11 +352,31 @@ 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.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/([^/]+))", + [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); }); + // 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); }); @@ -268,6 +420,10 @@ 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.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)", @@ -280,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)", @@ -287,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); }); @@ -375,6 +541,39 @@ 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); }); + + 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); }); + + // ─── 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); }); + + // ─── 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); }); + + // ─── 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) { @@ -555,6 +754,20 @@ 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); }); + + 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 --- @@ -662,7 +875,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_) { @@ -1147,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) { @@ -1338,6 +1714,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) { @@ -1372,56 +1860,237 @@ void WorldbuildingHttpHandler::handle_agent_relations(const httplib::Request& re } } -// --- PATCH scene / chapter --- +// --- Agent relation create --- -void WorldbuildingHttpHandler::handle_patch_scene(const httplib::Request& req, httplib::Response& res) { - std::string wid = req.matches[1]; - std::string sid = req.matches[2]; +void WorldbuildingHttpHandler::handle_agent_relation_upsert(const httplib::Request& req, httplib::Response& res) { try { - auto scene = service_->narrative().get_scene(wid, sid); - if (!scene) { - error_response(res, "Scene not found", 404, "scene_not_found"); + 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); - auto fields = body.at("fields"); - if (fields.contains("status")) { - auto parsed = worldbuilding::parse_scene_status(fields["status"].get()); - if (!parsed) { - error_response(res, "Invalid scene status", 400); - return; - } + if (!body.contains("target_id") || !body["target_id"].is_string()) { + error_response(res, "target_id is required", 400); + return; } - service_->narrative().patch_scene(wid, sid, fields); - json_response(res, {{"ok", true}}); - } catch (const worldbuilding::VersionConflictError& e) { - nlohmann::json j = { - {"ok", false}, - {"error", { - {"code", "version_conflict"}, - {"message", "资源已被其他来源修改,请刷新后重试"}, - {"current_version", e.current_version}, - {"retryable", true} - }} - }; - res.status = 409; - res.set_content(j.dump(), "application/json"); + 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); } } -void WorldbuildingHttpHandler::handle_patch_chapter(const httplib::Request& req, httplib::Response& res) { - std::string wid = req.matches[1]; - std::string cid = req.matches[2]; +// --- Agent relation update --- + +void WorldbuildingHttpHandler::handle_agent_relation_update(const httplib::Request& req, httplib::Response& res) { try { - auto chapter = service_->narrative().get_chapter(wid, cid); - if (!chapter) { - error_response(res, "Chapter not found", 404, "chapter_not_found"); + 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; } - auto body = nlohmann::json::parse(req.body); - auto fields = body.at("fields"); + 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) { + 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) { + std::string wid = req.matches[1]; + std::string sid = req.matches[2]; + try { + auto scene = service_->narrative().get_scene(wid, sid); + if (!scene) { + error_response(res, "Scene not found", 404, "scene_not_found"); + return; + } + auto body = nlohmann::json::parse(req.body); + auto fields = body.at("fields"); + if (fields.contains("status")) { + auto parsed = worldbuilding::parse_scene_status(fields["status"].get()); + if (!parsed) { + error_response(res, "Invalid scene status", 400); + return; + } + } + service_->narrative().patch_scene(wid, sid, fields); + json_response(res, {{"ok", true}}); + } catch (const worldbuilding::VersionConflictError& e) { + nlohmann::json j = { + {"ok", false}, + {"error", { + {"code", "version_conflict"}, + {"message", "资源已被其他来源修改,请刷新后重试"}, + {"current_version", e.current_version}, + {"retryable", true} + }} + }; + res.status = 409; + res.set_content(j.dump(), "application/json"); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +void WorldbuildingHttpHandler::handle_patch_chapter(const httplib::Request& req, httplib::Response& res) { + std::string wid = req.matches[1]; + std::string cid = req.matches[2]; + try { + auto chapter = service_->narrative().get_chapter(wid, cid); + if (!chapter) { + error_response(res, "Chapter not found", 404, "chapter_not_found"); + return; + } + auto body = nlohmann::json::parse(req.body); + auto fields = body.at("fields"); if (fields.contains("status")) { auto parsed = worldbuilding::parse_chapter_status(fields["status"].get()); if (!parsed) { @@ -1830,6 +2499,175 @@ 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"); + } +} + +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) { @@ -2353,4 +3191,592 @@ 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); + } +} + +// --- 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}}); + } catch (const std::exception& e) { + error_response(res, e.what(), 400); + } +} + +// --- 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); + } +} + +// --- 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); + } +} + +// --- 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); + } +} + +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]; + 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); + } +} + +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); + } +} + +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"]; + 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 + 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["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) { + 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); + } + 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)); + 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); +} + +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"); + } +} + +// --- 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 diff --git a/libs/worldbuilding/include/merak/worldbuilding/ids.hpp b/libs/worldbuilding/include/merak/worldbuilding/ids.hpp index 38dba419..8cf95edf 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/ids.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/ids.hpp @@ -1,14 +1,64 @@ #pragma once +#include #include +#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 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/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/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/include/merak/worldbuilding/worldbuilding_service.hpp b/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp index f2eaf451..76437551 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/worldbuilding_service.hpp @@ -168,6 +168,27 @@ 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); + + 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/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) { diff --git a/libs/worldbuilding/src/pipeline_manager.cpp b/libs/worldbuilding/src/pipeline_manager.cpp index 33958284..ac2d1f6a 100644 --- a/libs/worldbuilding/src/pipeline_manager.cpp +++ b/libs/worldbuilding/src/pipeline_manager.cpp @@ -970,6 +970,115 @@ 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; + } + + // 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; + 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{}; diff --git a/libs/worldbuilding/src/worldbuilding_service.cpp b/libs/worldbuilding/src/worldbuilding_service.cpp index 6acb35ad..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 @@ -886,5 +887,959 @@ 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; +} + +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