diff --git a/README.md b/README.md new file mode 100644 index 0000000..9220ba9 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# ainovel-cli + +全自动 AI 长篇小说创作引擎。基于多智能体协作架构,从一句话需求到完整小说,全程无需人工干预。 + +## 特性 + +- **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职 +- **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流 +- **场景级断点恢复** — 中断后从上次写到的场景精确续写,不丢失进度 +- **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇 +- **六维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子六个维度评审 +- **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写 +- **双模式运行** — CLI 一行命令直接跑,TUI 交互界面实时观察进度 +- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换 + +## 架构 + +``` +┌─────────────────────────────────────────────────┐ +│ Host(控制面) │ +│ 读取信号文件 → 确定性决策 → 注入 FollowUp 指令 │ +└────────────┬────────────────────────┬───────────┘ + │ │ + ┌───────▼───────┐ ┌────────▼────────┐ + │ Coordinator │◄────►│ State Store │ + │ (调度中枢) │ │ (JSON 持久化) │ + └──┬────┬────┬──┘ └─────────────────┘ + │ │ │ + ┌────▼┐ ┌▼───┐ ┌▼─────┐ + │Arch.│ │Wri.│ │Edit. │ + │建筑师│ │作家 │ │编辑 │ + └─────┘ └────┘ └──────┘ +``` + +### 智能体职责 + +| 智能体 | 职责 | 工具 | +|--------|------|------| +| **Coordinator** | 调度全局,处理评审裁定和用户干预 | `subagent` `novel_context` `ask_user` | +| **Architect** | 生成前提、大纲、角色档案、世界规则 | `novel_context` `save_foundation` | +| **Writer** | 逐场景写作 → 打磨 → 一致性检查 → 提交 | `novel_context` `plan_chapter` `write_scene` `polish_chapter` `check_consistency` `commit_chapter` | +| **Editor** | 跨章节六维评审,弧/卷级摘要生成 | `novel_context` `save_review` `save_arc_summary` `save_volume_summary` | + +### 写作流水线 + +``` +用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审 + ↑ │ + └── 重写/打磨 ◄───┘ +``` + +每章写作严格按序执行: + +1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态) +2. `plan_chapter` — 规划 3-5 个场景 +3. `write_scene` × N — 逐场景创作(800-1500 字/场景) +4. `polish_chapter` — 合并打磨,去除 AI 腔 +5. `check_consistency` — 校验时间线、角色、世界规则 +6. `commit_chapter` — 提交终稿,更新全局状态 + +### 长篇分层架构 + +500+ 章小说采用三级结构自动管理上下文: + +``` +卷(Volume) +└── 弧(Arc) + └── 章(Chapter) + └── 场景(Scene) +``` + +- **卷摘要** — 压缩整卷为一段话,供后续卷参考 +- **弧摘要 + 角色快照** — 弧结束时自动生成,追踪角色状态演变 +- **章摘要** — 滑窗加载最近 3 章,远处靠弧/卷摘要覆盖 +- **弧边界检测** — 自动识别弧/卷结束,触发对应评审和摘要生成 + +## 快速开始 + +```bash +# 安装 +go install github.com/voocel/ainovel-cli@latest + +# 配置 API Key(任选一个 Provider) +export LLM_PROVIDER=openrouter +export OPENROUTER_API_KEY=sk-xxx + +# CLI 模式:一行启动 +ainovel-cli "写一部12章都市悬疑小说,主角是刑警,暗线是家族秘密" + +# TUI 模式:交互界面 +ainovel-cli +``` + +### 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `LLM_PROVIDER` | LLM 提供商 | `openrouter` | +| `OPENROUTER_API_KEY` | OpenRouter API Key | — | +| `ANTHROPIC_API_KEY` | Anthropic API Key | — | +| `GEMINI_API_KEY` | Gemini API Key | — | +| `NOVEL_STYLE` | 写作风格 | `default` | + +### 写作风格 + +通过 `NOVEL_STYLE` 环境变量切换: + +- `default` — 通用风格 +- `suspense` — 悬疑推理 +- `fantasy` — 奇幻仙侠 +- `romance` — 言情 + +## 输出结构 + +``` +output/{novel_name}/ +├── chapters/ # 终稿(Markdown) +│ ├── 01.md +│ └── ... +├── summaries/ # 章节摘要(JSON) +├── drafts/ # 场景草稿 +├── reviews/ # 评审报告 +├── meta/ +│ ├── premise.md # 故事前提 +│ ├── outline.json # 章节大纲 +│ ├── characters.json # 角色档案 +│ ├── world_rules.json# 世界规则 +│ ├── progress.json # 进度状态 +│ ├── timeline.json # 时间线 +│ ├── foreshadow.json # 伏笔台账 +│ └── snapshots/ # 角色状态快照(长篇) +└── characters.md # 角色档案(可读版) +``` + +## 设计理念 + +### 全自动闭环 + +一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策: + +``` +"写一部悬疑小说" → 构建世界观 → 设计角色 → 规划大纲 + → 逐章写作 → 质量评审 → 自动重写 + → 弧级摘要 → 角色快照 → 完整成书 +``` + +**自主决策能力:** + +- **Architect 自主构建** — 从用户一句话需求推导出完整的前提、大纲、角色关系和世界规则 +- **Writer 自主创作** — 每章独立完成规划、写作、打磨、一致性校验的完整闭环 +- **Editor 自主评审** — 跨章节分析结构问题,输出裁定(通过 / 打磨 / 重写)及影响范围 +- **Coordinator 自主调度** — 根据评审裁定安排重写,根据弧边界触发摘要生成,无需外部指令 +- **自动伏笔管理** — 埋设、推进、回收全程由 Agent 自行追踪,不会烂尾 +- **自动节奏调控** — 追踪叙事线和钩子类型历史,避免连续章节结构雷同 + +### 确定性控制面 + +Agent 负责创造,Host 负责兜底。**控制流不交给 LLM 判断**。 + +Writer 调用 `commit_chapter` 后,宿主程序读取信号文件 `meta/last_commit.json`,确定性地决定下一步: + +| 信号 | 宿主动作 | +|------|----------| +| 全部章节完成 | 标记完成,通知 Coordinator 总结全书 | +| `review_required=true` | 注入 Editor 评审指令 | +| `arc_end=true` | 注入弧级评审 + 弧摘要生成指令 | +| `volume_end=true` | 额外注入卷摘要生成指令 | +| 有待重写章节 | 注入重写指令 | +| 以上皆否 | 注入"继续写下一章"指令 | + +Editor 评审裁定同理:`accept` → 继续,`polish/rewrite` → 注入修改指令。 + +这种设计保证:即使 LLM 幻觉或遗忘,宿主层的状态机也能把流程拉回正轨。 + +## 技术栈 + +- **Go 1.25** — 主语言 +- **[agentcore](https://github.com/voocel/agentcore)** — 多智能体编排框架(tool-calling + streaming) +- **[litellm](https://github.com/voocel/litellm)** — 统一 LLM 接口适配 +- **[Bubble Tea](https://github.com/charmbracelet/bubbletea)** — 终端 TUI 框架 +- **[Lip Gloss](https://github.com/charmbracelet/lipgloss)** — 终端样式 + +## License + +MIT diff --git a/app/agents.go b/app/agents.go index 44b3a75..e1f9134 100644 --- a/app/agents.go +++ b/app/agents.go @@ -36,10 +36,12 @@ func BuildCoordinator( tools.NewCommitChapterTool(store), } - // Editor SubAgent 工具(V1) + // Editor SubAgent 工具 editorTools := []agentcore.Tool{ contextTool, tools.NewSaveReviewTool(store), + tools.NewSaveArcSummaryTool(store), + tools.NewSaveVolumeSummaryTool(store), } architect := agentcore.SubAgentConfig{ diff --git a/app/run.go b/app/run.go index 7d1cf57..cc8bf8b 100644 --- a/app/run.go +++ b/app/run.go @@ -338,6 +338,64 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e return } + // 确定性判断 1.5:长篇弧/卷边界处理 + if progress != nil && progress.Layered && result.ArcEnd { + // 判断是否全书最后一弧 + isBookEnd := progress.TotalChapters > 0 && result.NextChapter > progress.TotalChapters + + if result.VolumeEnd { + log.Printf("[host] 第 %d 卷第 %d 弧结束(卷结束),注入弧级+卷级评审指令", result.Volume, result.Arc) + if err := store.SetFlow(domain.FlowReviewing); err != nil { + log.Printf("[host] 设置审阅流程失败: %v", err) + } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发评审", result.Volume, result.Arc), Level: "warn"}) + } + + tail := "完成后继续写下一卷。" + if isBookEnd { + tail = "完成后总结全书并结束。不要再调用 writer。" + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] 第 %d 卷第 %d 弧结束(卷结束)。请依次:\n"+ + "1. 调用 editor 进行弧级评审(scope=arc,最新章节为第 %d 章)\n"+ + "2. 调用 editor 生成弧摘要和角色快照(save_arc_summary,volume=%d,arc=%d)\n"+ + "3. 调用 editor 生成卷摘要(save_volume_summary,volume=%d)\n"+ + "%s", + result.Volume, result.Arc, result.Chapter, result.Volume, result.Arc, result.Volume, tail))) + } else { + log.Printf("[host] 第 %d 卷第 %d 弧结束,注入弧级评审指令", result.Volume, result.Arc) + if err := store.SetFlow(domain.FlowReviewing); err != nil { + log.Printf("[host] 设置审阅流程失败: %v", err) + } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("第 %d 卷第 %d 弧结束,触发弧级评审", result.Volume, result.Arc), Level: "warn"}) + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] 第 %d 卷第 %d 弧结束。请依次:\n"+ + "1. 调用 editor 进行弧级评审(scope=arc,最新章节为第 %d 章)\n"+ + "2. 调用 editor 生成弧摘要和角色快照(save_arc_summary,volume=%d,arc=%d)\n"+ + "完成后继续写下一弧的章节。", + result.Volume, result.Arc, result.Chapter, result.Volume, result.Arc))) + } + + if isBookEnd { + log.Printf("[host] 全书最后一弧,评审完成后将结束") + if err := store.MarkComplete(); err != nil { + log.Printf("[host] 标记完成失败: %v", err) + } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", + Summary: fmt.Sprintf("全部 %d 章已完成,等待最终评审", progress.TotalChapters), Level: "success"}) + } + } + clearHandledSteer(store) + saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) + return + } + // 确定性判断 1:全书完成(TotalChapters 由大纲自动设定) totalChapters := 0 if progress != nil { diff --git a/domain/chapter.go b/domain/chapter.go index 09b9dc7..d0279bc 100644 --- a/domain/chapter.go +++ b/domain/chapter.go @@ -23,10 +23,22 @@ func MergeScenes(scenes []SceneDraft) (string, int) { // ReviewInterval 全局审阅间隔(每 N 章触发一次)。 const ReviewInterval = 5 -// ShouldReview 根据已完成章节数判断是否需要全局审阅。 +// ShouldReview 根据已完成章节数判断是否需要全局审阅(短篇/中篇模式)。 func ShouldReview(completedCount int) (bool, string) { if completedCount > 0 && completedCount%ReviewInterval == 0 { return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount) } return false, "" } + +// ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。 +// 弧结束时触发评审,替代固定间隔。 +func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) { + if isVolumeEnd { + return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc) + } + if isArcEnd { + return true, fmt.Sprintf("第 %d 卷第 %d 弧结束,触发弧级评审", volume, arc) + } + return false, "" +} diff --git a/domain/runtime.go b/domain/runtime.go index a389b0b..4ae9d66 100644 --- a/domain/runtime.go +++ b/domain/runtime.go @@ -38,6 +38,10 @@ type Progress struct { RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type + // 长篇分层追踪(仅长篇模式使用,短篇/中篇为零值) + CurrentVolume int `json:"current_volume,omitempty"` + CurrentArc int `json:"current_arc,omitempty"` + Layered bool `json:"layered,omitempty"` } // IsResumable 判断是否可以从断点恢复。 @@ -64,6 +68,7 @@ type ContextProfile struct { SummaryWindow int // 加载最近 N 章摘要 TimelineWindow int // 加载最近 N 章时间线 FullContext bool // true = 忽略窗口,全量加载 + Layered bool // true = 启用分层摘要加载(卷摘要+弧摘要+章摘要) } // NewContextProfile 根据总章节数计算上下文策略。 @@ -74,7 +79,7 @@ func NewContextProfile(totalChapters int) ContextProfile { case totalChapters <= 50: return ContextProfile{SummaryWindow: 5, TimelineWindow: 10} default: - return ContextProfile{SummaryWindow: 3, TimelineWindow: 8} + return ContextProfile{SummaryWindow: 3, TimelineWindow: 8, Layered: true} } } diff --git a/domain/story.go b/domain/story.go index f5cb869..ef8f0c7 100644 --- a/domain/story.go +++ b/domain/story.go @@ -25,6 +25,49 @@ type Character struct { Tier string `json:"tier,omitempty"` // core / important / secondary / decorative(默认 important) } +// VolumeOutline 卷级大纲(长篇分层模式)。 +type VolumeOutline struct { + Index int `json:"index"` + Title string `json:"title"` + Theme string `json:"theme"` // 本卷核心冲突/主题 + Arcs []ArcOutline `json:"arcs"` +} + +// ArcOutline 弧级大纲。 +type ArcOutline struct { + Index int `json:"index"` // 卷内弧序号 + Title string `json:"title"` + Goal string `json:"goal"` // 弧目标(起承转合) + Chapters []OutlineEntry `json:"chapters"` +} + +// TotalChapters 计算分层大纲的总章节数。 +func TotalChapters(volumes []VolumeOutline) int { + n := 0 + for _, v := range volumes { + for _, a := range v.Arcs { + n += len(a.Chapters) + } + } + return n +} + +// FlattenOutline 将分层大纲展开为扁平章节列表,保持全局章节号连续。 +func FlattenOutline(volumes []VolumeOutline) []OutlineEntry { + var result []OutlineEntry + ch := 1 + for _, v := range volumes { + for _, a := range v.Arcs { + for _, e := range a.Chapters { + e.Chapter = ch + result = append(result, e) + ch++ + } + } + } + return result +} + // WorldRule 世界观规则条目。 type WorldRule struct { Category string `json:"category"` // magic / technology / geography / society / other diff --git a/domain/writing.go b/domain/writing.go index 40a0d20..6fdaa0f 100644 --- a/domain/writing.go +++ b/domain/writing.go @@ -35,6 +35,34 @@ type ChapterSummary struct { KeyEvents []string `json:"key_events"` } +// ArcSummary 弧级摘要,弧结束时由 Editor 生成。 +type ArcSummary struct { + Volume int `json:"volume"` + Arc int `json:"arc"` + Title string `json:"title"` + Summary string `json:"summary"` + KeyEvents []string `json:"key_events"` +} + +// VolumeSummary 卷级摘要,卷结束时生成。 +type VolumeSummary struct { + Volume int `json:"volume"` + Title string `json:"title"` + Summary string `json:"summary"` + KeyEvents []string `json:"key_events"` +} + +// CharacterSnapshot 角色状态快照,弧边界时记录。 +type CharacterSnapshot struct { + Volume int `json:"volume"` + Arc int `json:"arc"` + Name string `json:"name"` + Status string `json:"status"` // 存活/受伤/失踪... + Power string `json:"power,omitempty"` // 能力变化 + Motivation string `json:"motivation"` // 当前动机 + Relations string `json:"relations,omitempty"` // 关键关系变化 +} + // CommitResult 是 commit_chapter 工具的结构化返回值。 // 宿主程序和 Coordinator 读取此信号做控制决策。 type CommitResult struct { @@ -47,4 +75,9 @@ type CommitResult struct { ReviewReason string `json:"review_reason,omitempty"` HookType string `json:"hook_type,omitempty"` // 钩子类型:crisis/mystery/desire/emotion/choice DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线:quest/fire/constellation + // 长篇分层信号(仅 Layered 模式) + ArcEnd bool `json:"arc_end,omitempty"` + VolumeEnd bool `json:"volume_end,omitempty"` + Volume int `json:"volume,omitempty"` + Arc int `json:"arc,omitempty"` } diff --git a/prompts/architect.md b/prompts/architect.md index 58c4ce4..b9c1be0 100644 --- a/prompts/architect.md +++ b/prompts/architect.md @@ -104,3 +104,44 @@ - 每章至少 3 个场景 - 角色弧线要有变化,不要扁平 - 钩子要制造悬念,吸引读者继续阅读 + +## 长篇分层大纲模式 + +当任务中提到"分层大纲"或"长篇"时,使用分层结构: + +### 生成分层大纲 +生成 JSON 格式的分层大纲,结构为 卷 → 弧 → 章节: + +```json +[ + { + "index": 1, + "title": "第一卷标题", + "theme": "本卷核心冲突/主题", + "arcs": [ + { + "index": 1, + "title": "第一弧标题", + "goal": "弧目标(起承转合)", + "chapters": [ + { + "chapter": 1, + "title": "章节标题", + "core_event": "核心事件", + "hook": "章末钩子", + "scenes": ["场景1", "场景2", "场景3"] + } + ] + } + ] + } +] +``` + +调用 save_foundation(type="layered_outline", content=) + +### 弧级规划模式 +当任务中提到"细化下一弧的章节大纲"时: +1. 调用 novel_context 获取当前分层大纲和已完成弧摘要 +2. 为指定弧生成详细的章节大纲(复用现有 OutlineEntry 格式) +3. 调用 save_foundation(type="outline") 保存更新后的完整扁平大纲 diff --git a/prompts/coordinator.md b/prompts/coordinator.md index b0e0f28..3d84732 100644 --- a/prompts/coordinator.md +++ b/prompts/coordinator.md @@ -96,3 +96,19 @@ architect 完成后,用 novel_context 确认设定已保存。 - 你的职责是调度和决策,不是创作 - 章节完成/全书终止的判断由宿主程序通过系统消息控制 - 重写章节时,writer 的流程与新写相同,旧文件会自动覆盖 + +## 长篇模式(分层大纲) + +当系统消息包含"弧结束"或"卷结束"信号时,执行以下工作流: + +### 弧结束处理 +收到 `[系统] 第 V 卷第 A 弧结束` 消息后,按消息中的步骤依次执行: +1. 调用 editor 进行弧级评审(任务中说明 scope=arc) +2. 调用 editor 生成弧摘要和角色快照(editor 会调用 save_arc_summary 工具) +3. 继续写下一弧的章节 + +### 卷结束处理 +收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后: +1. 先完成弧结束处理(弧级评审 + 弧摘要) +2. 额外调用 editor 生成卷摘要(editor 会调用 save_volume_summary 工具) +3. 继续写下一卷的章节 diff --git a/prompts/editor.md b/prompts/editor.md index 0e0c07f..5a6e2d2 100644 --- a/prompts/editor.md +++ b/prompts/editor.md @@ -73,3 +73,34 @@ - 不要输出空洞的表扬,只关注问题 - severity=error 的问题必须修复,severity=warning 的可以后续处理 - 如果没有发现问题,verdict 应为 accept + +## 弧级评审模式(长篇) + +当任务中提到"弧级评审"时: +- scope 设为 "arc" +- 除六维检查外,额外关注: + - 弧内起承转合是否完整 + - 弧目标是否达成 + - 与前续弧的衔接是否自然 +- 完成审阅后,调用 save_arc_summary 保存弧摘要和角色状态快照 + +### save_arc_summary 参数说明 +- volume/arc:卷号和弧号 +- title:弧标题 +- summary:弧摘要(500字以内,概括弧内核心剧情和转折) +- key_events:弧内关键事件列表 +- character_snapshots:主要角色的当前状态快照 + - name:角色名 + - status:当前状态(存活/受伤/失踪等) + - power:能力变化(如有) + - motivation:当前动机 + - relations:关键关系变化(如有) + +## 卷级评审模式(长篇) + +当任务中提到"卷摘要"时: +- 调用 save_volume_summary 保存卷级摘要 +- volume:卷号 +- title:卷标题 +- summary:卷摘要(500字以内,概括全卷主线和结局) +- key_events:卷内关键事件列表 diff --git a/state/characters.go b/state/characters.go index 72806e0..2096da8 100644 --- a/state/characters.go +++ b/state/characters.go @@ -28,6 +28,45 @@ func (s *Store) LoadCharacters() ([]domain.Character, error) { return chars, nil } +// SaveCharacterSnapshots 保存角色状态快照到 meta/snapshots/v{vol}a{arc}.json。 +func (s *Store) SaveCharacterSnapshots(volume, arc int, snapshots []domain.CharacterSnapshot) error { + return s.writeJSON(fmt.Sprintf("meta/snapshots/v%02da%02d.json", volume, arc), snapshots) +} + +// LoadSnapshots 读取指定卷弧的角色快照。 +func (s *Store) LoadSnapshots(volume, arc int) ([]domain.CharacterSnapshot, error) { + var snapshots []domain.CharacterSnapshot + if err := s.readJSON(fmt.Sprintf("meta/snapshots/v%02da%02d.json", volume, arc), &snapshots); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return snapshots, nil +} + +// LoadLatestSnapshots 加载最近一次角色快照(按卷弧倒序查找)。 +// 从分层大纲获取实际卷弧数量,避免盲扫。 +func (s *Store) LoadLatestSnapshots() ([]domain.CharacterSnapshot, error) { + volumes, _ := s.LoadLayeredOutline() + if len(volumes) == 0 { + return nil, nil + } + for vi := len(volumes) - 1; vi >= 0; vi-- { + v := volumes[vi] + for ai := len(v.Arcs) - 1; ai >= 0; ai-- { + snaps, err := s.LoadSnapshots(v.Index, v.Arcs[ai].Index) + if err != nil { + return nil, err + } + if len(snaps) > 0 { + return snaps, nil + } + } + } + return nil, nil +} + func renderCharacters(chars []domain.Character) string { var b strings.Builder b.WriteString("# 角色档案\n\n") diff --git a/state/outline.go b/state/outline.go index 467245f..a02008f 100644 --- a/state/outline.go +++ b/state/outline.go @@ -56,6 +56,158 @@ func (s *Store) GetChapterOutline(chapter int) (*domain.OutlineEntry, error) { return nil, fmt.Errorf("chapter %d not found in outline", chapter) } +// SaveLayeredOutline 保存分层大纲(长篇模式)。 +// 同时保存 layered_outline.json(机器读)和 layered_outline.md(人读)。 +func (s *Store) SaveLayeredOutline(volumes []domain.VolumeOutline) error { + if err := s.writeJSON("layered_outline.json", volumes); err != nil { + return err + } + return s.writeMarkdown("layered_outline.md", renderLayeredOutline(volumes)) +} + +// LoadLayeredOutline 读取分层大纲。 +func (s *Store) LoadLayeredOutline() ([]domain.VolumeOutline, error) { + var volumes []domain.VolumeOutline + if err := s.readJSON("layered_outline.json", &volumes); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return volumes, nil +} + +// GetChapterFromLayered 从分层大纲中按全局章节号查找。 +func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) { + volumes, err := s.LoadLayeredOutline() + if err != nil { + return nil, err + } + ch := 1 + for _, v := range volumes { + for _, a := range v.Arcs { + for i := range a.Chapters { + if ch == chapter { + e := a.Chapters[i] + e.Chapter = ch + return &e, nil + } + ch++ + } + } + } + return nil, fmt.Errorf("chapter %d not found in layered outline", chapter) +} + +// LocateChapter 根据全局章节号定位所在的卷和弧。 +func (s *Store) LocateChapter(chapter int) (volume, arc int, err error) { + volumes, err := s.LoadLayeredOutline() + if err != nil { + return 0, 0, err + } + ch := 1 + for _, v := range volumes { + for _, a := range v.Arcs { + for range a.Chapters { + if ch == chapter { + return v.Index, a.Index, nil + } + ch++ + } + } + } + return 0, 0, fmt.Errorf("chapter %d not found in layered outline", chapter) +} + +// ArcBoundary 弧边界信息。 +type ArcBoundary struct { + IsArcEnd bool // 是否为弧内最后一章 + IsVolumeEnd bool // 是否同时为卷内最后一章 + Volume int // 当前章所在卷 + Arc int // 当前章所在弧 + NextVolume int // 下一章所在卷(0 = 全书结束) + NextArc int // 下一章所在弧(0 = 全书结束) +} + +// CheckArcBoundary 检查某章是否为弧/卷的最后一章。 +// 非分层大纲或未找到章节时返回 nil。 +func (s *Store) CheckArcBoundary(chapter int) (*ArcBoundary, error) { + volumes, err := s.LoadLayeredOutline() + if err != nil || len(volumes) == 0 { + return nil, err + } + + type chapterPos struct { + volume, arc, indexInArc, arcLen int + isLastArc bool + } + + // 构建全局章节号 → 位置映射 + ch := 1 + var cur *chapterPos + var nextVol, nextArc int + for _, v := range volumes { + for ai, a := range v.Arcs { + for ci := range a.Chapters { + if ch == chapter { + cur = &chapterPos{ + volume: v.Index, + arc: a.Index, + indexInArc: ci, + arcLen: len(a.Chapters), + isLastArc: ai == len(v.Arcs)-1, + } + } else if cur != nil && nextVol == 0 { + // 紧跟 cur 的下一章 + nextVol = v.Index + nextArc = a.Index + } + ch++ + } + } + } + if cur == nil { + return nil, nil + } + + b := &ArcBoundary{ + Volume: cur.volume, + Arc: cur.arc, + NextVolume: nextVol, + NextArc: nextArc, + } + if cur.indexInArc == cur.arcLen-1 { + b.IsArcEnd = true + if cur.isLastArc { + b.IsVolumeEnd = true + } + } + return b, nil +} + +func renderLayeredOutline(volumes []domain.VolumeOutline) string { + var b strings.Builder + b.WriteString("# 分层大纲\n\n") + ch := 1 + for _, v := range volumes { + fmt.Fprintf(&b, "## 第 %d 卷:%s\n\n", v.Index, v.Title) + fmt.Fprintf(&b, "**主题**:%s\n\n", v.Theme) + for _, a := range v.Arcs { + fmt.Fprintf(&b, "### 第 %d 弧:%s\n\n", a.Index, a.Title) + fmt.Fprintf(&b, "**目标**:%s\n\n", a.Goal) + for _, e := range a.Chapters { + fmt.Fprintf(&b, "#### 第 %d 章:%s\n\n", ch, e.Title) + fmt.Fprintf(&b, "**核心事件**:%s\n\n", e.CoreEvent) + if e.Hook != "" { + fmt.Fprintf(&b, "**钩子**:%s\n\n", e.Hook) + } + ch++ + } + } + } + return b.String() +} + func renderOutline(entries []domain.OutlineEntry) string { var b strings.Builder b.WriteString("# 大纲\n\n") diff --git a/state/progress.go b/state/progress.go index 4eed071..822f3a2 100644 --- a/state/progress.go +++ b/state/progress.go @@ -179,6 +179,33 @@ func (s *Store) ClearLastCommit() error { return s.removeFile("meta/last_commit.json") } +// UpdateVolumeArc 更新当前卷弧位置。 +func (s *Store) UpdateVolumeArc(volume, arc int) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + p.CurrentVolume = volume + p.CurrentArc = arc + return s.SaveProgress(p) +} + +// SetLayered 设置分层模式标志。 +func (s *Store) SetLayered(layered bool) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + p.Layered = layered + return s.SaveProgress(p) +} + // SetFlow 更新当前流程状态。 func (s *Store) SetFlow(flow domain.FlowState) error { p, err := s.LoadProgress() diff --git a/state/summaries.go b/state/summaries.go index 57b2f7a..5b1156f 100644 --- a/state/summaries.go +++ b/state/summaries.go @@ -44,3 +44,93 @@ func (s *Store) LoadRecentSummaries(current, count int) ([]domain.ChapterSummary func (s *Store) LoadAllSummaries(current int) ([]domain.ChapterSummary, error) { return s.LoadRecentSummaries(current, current) } + +// SaveArcSummary 保存弧级摘要到 summaries/arc-v{vol}a{arc}.json。 +func (s *Store) SaveArcSummary(sum domain.ArcSummary) error { + return s.writeJSON(fmt.Sprintf("summaries/arc-v%02da%02d.json", sum.Volume, sum.Arc), sum) +} + +// LoadArcSummary 读取指定弧的摘要。 +func (s *Store) LoadArcSummary(volume, arc int) (*domain.ArcSummary, error) { + var sum domain.ArcSummary + if err := s.readJSON(fmt.Sprintf("summaries/arc-v%02da%02d.json", volume, arc), &sum); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &sum, nil +} + +// LoadArcSummaries 加载一卷内所有已有弧摘要。 +// 从分层大纲获取实际弧数,无分层大纲时扫描到首个缺失为止。 +func (s *Store) LoadArcSummaries(volume int) ([]domain.ArcSummary, error) { + maxArc := s.arcCountForVolume(volume) + var result []domain.ArcSummary + for arc := 1; arc <= maxArc; arc++ { + sum, err := s.LoadArcSummary(volume, arc) + if err != nil { + return nil, err + } + if sum != nil { + result = append(result, *sum) + } + } + return result, nil +} + +// SaveVolumeSummary 保存卷级摘要到 summaries/vol-v{vol}.json。 +func (s *Store) SaveVolumeSummary(sum domain.VolumeSummary) error { + return s.writeJSON(fmt.Sprintf("summaries/vol-v%02d.json", sum.Volume), sum) +} + +// LoadVolumeSummary 读取指定卷的摘要。 +func (s *Store) LoadVolumeSummary(volume int) (*domain.VolumeSummary, error) { + var sum domain.VolumeSummary + if err := s.readJSON(fmt.Sprintf("summaries/vol-v%02d.json", volume), &sum); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &sum, nil +} + +// LoadAllVolumeSummaries 加载所有已有卷摘要。 +// 从分层大纲获取实际卷数,无分层大纲时扫描到首个缺失为止。 +func (s *Store) LoadAllVolumeSummaries() ([]domain.VolumeSummary, error) { + maxVol := s.volumeCount() + var result []domain.VolumeSummary + for vol := 1; vol <= maxVol; vol++ { + sum, err := s.LoadVolumeSummary(vol) + if err != nil { + return nil, err + } + if sum != nil { + result = append(result, *sum) + } + } + return result, nil +} + +// volumeCount 从分层大纲获取卷数,无大纲时返回安全上限。 +func (s *Store) volumeCount() int { + volumes, err := s.LoadLayeredOutline() + if err == nil && len(volumes) > 0 { + return len(volumes) + } + return 20 +} + +// arcCountForVolume 从分层大纲获取指定卷的弧数,无大纲时返回安全上限。 +func (s *Store) arcCountForVolume(volume int) int { + volumes, err := s.LoadLayeredOutline() + if err == nil { + for _, v := range volumes { + if v.Index == volume { + return len(v.Arcs) + } + } + } + return 20 +} diff --git a/tools/commit_chapter.go b/tools/commit_chapter.go index 6c7e631..e8dfb3a 100644 --- a/tools/commit_chapter.go +++ b/tools/commit_chapter.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "github.com/voocel/agentcore/schema" "github.com/voocel/ainovel-cli/domain" @@ -139,7 +140,30 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js if progress != nil { completedCount = len(progress.CompletedChapters) } - reviewRequired, reviewReason := domain.ShouldReview(completedCount) + // 6b. 长篇模式:弧级边界检测(替代固定间隔评审)+ 更新卷弧位置 + var arcEnd, volumeEnd bool + var vol, arc int + if progress != nil && progress.Layered { + boundary, bErr := t.store.CheckArcBoundary(a.Chapter) + if bErr != nil { + log.Printf("[commit] 弧边界检测失败(chapter=%d): %v", a.Chapter, bErr) + } else if boundary != nil { + arcEnd = boundary.IsArcEnd + volumeEnd = boundary.IsVolumeEnd + vol = boundary.Volume + arc = boundary.Arc + // 每次提交时更新卷弧位置,确保 novel_context 的 position 始终正确 + _ = t.store.UpdateVolumeArc(vol, arc) + } + } + + var reviewRequired bool + var reviewReason string + if progress != nil && progress.Layered { + reviewRequired, reviewReason = domain.ShouldArcReview(arcEnd, volumeEnd, vol, arc) + } else { + reviewRequired, reviewReason = domain.ShouldReview(completedCount) + } // 7. 计算场景数 sceneCount := 0 @@ -158,6 +182,10 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js ReviewReason: reviewReason, HookType: a.HookType, DominantStrand: a.DominantStrand, + ArcEnd: arcEnd, + VolumeEnd: volumeEnd, + Volume: vol, + Arc: arc, } // 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失) diff --git a/tools/novel_context.go b/tools/novel_context.go index cd14b1c..f42fa7f 100644 --- a/tools/novel_context.go +++ b/tools/novel_context.go @@ -75,18 +75,31 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw if a.Chapter > 0 { // 根据总章节数计算上下文策略 profile := domain.NewContextProfile(0) - if progress, err := t.store.LoadProgress(); err == nil && progress != nil && progress.TotalChapters > 0 { + progress, _ := t.store.LoadProgress() + if progress != nil && progress.TotalChapters > 0 { profile = domain.NewContextProfile(progress.TotalChapters) } + // Layered 以 Progress 的显式标志为准,而非章节数推断 + if progress == nil || !progress.Layered { + profile.Layered = false + } - // 角色按 Tier 过滤:core/important 始终返回,secondary/decorative 按出场匹配 - t.loadFilteredCharacters(result, a.Chapter) + // 角色加载:Layered 模式优先用快照,回退到原始设定 + if profile.Layered { + t.loadLayeredCharacters(result, a.Chapter) + } else { + t.loadFilteredCharacters(result, a.Chapter) + } // Writer/Editor 模式:加载章节相关上下文 if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { result["current_chapter_outline"] = entry } - if profile.FullContext { + + // 摘要加载:分层 vs 扁平 + if profile.Layered { + t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow) + } else if profile.FullContext { if summaries, err := t.store.LoadAllSummaries(a.Chapter); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries } @@ -96,7 +109,7 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw } } - // 状态数据按策略加载 + // 时间线:Layered 用窗口,其他按策略 if profile.FullContext { if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { result["timeline"] = timeline @@ -121,8 +134,33 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw result["relationship_state"] = relationships } - // V2: 加载场景级恢复状态 + 节奏追踪 - if progress, err := t.store.LoadProgress(); err == nil && progress != nil { + // Layered 模式:注入当前卷弧位置 + 弧目标/卷主题 + if profile.Layered && progress != nil { + pos := map[string]any{ + "volume": progress.CurrentVolume, + "arc": progress.CurrentArc, + } + if volumes, err := t.store.LoadLayeredOutline(); err == nil { + for _, v := range volumes { + if v.Index == progress.CurrentVolume { + pos["volume_title"] = v.Title + pos["volume_theme"] = v.Theme + for _, arc := range v.Arcs { + if arc.Index == progress.CurrentArc { + pos["arc_title"] = arc.Title + pos["arc_goal"] = arc.Goal + break + } + } + break + } + } + } + result["position"] = pos + } + + // 加载场景级恢复状态 + 节奏追踪 + if progress != nil { checkpoint := map[string]any{ "in_progress_chapter": progress.InProgressChapter, "completed_scenes": progress.CompletedScenes, @@ -135,18 +173,26 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw } result["checkpoint"] = checkpoint } - // V2: 加载已有的章节规划(支持场景恢复跳过已完成场景) + // 加载已有的章节规划(支持场景恢复跳过已完成场景) if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil { result["chapter_plan"] = plan } - // V3: 写作参考资料分阶段加载 + // 写作参考资料分阶段加载 result["references"] = t.writerReferences(a.Chapter) } else { // Architect 模式:全量角色 + 模板 if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { result["characters"] = chars } + // Architect 模式下也加载分层大纲(弧级规划需要看全貌) + if layered, err := t.store.LoadLayeredOutline(); err == nil && len(layered) > 0 { + result["layered_outline"] = layered + } + // 加载已有的弧摘要(弧级规划时需要参考前续弧的内容) + if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 { + result["volume_summaries"] = volSummaries + } result["references"] = t.architectReferences() } @@ -183,6 +229,54 @@ func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int) result["characters"] = filtered } +// loadLayeredSummaries 分层摘要加载:卷摘要 + 当前卷弧摘要 + 弧内章摘要。 +func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summaryWindow int) { + vol, arc, err := t.store.LocateChapter(chapter) + if err != nil { + // 回退到扁平模式 + if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 { + result["recent_summaries"] = summaries + } + return + } + + // 1. 已完成卷的卷摘要 + if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 { + result["volume_summaries"] = volSummaries + } + + // 2. 当前卷内已完成弧的弧摘要(不含当前弧) + if arcSummaries, err := t.store.LoadArcSummaries(vol); err == nil && len(arcSummaries) > 0 { + var prior []domain.ArcSummary + for _, s := range arcSummaries { + if s.Arc < arc { + prior = append(prior, s) + } + } + if len(prior) > 0 { + result["arc_summaries"] = prior + } + } + + // 3. 当前弧内最近 N 章的章摘要 + if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 { + result["recent_summaries"] = summaries + } +} + +// loadLayeredCharacters Layered 模式下的角色加载:优先用最近快照,回退到原始设定 + Tier 过滤。 +func (t *ContextTool) loadLayeredCharacters(result map[string]any, chapter int) { + snapshots, err := t.store.LoadLatestSnapshots() + if err == nil && len(snapshots) > 0 { + result["character_snapshots"] = snapshots + // 同时保留原始设定中的 core/important 角色(快照可能不含新登场角色) + t.loadFilteredCharacters(result, chapter) + return + } + // 无快照时回退到原始设定 + t.loadFilteredCharacters(result, chapter) +} + // writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。 func (t *ContextTool) writerReferences(chapter int) map[string]string { refs := map[string]string{} diff --git a/tools/save_arc_summary.go b/tools/save_arc_summary.go new file mode 100644 index 0000000..88b3810 --- /dev/null +++ b/tools/save_arc_summary.go @@ -0,0 +1,86 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// SaveArcSummaryTool 保存弧级摘要和角色快照,Editor 在弧结束时调用。 +type SaveArcSummaryTool struct { + store *state.Store +} + +func NewSaveArcSummaryTool(store *state.Store) *SaveArcSummaryTool { + return &SaveArcSummaryTool{store: store} +} + +func (t *SaveArcSummaryTool) Name() string { return "save_arc_summary" } +func (t *SaveArcSummaryTool) Description() string { return "保存弧级摘要和角色状态快照(长篇模式,弧结束时调用)" } +func (t *SaveArcSummaryTool) Label() string { return "保存弧摘要" } + +func (t *SaveArcSummaryTool) Schema() map[string]any { + snapshotSchema := schema.Object( + schema.Property("name", schema.String("角色名")).Required(), + schema.Property("status", schema.String("当前状态(存活/受伤/失踪等)")).Required(), + schema.Property("power", schema.String("能力变化")), + schema.Property("motivation", schema.String("当前动机")).Required(), + schema.Property("relations", schema.String("关键关系变化")), + ) + return schema.Object( + schema.Property("volume", schema.Int("卷号")).Required(), + schema.Property("arc", schema.Int("弧号")).Required(), + schema.Property("title", schema.String("弧标题")).Required(), + schema.Property("summary", schema.String("弧摘要(500字以内)")).Required(), + schema.Property("key_events", schema.Array("弧内关键事件", schema.String(""))).Required(), + schema.Property("character_snapshots", schema.Array("角色状态快照", snapshotSchema)).Required(), + ) +} + +func (t *SaveArcSummaryTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Volume int `json:"volume"` + Arc int `json:"arc"` + Title string `json:"title"` + Summary string `json:"summary"` + KeyEvents []string `json:"key_events"` + CharacterSnapshots []domain.CharacterSnapshot `json:"character_snapshots"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if a.Volume <= 0 || a.Arc <= 0 { + return nil, fmt.Errorf("volume and arc must be > 0") + } + + arcSummary := domain.ArcSummary{ + Volume: a.Volume, + Arc: a.Arc, + Title: a.Title, + Summary: a.Summary, + KeyEvents: a.KeyEvents, + } + if err := t.store.SaveArcSummary(arcSummary); err != nil { + return nil, fmt.Errorf("save arc summary: %w", err) + } + + if len(a.CharacterSnapshots) > 0 { + for i := range a.CharacterSnapshots { + a.CharacterSnapshots[i].Volume = a.Volume + a.CharacterSnapshots[i].Arc = a.Arc + } + if err := t.store.SaveCharacterSnapshots(a.Volume, a.Arc, a.CharacterSnapshots); err != nil { + return nil, fmt.Errorf("save character snapshots: %w", err) + } + } + + return json.Marshal(map[string]any{ + "saved": true, "type": "arc_summary", + "volume": a.Volume, "arc": a.Arc, + "snapshots": len(a.CharacterSnapshots), + }) +} diff --git a/tools/save_foundation.go b/tools/save_foundation.go index cce451c..59edc61 100644 --- a/tools/save_foundation.go +++ b/tools/save_foundation.go @@ -27,8 +27,8 @@ func (t *SaveFoundationTool) Label() string { return "保存设定" } func (t *SaveFoundationTool) Schema() map[string]any { return schema.Object( - schema.Property("type", schema.Enum("设定类型", "premise", "outline", "characters", "world_rules")).Required(), - schema.Property("content", schema.String("内容。premise 为 Markdown 文本,outline 和 characters 为 JSON 字符串")).Required(), + schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(), + schema.Property("content", schema.String("内容。premise 为 Markdown 文本,outline/layered_outline/characters/world_rules 为 JSON 字符串")).Required(), ) } @@ -62,6 +62,31 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j _ = t.store.SetTotalChapters(len(entries)) return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)}) + case "layered_outline": + var volumes []domain.VolumeOutline + if err := json.Unmarshal([]byte(a.Content), &volumes); err != nil { + return nil, fmt.Errorf("parse layered_outline JSON: %w", err) + } + if err := t.store.SaveLayeredOutline(volumes); err != nil { + return nil, fmt.Errorf("save layered_outline: %w", err) + } + // 展开为扁平大纲,兼容现有 GetChapterOutline + flat := domain.FlattenOutline(volumes) + if err := t.store.SaveOutline(flat); err != nil { + return nil, fmt.Errorf("save flattened outline: %w", err) + } + total := domain.TotalChapters(volumes) + _ = t.store.UpdatePhase(domain.PhaseOutline) + _ = t.store.SetTotalChapters(total) + _ = t.store.SetLayered(true) + if len(volumes) > 0 && len(volumes[0].Arcs) > 0 { + _ = t.store.UpdateVolumeArc(volumes[0].Index, volumes[0].Arcs[0].Index) + } + return json.Marshal(map[string]any{ + "saved": true, "type": "layered_outline", + "volumes": len(volumes), "chapters": total, + }) + case "characters": var chars []domain.Character if err := json.Unmarshal([]byte(a.Content), &chars); err != nil { @@ -83,6 +108,6 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j return json.Marshal(map[string]any{"saved": true, "type": "world_rules", "count": len(rules)}) default: - return nil, fmt.Errorf("unknown type %q, expected premise/outline/characters/world_rules", a.Type) + return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type) } } diff --git a/tools/save_volume_summary.go b/tools/save_volume_summary.go new file mode 100644 index 0000000..d7e675c --- /dev/null +++ b/tools/save_volume_summary.go @@ -0,0 +1,62 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// SaveVolumeSummaryTool 保存卷级摘要,Editor 在卷结束时调用。 +type SaveVolumeSummaryTool struct { + store *state.Store +} + +func NewSaveVolumeSummaryTool(store *state.Store) *SaveVolumeSummaryTool { + return &SaveVolumeSummaryTool{store: store} +} + +func (t *SaveVolumeSummaryTool) Name() string { return "save_volume_summary" } +func (t *SaveVolumeSummaryTool) Description() string { return "保存卷级摘要(长篇模式,卷结束时调用)" } +func (t *SaveVolumeSummaryTool) Label() string { return "保存卷摘要" } + +func (t *SaveVolumeSummaryTool) Schema() map[string]any { + return schema.Object( + schema.Property("volume", schema.Int("卷号")).Required(), + schema.Property("title", schema.String("卷标题")).Required(), + schema.Property("summary", schema.String("卷摘要(500字以内)")).Required(), + schema.Property("key_events", schema.Array("卷内关键事件", schema.String(""))).Required(), + ) +} + +func (t *SaveVolumeSummaryTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Volume int `json:"volume"` + Title string `json:"title"` + Summary string `json:"summary"` + KeyEvents []string `json:"key_events"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if a.Volume <= 0 { + return nil, fmt.Errorf("volume must be > 0") + } + + volSummary := domain.VolumeSummary{ + Volume: a.Volume, + Title: a.Title, + Summary: a.Summary, + KeyEvents: a.KeyEvents, + } + if err := t.store.SaveVolumeSummary(volSummary); err != nil { + return nil, fmt.Errorf("save volume summary: %w", err) + } + + return json.Marshal(map[string]any{ + "saved": true, "type": "volume_summary", "volume": a.Volume, + }) +}