feat: 支持长篇小说分层架构(卷/弧/章三级结构)
This commit is contained in:
185
README.md
Normal file
185
README.md
Normal 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
|
||||||
@@ -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{
|
||||||
|
|||||||
58
app/run.go
58
app/run.go
@@ -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_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 由大纲自动设定)
|
// 确定性判断 1:全书完成(TotalChapters 由大纲自动设定)
|
||||||
totalChapters := 0
|
totalChapters := 0
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
|
|||||||
@@ -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, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") 保存更新后的完整扁平大纲
|
||||||
|
|||||||
@@ -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. 继续写下一卷的章节
|
||||||
|
|||||||
@@ -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:卷内关键事件列表
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
152
state/outline.go
152
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)
|
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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
|
||||||
|
|||||||
@@ -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
86
tools/save_arc_summary.go
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
tools/save_volume_summary.go
Normal file
62
tools/save_volume_summary.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user