feat: 支持长篇小说分层架构(卷/弧/章三级结构)

This commit is contained in:
voocel
2026-03-12 16:27:15 +08:00
parent 3d65afa276
commit bce0adeff1
19 changed files with 1045 additions and 16 deletions

185
README.md Normal file
View File

@@ -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

View File

@@ -36,10 +36,12 @@ func BuildCoordinator(
tools.NewCommitChapterTool(store), tools.NewCommitChapterTool(store),
} }
// Editor SubAgent 工具V1 // Editor SubAgent 工具
editorTools := []agentcore.Tool{ editorTools := []agentcore.Tool{
contextTool, contextTool,
tools.NewSaveReviewTool(store), tools.NewSaveReviewTool(store),
tools.NewSaveArcSummaryTool(store),
tools.NewSaveVolumeSummaryTool(store),
} }
architect := agentcore.SubAgentConfig{ architect := agentcore.SubAgentConfig{

View File

@@ -338,6 +338,64 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
return 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_summaryvolume=%darc=%d\n"+
"3. 调用 editor 生成卷摘要save_volume_summaryvolume=%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_summaryvolume=%darc=%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 由大纲自动设定) // 确定性判断 1全书完成TotalChapters 由大纲自动设定)
totalChapters := 0 totalChapters := 0
if progress != nil { if progress != nil {

View File

@@ -23,10 +23,22 @@ func MergeScenes(scenes []SceneDraft) (string, int) {
// ReviewInterval 全局审阅间隔(每 N 章触发一次)。 // ReviewInterval 全局审阅间隔(每 N 章触发一次)。
const ReviewInterval = 5 const ReviewInterval = 5
// ShouldReview 根据已完成章节数判断是否需要全局审阅。 // ShouldReview 根据已完成章节数判断是否需要全局审阅(短篇/中篇模式)
func ShouldReview(completedCount int) (bool, string) { func ShouldReview(completedCount int) (bool, string) {
if completedCount > 0 && completedCount%ReviewInterval == 0 { if completedCount > 0 && completedCount%ReviewInterval == 0 {
return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount) return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount)
} }
return false, "" 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, ""
}

View File

@@ -38,6 +38,10 @@ type Progress struct {
RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因
StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand
HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type 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 判断是否可以从断点恢复。 // IsResumable 判断是否可以从断点恢复。
@@ -64,6 +68,7 @@ type ContextProfile struct {
SummaryWindow int // 加载最近 N 章摘要 SummaryWindow int // 加载最近 N 章摘要
TimelineWindow int // 加载最近 N 章时间线 TimelineWindow int // 加载最近 N 章时间线
FullContext bool // true = 忽略窗口,全量加载 FullContext bool // true = 忽略窗口,全量加载
Layered bool // true = 启用分层摘要加载(卷摘要+弧摘要+章摘要)
} }
// NewContextProfile 根据总章节数计算上下文策略。 // NewContextProfile 根据总章节数计算上下文策略。
@@ -74,7 +79,7 @@ func NewContextProfile(totalChapters int) ContextProfile {
case totalChapters <= 50: case totalChapters <= 50:
return ContextProfile{SummaryWindow: 5, TimelineWindow: 10} return ContextProfile{SummaryWindow: 5, TimelineWindow: 10}
default: default:
return ContextProfile{SummaryWindow: 3, TimelineWindow: 8} return ContextProfile{SummaryWindow: 3, TimelineWindow: 8, Layered: true}
} }
} }

View File

@@ -25,6 +25,49 @@ type Character struct {
Tier string `json:"tier,omitempty"` // core / important / secondary / decorative默认 important 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 世界观规则条目。 // WorldRule 世界观规则条目。
type WorldRule struct { type WorldRule struct {
Category string `json:"category"` // magic / technology / geography / society / other Category string `json:"category"` // magic / technology / geography / society / other

View File

@@ -35,6 +35,34 @@ type ChapterSummary struct {
KeyEvents []string `json:"key_events"` 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 工具的结构化返回值。 // CommitResult 是 commit_chapter 工具的结构化返回值。
// 宿主程序和 Coordinator 读取此信号做控制决策。 // 宿主程序和 Coordinator 读取此信号做控制决策。
type CommitResult struct { type CommitResult struct {
@@ -47,4 +75,9 @@ type CommitResult struct {
ReviewReason string `json:"review_reason,omitempty"` ReviewReason string `json:"review_reason,omitempty"`
HookType string `json:"hook_type,omitempty"` // 钩子类型crisis/mystery/desire/emotion/choice HookType string `json:"hook_type,omitempty"` // 钩子类型crisis/mystery/desire/emotion/choice
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线quest/fire/constellation 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"`
} }

View File

@@ -104,3 +104,44 @@
- 每章至少 3 个场景 - 每章至少 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=<JSON数组字符串>)
### 弧级规划模式
当任务中提到"细化下一弧的章节大纲"时:
1. 调用 novel_context 获取当前分层大纲和已完成弧摘要
2. 为指定弧生成详细的章节大纲(复用现有 OutlineEntry 格式)
3. 调用 save_foundation(type="outline") 保存更新后的完整扁平大纲

View File

@@ -96,3 +96,19 @@ architect 完成后,用 novel_context 确认设定已保存。
- 你的职责是调度和决策,不是创作 - 你的职责是调度和决策,不是创作
- 章节完成/全书终止的判断由宿主程序通过系统消息控制 - 章节完成/全书终止的判断由宿主程序通过系统消息控制
- 重写章节时writer 的流程与新写相同,旧文件会自动覆盖 - 重写章节时writer 的流程与新写相同,旧文件会自动覆盖
## 长篇模式(分层大纲)
当系统消息包含"弧结束"或"卷结束"信号时,执行以下工作流:
### 弧结束处理
收到 `[系统] 第 V 卷第 A 弧结束` 消息后,按消息中的步骤依次执行:
1. 调用 editor 进行弧级评审(任务中说明 scope=arc
2. 调用 editor 生成弧摘要和角色快照editor 会调用 save_arc_summary 工具)
3. 继续写下一弧的章节
### 卷结束处理
收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后:
1. 先完成弧结束处理(弧级评审 + 弧摘要)
2. 额外调用 editor 生成卷摘要editor 会调用 save_volume_summary 工具)
3. 继续写下一卷的章节

View File

@@ -73,3 +73,34 @@
- 不要输出空洞的表扬,只关注问题 - 不要输出空洞的表扬,只关注问题
- severity=error 的问题必须修复severity=warning 的可以后续处理 - severity=error 的问题必须修复severity=warning 的可以后续处理
- 如果没有发现问题verdict 应为 accept - 如果没有发现问题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卷内关键事件列表

View File

@@ -28,6 +28,45 @@ func (s *Store) LoadCharacters() ([]domain.Character, error) {
return chars, nil 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 { func renderCharacters(chars []domain.Character) string {
var b strings.Builder var b strings.Builder
b.WriteString("# 角色档案\n\n") b.WriteString("# 角色档案\n\n")

View File

@@ -56,6 +56,158 @@ func (s *Store) GetChapterOutline(chapter int) (*domain.OutlineEntry, error) {
return nil, fmt.Errorf("chapter %d not found in outline", chapter) 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 { func renderOutline(entries []domain.OutlineEntry) string {
var b strings.Builder var b strings.Builder
b.WriteString("# 大纲\n\n") b.WriteString("# 大纲\n\n")

View File

@@ -179,6 +179,33 @@ func (s *Store) ClearLastCommit() error {
return s.removeFile("meta/last_commit.json") 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 更新当前流程状态。 // SetFlow 更新当前流程状态。
func (s *Store) SetFlow(flow domain.FlowState) error { func (s *Store) SetFlow(flow domain.FlowState) error {
p, err := s.LoadProgress() p, err := s.LoadProgress()

View File

@@ -44,3 +44,93 @@ func (s *Store) LoadRecentSummaries(current, count int) ([]domain.ChapterSummary
func (s *Store) LoadAllSummaries(current int) ([]domain.ChapterSummary, error) { func (s *Store) LoadAllSummaries(current int) ([]domain.ChapterSummary, error) {
return s.LoadRecentSummaries(current, current) 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
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"github.com/voocel/agentcore/schema" "github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/domain"
@@ -139,7 +140,30 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
if progress != nil { if progress != nil {
completedCount = len(progress.CompletedChapters) 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. 计算场景数 // 7. 计算场景数
sceneCount := 0 sceneCount := 0
@@ -158,6 +182,10 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
ReviewReason: reviewReason, ReviewReason: reviewReason,
HookType: a.HookType, HookType: a.HookType,
DominantStrand: a.DominantStrand, DominantStrand: a.DominantStrand,
ArcEnd: arcEnd,
VolumeEnd: volumeEnd,
Volume: vol,
Arc: arc,
} }
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失) // 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)

View File

@@ -75,18 +75,31 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
if a.Chapter > 0 { if a.Chapter > 0 {
// 根据总章节数计算上下文策略 // 根据总章节数计算上下文策略
profile := domain.NewContextProfile(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) profile = domain.NewContextProfile(progress.TotalChapters)
} }
// Layered 以 Progress 的显式标志为准,而非章节数推断
if progress == nil || !progress.Layered {
profile.Layered = false
}
// 角色按 Tier 过滤core/important 始终返回secondary/decorative 按出场匹配 // 角色加载Layered 模式优先用快照,回退到原始设定
t.loadFilteredCharacters(result, a.Chapter) if profile.Layered {
t.loadLayeredCharacters(result, a.Chapter)
} else {
t.loadFilteredCharacters(result, a.Chapter)
}
// Writer/Editor 模式:加载章节相关上下文 // Writer/Editor 模式:加载章节相关上下文
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil {
result["current_chapter_outline"] = entry 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 { if summaries, err := t.store.LoadAllSummaries(a.Chapter); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries result["recent_summaries"] = summaries
} }
@@ -96,7 +109,7 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
} }
} }
// 状态数据按策略加载 // 时间线Layered 用窗口,其他按策略
if profile.FullContext { if profile.FullContext {
if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 {
result["timeline"] = timeline result["timeline"] = timeline
@@ -121,8 +134,33 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
result["relationship_state"] = relationships result["relationship_state"] = relationships
} }
// V2: 加载场景级恢复状态 + 节奏追踪 // Layered 模式:注入当前卷弧位置 + 弧目标/卷主题
if progress, err := t.store.LoadProgress(); err == nil && progress != nil { 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{ checkpoint := map[string]any{
"in_progress_chapter": progress.InProgressChapter, "in_progress_chapter": progress.InProgressChapter,
"completed_scenes": progress.CompletedScenes, "completed_scenes": progress.CompletedScenes,
@@ -135,18 +173,26 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
} }
result["checkpoint"] = checkpoint result["checkpoint"] = checkpoint
} }
// V2: 加载已有的章节规划(支持场景恢复跳过已完成场景) // 加载已有的章节规划(支持场景恢复跳过已完成场景)
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil { if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
result["chapter_plan"] = plan result["chapter_plan"] = plan
} }
// V3: 写作参考资料分阶段加载 // 写作参考资料分阶段加载
result["references"] = t.writerReferences(a.Chapter) result["references"] = t.writerReferences(a.Chapter)
} else { } else {
// Architect 模式:全量角色 + 模板 // Architect 模式:全量角色 + 模板
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
result["characters"] = chars 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() result["references"] = t.architectReferences()
} }
@@ -183,6 +229,54 @@ func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int)
result["characters"] = filtered 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 返回全量,后续章节裁剪掉不再需要的模板。 // writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。
func (t *ContextTool) writerReferences(chapter int) map[string]string { func (t *ContextTool) writerReferences(chapter int) map[string]string {
refs := map[string]string{} refs := map[string]string{}

86
tools/save_arc_summary.go Normal file
View File

@@ -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),
})
}

View File

@@ -27,8 +27,8 @@ func (t *SaveFoundationTool) Label() string { return "保存设定" }
func (t *SaveFoundationTool) Schema() map[string]any { func (t *SaveFoundationTool) Schema() map[string]any {
return schema.Object( return schema.Object(
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "characters", "world_rules")).Required(), schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
schema.Property("content", schema.String("内容。premise 为 Markdown 文本outline 和 characters 为 JSON 字符串")).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)) _ = t.store.SetTotalChapters(len(entries))
return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": 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": case "characters":
var chars []domain.Character var chars []domain.Character
if err := json.Unmarshal([]byte(a.Content), &chars); err != nil { 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)}) return json.Marshal(map[string]any{"saved": true, "type": "world_rules", "count": len(rules)})
default: 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)
} }
} }

View File

@@ -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,
})
}