refactor: Agent驱动重构,整章写入替代场景拼接

This commit is contained in:
voocel
2026-03-15 14:14:46 +08:00
parent 25e219e934
commit 568ef0b1d1
27 changed files with 942 additions and 568 deletions

View File

@@ -6,9 +6,9 @@
- **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职 - **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职
- **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流 - **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流
- **场景级断点恢复** — 中断后从上次写到的场景精确续写,不丢失进度 - **章节级断点恢复** — 中断后从上次写到的章节续写,不丢失进度
- **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇 - **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇
- **维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子六个维度评审 - **维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子、审美品质七个维度评审,审美维度必须引用原文举证
- **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写 - **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写
- **双模式运行** — CLI 一行命令直接跑TUI 交互界面实时观察进度 - **双模式运行** — CLI 一行命令直接跑TUI 交互界面实时观察进度
- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换 - **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换
@@ -38,10 +38,10 @@
|--------|------|------| |--------|------|------|
| **Coordinator** | 调度全局,处理评审裁定和用户干预 | `subagent` `novel_context` `ask_user` | | **Coordinator** | 调度全局,处理评审裁定和用户干预 | `subagent` `novel_context` `ask_user` |
| **Architect** | 生成前提、大纲、角色档案、世界规则 | `novel_context` `save_foundation` | | **Architect** | 生成前提、大纲、角色档案、世界规则 | `novel_context` `save_foundation` |
| **Writer** | 逐场景写作 → 打磨 → 一致性检查 → 提交 | `novel_context` `plan_chapter` `write_scene` `polish_chapter` `check_consistency` `commit_chapter` | | **Writer** | 自主完成一章的构思、写作、自审和提交 | `novel_context` `read_chapter` `plan_chapter` `draft_chapter` `check_consistency` `commit_chapter` |
| **Editor** | 跨章节六维评审,弧/卷级摘要生成 | `novel_context` `save_review` `save_arc_summary` `save_volume_summary` | | **Editor** | 阅读原文,从结构和审美两个层面审阅 | `novel_context` `read_chapter` `save_review` `save_arc_summary` `save_volume_summary` |
### 写作流水线 ### 写作流
``` ```
用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审 用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审
@@ -49,14 +49,14 @@
└── 重写/打磨 ◄───┘ └── 重写/打磨 ◄───┘
``` ```
每章写作严格按序执行 Writer 自主决定每章的创作流程,建议路径
1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态) 1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态、风格锚点、声纹
2. `plan_chapter`规划 3-5 个场景 2. `read_chapter`回读前一章结尾和角色对话,找回语气和节奏
3. `write_scene` × N — 逐场景创作800-1500 字/场景) 3. `plan_chapter` — 构思本章目标、冲突、情绪弧线
4. `polish_chapter`合并打磨,去除 AI 腔 4. `draft_chapter`写入整章正文
5. `check_consistency`校验时间线、角色、世界规则 5. `read_chapter` + `check_consistency`自审:回读草稿,对照状态数据检查一致性
6. `commit_chapter` — 提交终稿,更新全局状态 6. `commit_chapter` — 提交终稿,更新全局状态(可选附带大纲偏离反馈)
### 长篇分层架构 ### 长篇分层架构
@@ -134,6 +134,20 @@ output/{novel_name}/
## 设计理念 ## 设计理念
### Agent 驱动原则
**工具负责 IOAgent 负责思考。不要用流水线绑住 Agent 的手脚。**
这是本项目所有设计决策的最高优先级准则。具体要求:
1. **工具只做数据读写** — 工具不包含业务逻辑判断,不强制执行顺序。工具是 Agent 的手和眼,不是 Agent 的脑。
2. **决策权归 Agent** — 规划、写作、打磨、自审都是 Agent 的思考行为不是工具调用节点。Agent 自主决定何时读、何时写、何时审。
3. **不用流水线约束创作** — 不强制"先规划→再按场景写→再打磨→再检查"的固定流程。Writer 可以先写完整章,回读后修改,自审后提交,顺序自定。
4. **给 Agent 感知能力** — Agent 能回读自己写的文字和前文原文,而非只看结构化摘要。风格保持靠阅读原文,不靠字段描述。
5. **Host 只兜底控制流** — 确定性状态机只负责"下一步该做什么"的流程判断,不干预创作内容。
任何新增功能或工具设计,都必须先问:**这是 IO 操作还是思考行为?** 如果是思考,交给 Agent如果是 IO才做成工具。
### 全自动闭环 ### 全自动闭环
一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策: 一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策:

View File

@@ -18,6 +18,7 @@ func BuildCoordinator(
) (*agentcore.Agent, *tools.AskUserTool) { ) (*agentcore.Agent, *tools.AskUserTool) {
// 共享工具 // 共享工具
contextTool := tools.NewContextTool(store, refs, cfg.Style) contextTool := tools.NewContextTool(store, refs, cfg.Style)
readChapter := tools.NewReadChapterTool(store)
askUser := tools.NewAskUserTool() askUser := tools.NewAskUserTool()
// Architect SubAgent 工具 // Architect SubAgent 工具
@@ -26,19 +27,20 @@ func BuildCoordinator(
tools.NewSaveFoundationTool(store), tools.NewSaveFoundationTool(store),
} }
// Writer SubAgent 工具V1: +polish_chapter +check_consistency // Writer SubAgent 工具:读写 + 规划 + 一致性检查 + 提交
writerTools := []agentcore.Tool{ writerTools := []agentcore.Tool{
contextTool, contextTool,
readChapter,
tools.NewPlanChapterTool(store), tools.NewPlanChapterTool(store),
tools.NewWriteSceneTool(store), tools.NewDraftChapterTool(store),
tools.NewPolishChapterTool(store),
tools.NewCheckConsistencyTool(store), tools.NewCheckConsistencyTool(store),
tools.NewCommitChapterTool(store), tools.NewCommitChapterTool(store),
} }
// Editor SubAgent 工具 // Editor SubAgent 工具:读原文 + 审阅 + 摘要
editorTools := []agentcore.Tool{ editorTools := []agentcore.Tool{
contextTool, contextTool,
readChapter,
tools.NewSaveReviewTool(store), tools.NewSaveReviewTool(store),
tools.NewSaveArcSummaryTool(store), tools.NewSaveArcSummaryTool(store),
tools.NewSaveVolumeSummaryTool(store), tools.NewSaveVolumeSummaryTool(store),
@@ -79,16 +81,16 @@ func BuildCoordinator(
writer := agentcore.SubAgentConfig{ writer := agentcore.SubAgentConfig{
Name: "writer", Name: "writer",
Description: "场景写作者:逐场景完成一章的创作,包含打磨和一致性检查", Description: "创作者:自主完成一章的构思、写作、自审和提交",
Model: model, Model: model,
SystemPrompt: writerPrompt, SystemPrompt: writerPrompt,
Tools: writerTools, Tools: writerTools,
MaxTurns: 25, MaxTurns: 20,
} }
editor := agentcore.SubAgentConfig{ editor := agentcore.SubAgentConfig{
Name: "editor", Name: "editor",
Description: "全局审阅者:发现跨章结构问题,输出审阅结果", Description: "审阅者:阅读原文,从结构和审美两个层面发现问题",
Model: model, Model: model,
SystemPrompt: prompts.Editor, SystemPrompt: prompts.Editor,
Tools: editorTools, Tools: editorTools,

View File

@@ -283,12 +283,11 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.InProgressChapter > 0 { if progress.InProgressChapter > 0 {
ch := progress.InProgressChapter ch := progress.InProgressChapter
scenes := len(progress.CompletedScenes)
return recoveryResult{ return recoveryResult{
PromptText: withGuidance(fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", "第 %d 章正在进行中,已有部分草稿。请调用 writer 继续完成该章(可用 read_chapter 读取已有草稿)。总共需要写 %d 章。",
ch, scenes, scenes+1, progress.TotalChapters)), ch, progress.TotalChapters)),
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes), Label: fmt.Sprintf("恢复:第 %d 章进行中", ch),
} }
} }
@@ -348,17 +347,29 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
log.Printf("[host] 清除 commit 信号失败: %v", err) log.Printf("[host] 清除 commit 信号失败: %v", err)
} }
log.Printf("[host] 章节提交信号:第 %d 章,%d 字%d 个场景", log.Printf("[host] 章节提交信号:第 %d 章,%d 字",
result.Chapter, result.WordCount, result.SceneCount) result.Chapter, result.WordCount)
if emit != nil { if emit != nil {
emit(UIEvent{ emit(UIEvent{
Time: time.Now(), Time: time.Now(),
Category: "SYSTEM", Category: "SYSTEM",
Summary: fmt.Sprintf("第 %d 章已提交:%d 字%d 个场景", result.Chapter, result.WordCount, result.SceneCount), Summary: fmt.Sprintf("第 %d 章已提交:%d 字", result.Chapter, result.WordCount),
Level: "success", Level: "success",
}) })
} }
// outline_feedback 处理Writer 反馈大纲偏离
if result.Feedback != nil && result.Feedback.Deviation != "" {
log.Printf("[host] outline_feedback: %s", result.Feedback.Deviation)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: "Writer 反馈大纲偏离: " + truncateLog(result.Feedback.Deviation, 60), Level: "info"})
}
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] Writer 在第 %d 章写作中发现大纲偏离。偏离:%s。建议%s。请评估是否需要调整后续大纲。",
result.Chapter, result.Feedback.Deviation, result.Feedback.Suggestion)))
}
// 确定性判断 0正在重写/打磨流程中 // 确定性判断 0正在重写/打磨流程中
progress, _ := store.LoadProgress() progress, _ := store.LoadProgress()
if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) { if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) {

View File

@@ -37,7 +37,6 @@ type UISnapshot struct {
CompletedCount int CompletedCount int
TotalWordCount int TotalWordCount int
InProgressChapter int InProgressChapter int
CompletedScenes int
PendingRewrites []int PendingRewrites []int
RewriteReason string RewriteReason string
PendingSteer string PendingSteer string
@@ -272,7 +271,6 @@ func (rt *Runtime) Snapshot() UISnapshot {
snap.CompletedCount = len(progress.CompletedChapters) snap.CompletedCount = len(progress.CompletedChapters)
snap.TotalWordCount = progress.TotalWordCount snap.TotalWordCount = progress.TotalWordCount
snap.InProgressChapter = progress.InProgressChapter snap.InProgressChapter = progress.InProgressChapter
snap.CompletedScenes = len(progress.CompletedScenes)
snap.PendingRewrites = progress.PendingRewrites snap.PendingRewrites = progress.PendingRewrites
snap.RewriteReason = progress.RewriteReason snap.RewriteReason = progress.RewriteReason
} }

View File

@@ -2,24 +2,9 @@ package domain
import ( import (
"fmt" "fmt"
"strings"
"unicode/utf8" "unicode/utf8"
) )
// MergeScenes 将多个场景草稿按顺序合并为完整章节正文。
// 返回合并后的正文和总字数(按 rune 计)。
func MergeScenes(scenes []SceneDraft) (string, int) {
var b strings.Builder
for i, s := range scenes {
if i > 0 {
b.WriteString("\n\n")
}
b.WriteString(s.Content)
}
content := b.String()
return content, utf8.RuneCountInString(content)
}
// ReviewInterval 全局审阅间隔(每 N 章触发一次)。 // ReviewInterval 全局审阅间隔(每 N 章触发一次)。
const ReviewInterval = 5 const ReviewInterval = 5
@@ -32,7 +17,6 @@ func ShouldReview(completedCount int) (bool, string) {
} }
// ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。 // ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。
// 弧结束时触发评审,替代固定间隔。
func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) { func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) {
if isVolumeEnd { if isVolumeEnd {
return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc) return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc)
@@ -42,3 +26,8 @@ func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string)
} }
return false, "" return false, ""
} }
// WordCount 按 rune 计算字数。
func WordCount(content string) int {
return utf8.RuneCountInString(content)
}

View File

@@ -1,30 +1,15 @@
package domain package domain
// ChapterPlan 章节规划,写入 drafts/{ch}.plan.json // ChapterPlan 章节写作构思Writer 自主生成
// 不再强制场景拆分Agent 自己决定如何组织内容。
type ChapterPlan struct { type ChapterPlan struct {
Chapter int `json:"chapter"` Chapter int `json:"chapter"`
Title string `json:"title"` Title string `json:"title"`
Goal string `json:"goal"` Goal string `json:"goal"`
Conflict string `json:"conflict"` Conflict string `json:"conflict"`
Scenes []ScenePlan `json:"scenes"`
Hook string `json:"hook"` Hook string `json:"hook"`
EmotionArc string `json:"emotion_arc,omitempty"` EmotionArc string `json:"emotion_arc,omitempty"`
} Notes string `json:"notes,omitempty"` // Agent 的自由备忘
// ScenePlan 场景规划。
type ScenePlan struct {
Index int `json:"index"`
Summary string `json:"summary"`
POV string `json:"pov,omitempty"`
Location string `json:"location,omitempty"`
}
// SceneDraft 场景草稿。
type SceneDraft struct {
Chapter int `json:"chapter"`
Scene int `json:"scene"`
Content string `json:"content"`
WordCount int `json:"word_count"`
} }
// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。 // ChapterSummary 章节摘要,供后续章节的上下文窗口使用。
@@ -57,10 +42,16 @@ type CharacterSnapshot struct {
Volume int `json:"volume"` Volume int `json:"volume"`
Arc int `json:"arc"` Arc int `json:"arc"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` // 存活/受伤/失踪... Status string `json:"status"`
Power string `json:"power,omitempty"` // 能力变化 Power string `json:"power,omitempty"`
Motivation string `json:"motivation"` // 当前动机 Motivation string `json:"motivation"`
Relations string `json:"relations,omitempty"` // 关键关系变化 Relations string `json:"relations,omitempty"`
}
// OutlineFeedback Writer 对大纲的反馈,提交章节时可选。
type OutlineFeedback struct {
Deviation string `json:"deviation"` // 偏离描述
Suggestion string `json:"suggestion"` // 调整建议
} }
// CommitResult 是 commit_chapter 工具的结构化返回值。 // CommitResult 是 commit_chapter 工具的结构化返回值。
@@ -69,13 +60,13 @@ type CommitResult struct {
Chapter int `json:"chapter"` Chapter int `json:"chapter"`
Committed bool `json:"committed"` Committed bool `json:"committed"`
WordCount int `json:"word_count"` WordCount int `json:"word_count"`
SceneCount int `json:"scene_count"`
NextChapter int `json:"next_chapter"` NextChapter int `json:"next_chapter"`
ReviewRequired bool `json:"review_required"` ReviewRequired bool `json:"review_required"`
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"`
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线quest/fire/constellation DominantStrand string `json:"dominant_strand,omitempty"`
// 长篇分层信号(仅 Layered 模式) Feedback *OutlineFeedback `json:"feedback,omitempty"`
// 长篇分层信号
ArcEnd bool `json:"arc_end,omitempty"` ArcEnd bool `json:"arc_end,omitempty"`
VolumeEnd bool `json:"volume_end,omitempty"` VolumeEnd bool `json:"volume_end,omitempty"`
Volume int `json:"volume,omitempty"` Volume int `json:"volume,omitempty"`

View File

@@ -42,7 +42,7 @@
- 中期转向:前期方法何时失效,故事如何换挡 - 中期转向:前期方法何时失效,故事如何换挡
- 终局命题:后期真正要回答的最终问题 - 终局命题:后期真正要回答的最终问题
调用 save_foundation(type="premise", scale="long", content=<Markdown文本>) 调用 save_foundation(type="premise", scale="long", content=<Markdown文本字符串>)
### 3. 生成 Layered Outline ### 3. 生成 Layered Outline
@@ -50,9 +50,11 @@
-Volume阶段主题、阶段升级、阶段代价 -Volume阶段主题、阶段升级、阶段代价
-Arc局部目标、局部阻力、阶段转折 -Arc局部目标、局部阻力、阶段转折
-Chapter章节标题、核心事件、钩子、场景 -Chapter章节标题、核心事件、钩子、要点
调用 save_foundation(type="layered_outline", scale="long", content=<JSON数组字符串>) 调用 save_foundation(type="layered_outline", scale="long", content=<JSON数组>)
注意:`content` 对于 layered_outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
要求: 要求:
@@ -80,7 +82,7 @@
- 重要配角不能只是阶段性工具人 - 重要配角不能只是阶段性工具人
- 关系线必须具备长期张力,而不是只服务某一章剧情 - 关系线必须具备长期张力,而不是只服务某一章剧情
调用 save_foundation(type="characters", scale="long", content=<JSON数组字符串>) 调用 save_foundation(type="characters", scale="long", content=<JSON数组>)
### 5. 生成 World Rules ### 5. 生成 World Rules
@@ -95,7 +97,7 @@
- 特别注意资源、代价、限制、秩序、势力边界 - 特别注意资源、代价、限制、秩序、势力边界
- 规则要能支撑中后期升级,而不是只服务前几章 - 规则要能支撑中后期升级,而不是只服务前几章
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组字符串>) 调用 save_foundation(type="world_rules", scale="long", content=<JSON数组>)
## 增量修改模式 ## 增量修改模式

View File

@@ -40,7 +40,7 @@
- 故事引擎:中篇靠什么持续推进 - 故事引擎:中篇靠什么持续推进
- 中段转折:故事在哪个阶段会发生结构变化 - 中段转折:故事在哪个阶段会发生结构变化
调用 save_foundation(type="premise", scale="mid", content=<Markdown文本>) 调用 save_foundation(type="premise", scale="mid", content=<Markdown文本字符串>)
### 3. 生成 Outline ### 3. 生成 Outline
@@ -51,7 +51,7 @@
- title - title
- core_event - core_event
- hook - hook
- scenes3-5 个场景 - scenes3-5 个要点,描述本章的关键段落和事件
要求: 要求:
@@ -60,7 +60,9 @@
- 中段必须出现一次改变后续推进方式的转折 - 中段必须出现一次改变后续推进方式的转折
- 支线不能游离,必须服务主线或人物关系变化 - 支线不能游离,必须服务主线或人物关系变化
调用 save_foundation(type="outline", scale="mid", content=<JSON数组字符串>) 调用 save_foundation(type="outline", scale="mid", content=<JSON数组>)
注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
### 4. 生成 Characters ### 4. 生成 Characters
@@ -78,7 +80,7 @@
- 角色弧线要跨越多个阶段,而不是一章完成 - 角色弧线要跨越多个阶段,而不是一章完成
- 配角要能反向影响主线 - 配角要能反向影响主线
调用 save_foundation(type="characters", scale="mid", content=<JSON数组字符串>) 调用 save_foundation(type="characters", scale="mid", content=<JSON数组>)
### 5. 生成 World Rules ### 5. 生成 World Rules
@@ -92,7 +94,7 @@
- 规则必须制造选择或代价 - 规则必须制造选择或代价
- 不能只是背景百科 - 不能只是背景百科
调用 save_foundation(type="world_rules", scale="mid", content=<JSON数组字符串>) 调用 save_foundation(type="world_rules", scale="mid", content=<JSON数组>)
## 增量修改模式 ## 增量修改模式

View File

@@ -38,7 +38,7 @@
- 差异化卖点(至少 2 条) - 差异化卖点(至少 2 条)
- 本作为什么适合短篇/单卷收束 - 本作为什么适合短篇/单卷收束
调用 save_foundation(type="premise", scale="short", content=<Markdown文本>) 调用 save_foundation(type="premise", scale="short", content=<Markdown文本字符串>)
### 3. 生成 Outline ### 3. 生成 Outline
@@ -49,7 +49,7 @@
- title - title
- core_event - core_event
- hook - hook
- scenes3-5 个场景 - scenes3-5 个要点,描述本章的关键段落和事件
要求: 要求:
@@ -59,7 +59,9 @@
- 世界规则只保留会直接影响剧情的部分 - 世界规则只保留会直接影响剧情的部分
- 结局必须回收核心承诺 - 结局必须回收核心承诺
调用 save_foundation(type="outline", scale="short", content=<JSON数组字符串>) 调用 save_foundation(type="outline", scale="short", content=<JSON数组>)
注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
### 4. 生成 Characters ### 4. 生成 Characters
@@ -76,7 +78,7 @@
- 角色功能必须清晰,避免冗余 - 角色功能必须清晰,避免冗余
- 主要角色弧线要在单卷内完成 - 主要角色弧线要在单卷内完成
调用 save_foundation(type="characters", scale="short", content=<JSON数组字符串>) 调用 save_foundation(type="characters", scale="short", content=<JSON数组>)
### 5. 生成 World Rules ### 5. 生成 World Rules
@@ -90,7 +92,7 @@
- 只保留必要规则,避免为短篇过度设计世界 - 只保留必要规则,避免为短篇过度设计世界
- 规则必须直接服务当前冲突 - 规则必须直接服务当前冲突
调用 save_foundation(type="world_rules", scale="short", content=<JSON数组字符串>) 调用 save_foundation(type="world_rules", scale="short", content=<JSON数组>)
## 增量修改模式 ## 增量修改模式

View File

@@ -129,12 +129,21 @@
如果当前作品已经采用 layered_outline不要在修改时退化成短篇式 outline 思路。 如果当前作品已经采用 layered_outline不要在修改时退化成短篇式 outline 思路。
### Writer 大纲反馈
收到 `[系统] Writer 在第 N 章写作中发现大纲偏离` 消息后:
1. 评估反馈是否合理(角色变得更有魅力?支线更有趣?大纲走向不对?)
2. 如果认为值得采纳,调用对应级别的规划师进行增量修改
3. 如果认为不需要调整,忽略并继续
4. 不要因为 Writer 的一次反馈就大幅推翻已有规划
## 恢复指示 ## 恢复指示
- 收到从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作 - 收到从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作
- 收到第 N 章正在进行中,已完成 M 个场景”的指示:调用 writer 从场景 M+1 继续该章写作 - 收到第 N 章正在进行中”的指示:调用 writer 继续完成该章writer 可用 read_chapter 读取已有草稿)
- 收到有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节 - 收到有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
- 收到上次审阅中断”的指示:重新调用 editor 进行全局审阅 - 收到上次审阅中断”的指示:重新调用 editor 进行全局审阅
## 长篇模式(分层大纲) ## 长篇模式(分层大纲)

View File

@@ -1,123 +1,125 @@
你是小说全局审阅者。你负责发现跨章和全局结构问题,不直接修改正文 你是小说全局审阅者。你负责阅读原文,从结构和审美两个层面发现问题
## 你的工具 ## 你的工具
- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系、状态变化) - **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系、状态变化)
- **read_chapter**: 读取章节原文(你必须读原文才能审阅,不能只看摘要)
- **save_review**: 保存审阅结果 - **save_review**: 保存审阅结果
- **save_arc_summary**: 保存弧摘要和角色快照(长篇模式)
- **save_volume_summary**: 保存卷摘要(长篇模式)
## 工作流程 ## 工作流程
### 1. 获取上下文 ### 1. 获取上下文
调用 novel_context(chapter=最新章节号),获取全部状态数据。 调用 novel_context(chapter=最新章节号),获取全部状态数据。
### 2. 六维结构化审 ### 2. 阅读原文
**必须**调用 read_chapter 读取要审阅的章节原文。不能只看摘要就下结论。
对于全局审阅,至少读最近 3-5 章的原文。
### 3. 七维结构化审阅
逐维度检查,每个维度必须给出**评分0-100**和结论pass/warning/fail 逐维度检查,每个维度必须给出**评分0-100**和结论pass/warning/fail
#### 维度一设定一致性consistency #### 维度一设定一致性consistency
- 事件发生顺序是否与时间线矛盾 - 事件顺序是否与时间线矛盾
- 时间跨度是否自洽
- 世界规则边界是否被违反 - 世界规则边界是否被违反
- 角色属性(能力、外貌、身份)是否前后矛盾 - 角色属性是否前后矛盾
- 如果有 recent_state_changes,检查角色状态描述是否与记录一致 - 角色状态描述是否与 state_changes 记录一致
- 注意角色别名/称号,同一人不同称呼不要误判为不同角色 - 注意角色别名,同一人不同称呼不要误判
#### 维度二人设一致性character #### 维度二人设一致性character
- 角色行为是否符合性格设定和弧线 - 角色行为是否符合性格设定和弧线
- 对话风格是否与角色身份匹配 - 对话风格是否与角色身份匹配
- 角色动机是否合理连贯 - 角色动机是否合理连贯
- 角色成长是否有合理铺垫
#### 维度三节奏平衡pacing #### 维度三节奏平衡pacing
- 是否连续多章同一类型(纯打斗、纯对话、纯描写) - 是否连续多章同一类型
- 主线是否持续推进,有无原地踏步 - 主线是否持续推进
- 情感节奏是否有张有弛 - strand_history / hook_history 分布是否失衡
- 如果有 strand_history 数据,检查 quest/fire/constellation 三线分布是否失衡
#### 维度四叙事连贯continuity #### 维度四叙事连贯continuity
- 场景之间过渡是否自然 - 场景过渡是否自然
- 因果逻辑是否通顺 - 因果逻辑是否通顺
- 信息传递是否一致角色A不应知道只有角色B知道的事 - 信息传递是否一致
#### 维度五伏笔健康foreshadow #### 维度五伏笔健康foreshadow
- 是否有超过 5 章未推进的伏笔(遗忘风险) - 是否有超过 5 章未推进的伏笔
- 新伏笔是否有回收方向 - 新伏笔是否有回收方向
- 已回收伏笔的解决是否令人满意 - 已回收伏笔的解决是否令人满意
#### 维度六钩子质量hook #### 维度六钩子质量hook
- 章末钩子是否有足够吸引力 - 章末钩子是否有足够吸引力
- 如果有 hook_history 数据,检查是否连续使用同一类型钩子 - 是否连续使用同一类型钩子
- 钩子是否与主线推进方向一致 - 钩子是否与主线推进方向一致
### 3. 输出审阅 #### 维度七审美品质aesthetic— 新增
审阅原文的文学品质,**必须引用原文**来证明问题:
- **画面感**:描写是否有具象画面,还是流于抽象概述?
引用缺乏画面感的段落,给出改进方向
- **对话区分度**:不同角色说话是否能区分?
引用说话方式雷同的对话,指出问题
- **AI 痕迹**:是否有"不禁""竟然""仿佛"等滥用词、排比三连、四字成语堆砌?
引用具体句子
- **情感打动力**:是否有让读者心跳加速或产生共鸣的段落?
如果整章平淡如水,指出最该加强的位置
### 4. 输出审阅
调用 save_review给出 调用 save_review给出
- **dimensions**个维度的评分(每个维度一条) - **dimensions**个维度的评分
- dimension维度名consistency/character/pacing/continuity/foreshadow/hook - dimension维度名consistency/character/pacing/continuity/foreshadow/hook/aesthetic
- score0-100 分 - score0-100 分
- verdictpass≥80/ warning60-79/ fail<60 - verdictpass≥80/ warning60-79/ fail<60
- comment该维度的简要结论 - comment简要结论aesthetic 维度必须引用原文
- **issues**发现的具体问题列表每个问题包含 - **issues**发现的具体问题列表
- type问题维度consistency/character/pacing/continuity/foreshadow/hook - type问题维度
- severity问题严重程度 - severitycritical / error / warning
- description具体问题描述 - description具体问题描述aesthetic 类问题必须引用原文
- suggestion修改建议 - suggestion修改建议
- **verdict**审阅结论accept/polish/rewrite - **verdict**审阅结论accept/polish/rewrite
- **summary**审阅总结200字以内按维度概括 - **summary**审阅总结200字以内
- **affected_chapters**需要重写或打磨的章节号列表verdict polish/rewrite 时必填 - **affected_chapters**需要修改的章节号列表
### severity 分级标准 ### severity 分级标准
| 级别 | 定义 | 示例 | | 级别 | 定义 | 示例 |
|------|------|------| |------|------|------|
| **critical** | 逻辑硬伤必须修复 | 角色已死再次出场违反世界规则核心边界时间线严重错乱 | | **critical** | 逻辑硬伤必须修复 | 角色已死再次出场违反世界规则核心边界 |
| **error** | 明显矛盾应当修复 | 角色行为与人设严重不符伏笔遗忘超过10章节奏严重失衡 | | **error** | 明显矛盾或品质问题 | 角色行为严重不符人设整章 AI 味浓重 |
| **warning** | 轻微瑕疵可后续处理 | 细节不够精确节奏略显平淡钩子强度不足 | | **warning** | 轻微瑕疵 | 细节不够精确个别句子可打磨 |
### 判定标准 ### 判定标准
- 存在任何 critical 问题 verdict 必须为 rewrite - 存在 critical verdict 必须为 rewrite
- critical 存在 error verdict 至少为 polish - critical error verdict 至少为 polish
- 只有 warning 或无问题 verdict accept - 只有 warning 或无问题 accept
## 弧级评审模式(长篇)
当任务提到"弧级评审"
- scope 设为 "arc"
- 额外关注弧内起承转合弧目标达成与前续弧衔接
- 完成审阅后调用 save_arc_summary 保存弧摘要和角色快照
### save_arc_summary 参数
- volume/arc卷号弧号
- title弧标题
- summary弧摘要500字以内
- key_events弧内关键事件
- character_snapshots主要角色当前状态快照
## 卷级评审模式(长篇)
当任务提到"卷摘要"调用 save_volume_summary
## 注意事项 ## 注意事项
- 不要自己修改正文 - 不要自己修改正文
- 不要输出空洞的表扬只关注问题 - 不要输出空洞的表扬只关注问题
- critical 问题绝不放过这是底线 - critical 绝不放过
- warning 级问题如果是有意为之的过渡铺垫可以不报 - **审美维度的问题必须引用原文**不接受空泛的"文笔还需提升"
- 如果没有发现问题verdict 应为 accept所有维度 score 80
## 弧级评审模式(长篇)
当任务中提到"弧级评审"
- 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

@@ -1,85 +1,79 @@
你是小说场景写作者。你负责逐场景地完成一章的创作 你是小说作者。你负责自主完成一章的构思、写作、自审和提交
## 你的工具 ## 你的工具
- **novel_context**: 获取当前章节的创作上下文 - **novel_context**: 获取当前章节的创作上下文(设定、前情、角色、伏笔、时间线)
- **plan_chapter**: 创建章节写作规划 - **read_chapter**: 回读任意章节原文、草稿,或提取角色对话片段
- **write_scene**: 写入单个场景 - **plan_chapter**: 保存你的章节构思
- **polish_chapter**: 保存打磨后的完整章节正文 - **draft_chapter**: 写入章节正文(整章或续写)
- **check_consistency**: 检查章节与全局状态的一致性 - **check_consistency**: 加载状态数据,供你对照检查一致性
- **commit_chapter**: 提交完成的章节 - **commit_chapter**: 提交完成的章节
## 写作流水线 ## 你的自主权
严格按以下顺序执行,不可跳步 你可以按任何顺序使用工具,只要最终提交一章高质量的正文。以下是建议流程,但不是强制流程
### 1. 获取上下文 ### 建议流程
调用 novel_context(chapter=N) 获取:
- 故事前提、大纲、角色档案
- 前几章摘要
- 时间线、伏笔账本、人物关系(用于保持一致性)
- 写作参考资料
### 2. 规划章节 1. **读上下文** — 调用 novel_context(chapter=N) 了解前情、大纲、角色、伏笔
调用 plan_chapter基于大纲拆分为 3-5 个场景,明确每个场景的目标、视角和地点。 2. **回读前文** — 调用 read_chapter 读前一章结尾(找回语气和节奏),读关键角色的对话片段(保持声音一致)
3. **构思** — 在脑中(或 plan_chapter梳理本章的目标、冲突、情绪弧线、钩子
4. **写作** — 调用 draft_chapter 写入整章正文
5. **自审** — 回读自己的草稿read_chapter source=draft对照 check_consistency 的状态数据,检查一致性和质量
6. **修改** — 如果不满意,再次调用 draft_chapter(mode=write) 覆盖
7. **提交** — 调用 commit_chapter
### 3. 逐场景写作 你可以跳过任何步骤,也可以重复任何步骤。关键是:**写出好的正文**。
对每个场景依次调用 write_scene。
**场景写作要求** ## 写作标准
- 每个场景 800-1500 字
- 第一个场景的前 20% 必须出现冲突或悬念 ### 开头致命
- 以具体的动作、对话或感官描写开场,不要用抽象描述 - 前 20% 必须出现冲突或悬念
- 对话要体现人物性格,避免说教式对白 - 以动作、对话或感官描写开场,不用抽象描述
- 绝对避免:天气开场、日常流程、回顾上章、缓慢铺垫
### 对话真实
- 每句对话必须有目的:推动情节、揭示人物、制造冲突
- 不同角色说话方式不同(用 read_chapter 提取的对话片段找回角色声音)
- 有潜台词和动作穿插,不说教
### 描写具象
- 用五感描写替代抽象概述
- 用身体反应替代情绪标签(不写"他很愤怒",写"他握紧拳头,指节发白"
- 用细节和动作推动情节,不用概述和总结 - 用细节和动作推动情节,不用概述和总结
- 场景之间自然过渡
### 4. 打磨章节 ### 去 AI 味
将所有场景合并,进行整体打磨,然后调用 polish_chapter 保存: - 不用"不禁"、"竟然"、"仿佛"、"此外"、"然而"等滥用词
- **去 AI 味**:不用"不禁"、"竟然"、"仿佛"等滥用词,不用排比三连,控制形容词密度 - 不用排比三连、四字成语堆砌
- **对话自然化**:体现人物性格差异,加入潜台词和动作穿插 - 句式多样化,长短交错
- **细节具象化**:用五感描写替代抽象概述
- **节奏调整**:关键转折放慢,过渡段落紧凑
### 5. 一致性检查 ### 节奏
调用 check_consistency(chapter=N),检查是否有矛盾: - 关键转折放慢,过渡段落紧凑
- 如果发现 error 级别问题,回到第 3 步修正相关场景,重新打磨 - 章内有紧张-缓解-新紧张的呼吸感
- 如果只有 warning记录后继续
### 6. 提交章节
调用 commit_chapter提供
- summary: 本章内容摘要200字以内
- characters: 本章出场角色名列表(使用正式名,不用别名)
- key_events: 本章关键事件列表
- timeline_events: 本章发生的时间线事件
- foreshadow_updates: 伏笔操作plant 埋设 / advance 推进 / resolve 回收)
- relationship_changes: 人物关系变化
- state_changes: 角色/实体状态变化(修为提升、位置转移、状态变化等),每条包含 entity/field/old_value/new_value/reason
## 重写模式
当任务中包含"重写"或"打磨"指令时:
- 流水线与新写完全相同context → plan → write_scene × N → polish → consistency → commit
- 旧的 plan、scene、polished 文件会被自然覆盖
- commit_chapter 会自动修正字数统计
- 重点关注审阅意见中指出的问题,确保修正到位
## 场景恢复模式
当任务中提到"从场景 M 继续"时:
- 调用 novel_context 获取上下文
- 检查已有的 chapter plan 和已完成场景
- 跳过已完成的场景,从指定场景编号开始写作
- 后续流程不变:完成所有场景 → polish → consistency → commit
## 注意事项
- 严格场景级写作,一次只写一个场景
- 不要整章一起写然后拆分
- 章末必须有悬念钩子 - 章末必须有悬念钩子
- 保持与前几章的连贯性
## 字数要求
- 每章 3000-5000 字
- 字数不够时用具体细节扩展,不用水话填充 - 字数不够时用具体细节扩展,不用水话填充
- 注意时间线连贯和伏笔管理
- 角色在正文中可以使用别名/称号/绰号,但 commit 时 characters 列表使用正式名 ## 重写/打磨模式
- 如果上下文中有 recent_state_changes注意本章对角色状态的描述必须与记录一致如修为、位置、伤势等 当任务中包含"重写"或"打磨"指令时:
- 本章中角色发生任何状态变化(修为提升、位置转移、受伤/恢复、获得/失去物品等),必须在 commit 的 state_changes 中上报 - 用 read_chapter 读取原文和审阅意见
- 重点修正审阅指出的问题
- 整章重写后 draft_chapter(mode=write) 覆盖
- commit_chapter 会自动修正字数统计
## 大纲反馈
如果写作过程中发现某个角色比预期更有魅力、某条支线比主线更有趣、或大纲的走向不太对,你可以在 commit_chapter 的 feedback 字段中反馈。系统会将你的建议转达给 Coordinator 评估。
## 提交要求
commit_chapter 时提供:
- summary: 本章内容摘要200字以内
- characters: 本章出场角色名列表(使用正式名)
- key_events: 本章关键事件列表
- timeline_events: 时间线事件
- foreshadow_updates: 伏笔操作plant/advance/resolve
- relationship_changes: 人物关系变化
- state_changes: 角色/实体状态变化
- hook_type / dominant_strand: 钩子类型和主导叙事线
- feedback: 对大纲的反馈(可选)

View File

@@ -3,21 +3,19 @@ package state
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath" "regexp"
"sort"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/domain"
) )
// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。 // SaveChapterPlan 保存章节构思到 drafts/{ch}.plan.json。
func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error { func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error {
return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan) return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan)
} }
// LoadChapterPlan 读取章节规划 // LoadChapterPlan 读取章节构思
func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) { func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
var plan domain.ChapterPlan var plan domain.ChapterPlan
if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil { if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil {
@@ -29,47 +27,30 @@ func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
return &plan, nil return &plan, nil
} }
// SaveSceneDraft 保存场景草稿到 drafts/{ch}.scene-{n}.md。 // SaveDraft 保存整章草稿到 drafts/{ch}.draft.md。
func (s *Store) SaveSceneDraft(draft domain.SceneDraft) error { func (s *Store) SaveDraft(chapter int, content string) error {
rel := fmt.Sprintf("drafts/%02d.scene-%d.md", draft.Chapter, draft.Scene) return s.writeMarkdown(fmt.Sprintf("drafts/%02d.draft.md", chapter), content)
return s.writeMarkdown(rel, draft.Content)
} }
// LoadSceneDrafts 加载指定章节的所有场景草稿,按场景编号排序 // AppendDraft 追加内容到现有草稿(续写模式)
func (s *Store) LoadSceneDrafts(chapter int) ([]domain.SceneDraft, error) { func (s *Store) AppendDraft(chapter int, content string) error {
pattern := filepath.Join(s.dir, "drafts", fmt.Sprintf("%02d.scene-*.md", chapter)) rel := fmt.Sprintf("drafts/%02d.draft.md", chapter)
matches, err := filepath.Glob(pattern) existing, err := s.readFile(rel)
if err != nil { if err != nil && !os.IsNotExist(err) {
return nil, err return err
} }
sort.Strings(matches) var merged string
if len(existing) > 0 {
var drafts []domain.SceneDraft merged = string(existing) + "\n\n" + content
for _, m := range matches { } else {
base := filepath.Base(m) merged = content
sceneNum := parseSceneNum(base)
content, err := os.ReadFile(m)
if err != nil {
return nil, fmt.Errorf("read scene draft %s: %w", base, err)
} }
drafts = append(drafts, domain.SceneDraft{ return s.writeMarkdown(rel, merged)
Chapter: chapter,
Scene: sceneNum,
Content: string(content),
WordCount: utf8.RuneCountInString(string(content)),
})
}
return drafts, nil
} }
// SavePolished 保存打磨后的章节正文到 drafts/{ch}.polished.md // LoadDraft 读取整章草稿
func (s *Store) SavePolished(chapter int, content string) error { func (s *Store) LoadDraft(chapter int) (string, error) {
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.polished.md", chapter), content) data, err := s.readFile(fmt.Sprintf("drafts/%02d.draft.md", chapter))
}
// LoadPolished 读取打磨后的章节正文。不存在时返回空字符串。
func (s *Store) LoadPolished(chapter int) (string, error) {
data, err := s.readFile(fmt.Sprintf("drafts/%02d.polished.md", chapter))
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", nil return "", nil
} }
@@ -79,21 +60,16 @@ func (s *Store) LoadPolished(chapter int) (string, error) {
return string(data), nil return string(data), nil
} }
// LoadChapterContent 加载章节正文:优先 polished否则 merge scenes // LoadChapterContent 加载章节草稿正文及字数
func (s *Store) LoadChapterContent(chapter int) (string, int, error) { func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
polished, err := s.LoadPolished(chapter) draft, err := s.LoadDraft(chapter)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
if polished != "" { if draft != "" {
return polished, utf8.RuneCountInString(polished), nil return draft, utf8.RuneCountInString(draft), nil
} }
drafts, err := s.LoadSceneDrafts(chapter) return "", 0, nil
if err != nil {
return "", 0, err
}
content, wc := domain.MergeScenes(drafts)
return content, wc, nil
} }
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。 // SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。
@@ -101,14 +77,120 @@ func (s *Store) SaveFinalChapter(chapter int, content string) error {
return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content) return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content)
} }
// parseSceneNum 从文件名如 "01.scene-2.md" 提取场景编号 // LoadChapterText 读取已提交的终稿原文
func parseSceneNum(filename string) int { func (s *Store) LoadChapterText(chapter int) (string, error) {
// 格式:{ch}.scene-{n}.md data, err := s.readFile(fmt.Sprintf("chapters/%02d.md", chapter))
parts := strings.Split(filename, "scene-") if os.IsNotExist(err) {
if len(parts) < 2 { return "", nil
return 0
} }
numStr := strings.TrimSuffix(parts[1], ".md") if err != nil {
n, _ := strconv.Atoi(numStr) return "", err
return n }
return string(data), nil
}
// LoadChapterRange 读取指定范围的终稿原文片段(每章截取前 maxRunes 个字符)。
func (s *Store) LoadChapterRange(from, to, maxRunes int) (map[int]string, error) {
result := make(map[int]string)
for ch := from; ch <= to; ch++ {
text, err := s.LoadChapterText(ch)
if err != nil {
return nil, err
}
if text == "" {
continue
}
if maxRunes > 0 {
runes := []rune(text)
if len(runes) > maxRunes {
text = string(runes[:maxRunes]) + "..."
}
}
result[ch] = text
}
return result, nil
}
// dialogueRe 匹配中文引号对话。
var dialogueRe = regexp.MustCompile(`"[^"]*"`)
// ExtractDialogue 从已提交章节中提取指定角色的对话片段。
// 通过检查对话所在段落是否包含角色名/别名来关联。
func (s *Store) ExtractDialogue(characterName string, aliases []string, maxSamples int) []string {
if maxSamples <= 0 {
maxSamples = 5
}
names := append([]string{characterName}, aliases...)
var samples []string
// 从最近的章节开始向前搜索
for ch := 99; ch >= 1 && len(samples) < maxSamples; ch-- {
text, err := s.LoadChapterText(ch)
if err != nil || text == "" {
continue
}
paragraphs := strings.Split(text, "\n")
for _, para := range paragraphs {
if len(samples) >= maxSamples {
break
}
// 段落中要包含角色名
found := false
for _, name := range names {
if strings.Contains(para, name) {
found = true
break
}
}
if !found {
continue
}
// 提取该段落中的对话
matches := dialogueRe.FindAllString(para, -1)
for _, m := range matches {
if len(samples) >= maxSamples {
break
}
if utf8.RuneCountInString(m) > 5 { // 过滤太短的
samples = append(samples, characterName+": "+m)
}
}
}
}
return samples
}
// ExtractStyleAnchors 从已提交章节中提取代表性段落作为风格锚点。
// 选取描写密度高(非对话、非短句)的段落。
func (s *Store) ExtractStyleAnchors(maxAnchors int) []string {
if maxAnchors <= 0 {
maxAnchors = 5
}
var anchors []string
// 从第 1 章开始,均匀采样
for ch := 1; ch <= 99 && len(anchors) < maxAnchors; ch++ {
text, err := s.LoadChapterText(ch)
if err != nil || text == "" {
continue
}
paragraphs := strings.Split(text, "\n\n")
for _, para := range paragraphs {
if len(anchors) >= maxAnchors {
break
}
para = strings.TrimSpace(para)
runeCount := utf8.RuneCountInString(para)
// 选取 50-300 字的非对话段落
if runeCount < 50 || runeCount > 300 {
continue
}
// 跳过纯对话段落
if strings.Count(para, "\u201c") > 2 {
continue
}
anchors = append(anchors, para)
}
}
return anchors
} }

View File

@@ -157,30 +157,7 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
return &r, nil return &r, nil
} }
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint // ClearInProgress 清除进度中间状态(章节提交后调用)
// 切换到不同章节时自动清空旧的 CompletedScenes。
func (s *Store) MarkSceneComplete(chapter, scene int) error {
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return fmt.Errorf("progress not initialized, call InitProgress first")
}
// 章节切换:清空旧场景列表
if p.InProgressChapter != chapter {
p.CompletedScenes = nil
}
p.InProgressChapter = chapter
if !slices.Contains(p.CompletedScenes, scene) {
p.CompletedScenes = append(p.CompletedScenes, scene)
}
return s.saveProgressUnlocked(p)
})
}
// ClearInProgress 清除场景级进度状态(章节提交后调用)。
func (s *Store) ClearInProgress() error { func (s *Store) ClearInProgress() error {
return s.withWriteLock(func() error { return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked() p, err := s.loadProgressUnlocked()

View File

@@ -6,12 +6,11 @@ import (
"fmt" "fmt"
"github.com/voocel/agentcore/schema" "github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/state"
) )
// CheckConsistencyTool 对照状态文件检查章节一致性 // CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理 // 纯 IO 工具:只负责加载数据,不注入指令
type CheckConsistencyTool struct { type CheckConsistencyTool struct {
store *state.Store store *state.Store
} }
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
func (t *CheckConsistencyTool) Name() string { return "check_consistency" } func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
func (t *CheckConsistencyTool) Description() string { func (t *CheckConsistencyTool) Description() string {
return "检查章节一致性。返回章节内容全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项" return "加载章节内容全部状态数据(时间线、伏笔、关系、世界规则、角色状态),供你自行对照检查一致性"
} }
func (t *CheckConsistencyTool) Label() string { return "一致性检查" } func (t *CheckConsistencyTool) Label() string { return "一致性检查" }
@@ -45,7 +44,7 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
result := map[string]any{"chapter": a.Chapter} result := map[string]any{"chapter": a.Chapter}
// 加载章节内容polished 优先) // 章节内容
content, wordCount, err := t.store.LoadChapterContent(a.Chapter) content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil { if err != nil {
return nil, fmt.Errorf("load chapter content: %w", err) return nil, fmt.Errorf("load chapter content: %w", err)
@@ -56,22 +55,18 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
result["content"] = content result["content"] = content
result["word_count"] = wordCount result["word_count"] = wordCount
// 加载全部状态数据供 LLM 对照 // 状态数据全部加载Agent 自行决定怎么用)
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 { if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
result["timeline"] = timeline result["timeline"] = timeline
} }
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 { if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow result["foreshadow_ledger"] = foreshadow
if active := filterActive(foreshadow); len(active) > 0 {
result["unresolved_foreshadow"] = active
}
} }
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 { if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
result["relationships"] = relationships result["relationships"] = relationships
} }
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 { if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
result["characters"] = chars result["characters"] = chars
// 构建别名映射表,供 LLM 识别角色的不同称呼
aliasMap := make(map[string]string) aliasMap := make(map[string]string)
for _, c := range chars { for _, c := range chars {
for _, alias := range c.Aliases { for _, alias := range c.Aliases {
@@ -82,65 +77,15 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
result["alias_map"] = aliasMap result["alias_map"] = aliasMap
} }
} }
// 加载最近状态变化,供对照当前章节的状态描述
if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 { if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 {
result["recent_state_changes"] = changes result["recent_state_changes"] = changes
} }
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 { if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
result["world_rules"] = rules result["world_rules"] = rules
// 提取边界清单,方便 LLM 逐条对照
var boundaries []string
for _, r := range rules {
if r.Boundary != "" {
boundaries = append(boundaries, fmt.Sprintf("[%s] %s", r.Category, r.Boundary))
} }
}
if len(boundaries) > 0 {
result["world_rules_boundaries"] = boundaries
}
}
// 加载前两章摘要
if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 { if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 {
result["recent_summaries"] = summaries result["recent_summaries"] = summaries
} }
result["instruction"] = `请逐项对照以上状态数据检查本章内容,返回 JSON 数组格式的冲突项:
[
{
"type": "timeline|foreshadow|relationship|character|world_rules|state",
"severity": "critical|error|warning",
"description": "具体冲突描述",
"suggestion": "建议修正范围和方式"
}
]
severity 分级:
- critical严重逻辑硬伤必须修复如角色已死但再次出场、违反世界规则核心边界
- error明显矛盾应当修复如时间线冲突、角色行为与人设严重不符
- warning轻微瑕疵可后续处理如细节不够精确、可改进但不影响阅读
检查清单:
1. 时间线:本章事件时间是否与已有 timeline 矛盾
2. 伏笔unresolved_foreshadow 中是否有本章应推进但遗漏的
3. 人物关系:角色互动是否与 relationships 当前状态矛盾
4. 角色一致性:行为是否符合 characters 中的性格和弧线
5. 世界规则:逐条检查 world_rules_boundaries 中的边界约束,本章内容是否违反任何一条
6. 别名一致性:如果有 alias_map检查同一角色的不同称呼是否指向正确的人
7. 状态连续性:如果有 recent_state_changes检查本章对角色状态的描述是否与最近的状态变化记录一致
如果没有发现冲突,返回空数组 []。不要返回其他格式。`
return json.Marshal(result) return json.Marshal(result)
} }
func filterActive(entries []domain.ForeshadowEntry) []domain.ForeshadowEntry {
var active []domain.ForeshadowEntry
for _, e := range entries {
if e.Status != "resolved" {
active = append(active, e)
}
}
return active
}

View File

@@ -12,7 +12,6 @@ import (
) )
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。 // CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
type CommitChapterTool struct { type CommitChapterTool struct {
store *state.Store store *state.Store
} }
@@ -23,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
func (t *CommitChapterTool) Name() string { return "commit_chapter" } func (t *CommitChapterTool) Name() string { return "commit_chapter" }
func (t *CommitChapterTool) Description() string { func (t *CommitChapterTool) Description() string {
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号" return "提交章节终稿。加载草稿正文,保存为终稿,同时更新时间线、伏笔、关系、角色状态。返回结构化信号"
} }
func (t *CommitChapterTool) Label() string { return "提交章节" } func (t *CommitChapterTool) Label() string { return "提交章节" }
@@ -34,7 +33,7 @@ func (t *CommitChapterTool) Schema() map[string]any {
schema.Property("characters", schema.Array("涉及角色", schema.String(""))), schema.Property("characters", schema.Array("涉及角色", schema.String(""))),
) )
foreshadowSchema := schema.Object( foreshadowSchema := schema.Object(
schema.Property("id", schema.String("伏笔 ID(新埋设时自定义,推进/回收时使用已有 ID")).Required(), schema.Property("id", schema.String("伏笔 ID")).Required(),
schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(), schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(),
schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")), schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")),
) )
@@ -45,11 +44,15 @@ func (t *CommitChapterTool) Schema() map[string]any {
) )
stateChangeSchema := schema.Object( stateChangeSchema := schema.Object(
schema.Property("entity", schema.String("角色名或实体名")).Required(), schema.Property("entity", schema.String("角色名或实体名")).Required(),
schema.Property("field", schema.String("变化属性realm/location/status/power/relation 等")).Required(), schema.Property("field", schema.String("变化属性")).Required(),
schema.Property("old_value", schema.String("变化前的值(首次出现可空)")), schema.Property("old_value", schema.String("变化前的值")),
schema.Property("new_value", schema.String("变化后的值")).Required(), schema.Property("new_value", schema.String("变化后的值")).Required(),
schema.Property("reason", schema.String("变化原因")), schema.Property("reason", schema.String("变化原因")),
) )
feedbackSchema := schema.Object(
schema.Property("deviation", schema.String("偏离大纲的描述")).Required(),
schema.Property("suggestion", schema.String("对后续大纲的调整建议")).Required(),
)
return schema.Object( return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(), schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("summary", schema.String("本章内容摘要200字以内")).Required(), schema.Property("summary", schema.String("本章内容摘要200字以内")).Required(),
@@ -58,9 +61,10 @@ func (t *CommitChapterTool) Schema() map[string]any {
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)), schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)), schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)), schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
schema.Property("state_changes", schema.Array("角色/实体状态变化(修为提升、位置转移、状态变化等)", stateChangeSchema)), schema.Property("state_changes", schema.Array("角色/实体状态变化", stateChangeSchema)),
schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")), schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")), schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")),
schema.Property("feedback", feedbackSchema),
) )
} }
@@ -76,6 +80,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
StateChanges []domain.StateChange `json:"state_changes"` StateChanges []domain.StateChange `json:"state_changes"`
HookType string `json:"hook_type"` HookType string `json:"hook_type"`
DominantStrand string `json:"dominant_strand"` DominantStrand string `json:"dominant_strand"`
Feedback *domain.OutlineFeedback `json:"feedback"`
} }
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err) return nil, fmt.Errorf("invalid args: %w", err)
@@ -87,7 +92,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
return nil, err return nil, err
} }
// 1. 加载章节正文polished 优先,否则 merge scenes // 1. 加载章节正文
content, wordCount, err := t.store.LoadChapterContent(a.Chapter) content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil { if err != nil {
return nil, fmt.Errorf("load chapter content: %w", err) return nil, fmt.Errorf("load chapter content: %w", err)
@@ -157,7 +162,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
if progress != nil { if progress != nil {
completedCount = len(progress.CompletedChapters) completedCount = len(progress.CompletedChapters)
} }
// 6b. 长篇模式:弧级边界检测(替代固定间隔评审)+ 更新卷弧位置
// 6b. 长篇模式:弧级边界检测
var arcEnd, volumeEnd bool var arcEnd, volumeEnd bool
var vol, arc int var vol, arc int
if progress != nil && progress.Layered { if progress != nil && progress.Layered {
@@ -169,7 +175,6 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
volumeEnd = boundary.IsVolumeEnd volumeEnd = boundary.IsVolumeEnd
vol = boundary.Volume vol = boundary.Volume
arc = boundary.Arc arc = boundary.Arc
// 每次提交时更新卷弧位置,确保 novel_context 的 position 始终正确
_ = t.store.UpdateVolumeArc(vol, arc) _ = t.store.UpdateVolumeArc(vol, arc)
} }
} }
@@ -182,35 +187,29 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
reviewRequired, reviewReason = domain.ShouldReview(completedCount) reviewRequired, reviewReason = domain.ShouldReview(completedCount)
} }
// 7. 计算场景数 // 7. 构造结构化信号
sceneCount := 0
if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil {
sceneCount = len(scenes)
}
// 8. 构造结构化信号
result := domain.CommitResult{ result := domain.CommitResult{
Chapter: a.Chapter, Chapter: a.Chapter,
Committed: true, Committed: true,
WordCount: wordCount, WordCount: wordCount,
SceneCount: sceneCount,
NextChapter: a.Chapter + 1, NextChapter: a.Chapter + 1,
ReviewRequired: reviewRequired, ReviewRequired: reviewRequired,
ReviewReason: reviewReason, ReviewReason: reviewReason,
HookType: a.HookType, HookType: a.HookType,
DominantStrand: a.DominantStrand, DominantStrand: a.DominantStrand,
Feedback: a.Feedback,
ArcEnd: arcEnd, ArcEnd: arcEnd,
VolumeEnd: volumeEnd, VolumeEnd: volumeEnd,
Volume: vol, Volume: vol,
Arc: arc, Arc: arc,
} }
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失) // 8. 写入信号文件
if err := t.store.SaveLastCommit(result); err != nil { if err := t.store.SaveLastCommit(result); err != nil {
return nil, fmt.Errorf("save commit signal: %w", err) return nil, fmt.Errorf("save commit signal: %w", err)
} }
// 10. 清除场景级进度(章节已提交) // 9. 清除进度中间状态
if err := t.store.ClearInProgress(); err != nil { if err := t.store.ClearInProgress(); err != nil {
return nil, fmt.Errorf("clear in-progress: %w", err) return nil, fmt.Errorf("clear in-progress: %w", err)
} }

View File

@@ -25,8 +25,8 @@ func TestCommitChapterRejectsNonPendingRewrite(t *testing.T) {
if err := store.SetFlow(domain.FlowRewriting); err != nil { if err := store.SetFlow(domain.FlowRewriting); err != nil {
t.Fatalf("SetFlow: %v", err) t.Fatalf("SetFlow: %v", err)
} }
if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil { if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
t.Fatalf("SavePolished: %v", err) t.Fatalf("SaveDraft: %v", err)
} }
tool := NewCommitChapterTool(store) tool := NewCommitChapterTool(store)
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
if err := store.SetFlow(domain.FlowRewriting); err != nil { if err := store.SetFlow(domain.FlowRewriting); err != nil {
t.Fatalf("SetFlow: %v", err) t.Fatalf("SetFlow: %v", err)
} }
if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil { if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
t.Fatalf("SavePolished: %v", err) t.Fatalf("SaveDraft: %v", err)
} }
tool := NewCommitChapterTool(store) tool := NewCommitChapterTool(store)

80
tools/draft_chapter.go Normal file
View File

@@ -0,0 +1,80 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"unicode/utf8"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/state"
)
// DraftChapterTool 写入整章草稿,替代旧的 write_scene + polish_chapter 流水线。
// Agent 自主决定一次写完还是分批续写。
type DraftChapterTool struct {
store *state.Store
}
func NewDraftChapterTool(store *state.Store) *DraftChapterTool {
return &DraftChapterTool{store: store}
}
func (t *DraftChapterTool) Name() string { return "draft_chapter" }
func (t *DraftChapterTool) Description() string {
return "写入章节正文。mode=write 覆盖写入整章mode=append 追加到现有草稿(续写/修改)"
}
func (t *DraftChapterTool) Label() string { return "写入章节" }
func (t *DraftChapterTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("content", schema.String("章节正文")).Required(),
schema.Property("mode", schema.Enum("写入模式", "write", "append")),
)
}
func (t *DraftChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
Content string `json:"content"`
Mode string `json:"mode"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
if a.Chapter <= 0 {
return nil, fmt.Errorf("chapter must be > 0")
}
if a.Content == "" {
return nil, fmt.Errorf("content must not be empty")
}
switch a.Mode {
case "append":
if err := t.store.AppendDraft(a.Chapter, a.Content); err != nil {
return nil, fmt.Errorf("append draft: %w", err)
}
// 读取合并后的完整内容计算字数
full, err := t.store.LoadDraft(a.Chapter)
if err != nil {
return nil, fmt.Errorf("load draft after append: %w", err)
}
return json.Marshal(map[string]any{
"written": true,
"chapter": a.Chapter,
"mode": "append",
"word_count": utf8.RuneCountInString(full),
})
default: // write
if err := t.store.SaveDraft(a.Chapter, a.Content); err != nil {
return nil, fmt.Errorf("save draft: %w", err)
}
return json.Marshal(map[string]any{
"written": true,
"chapter": a.Chapter,
"mode": "write",
"word_count": utf8.RuneCountInString(a.Content),
})
}
}

View File

@@ -211,11 +211,10 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
result["position"] = pos result["position"] = pos
} }
// 加载场景级恢复状态 + 节奏追踪 // 加载进度状态和节奏追踪
if progress != nil { 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,
} }
if len(progress.StrandHistory) > 0 { if len(progress.StrandHistory) > 0 {
checkpoint["strand_history"] = progress.StrandHistory checkpoint["strand_history"] = progress.StrandHistory
@@ -225,13 +224,43 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
} }
result["checkpoint"] = checkpoint result["checkpoint"] = checkpoint
} }
// 加载已有的章节规划(支持场景恢复跳过已完成场景) // 加载已有的章节构思
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
} else { } else {
warn("chapter_plan", err) warn("chapter_plan", err)
} }
// 风格锚点:从前文提取代表性段落
if anchors := t.store.ExtractStyleAnchors(3); len(anchors) > 0 {
result["style_anchors"] = anchors
}
// 角色声纹:提取出场角色的对话原文片段
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil && entry != nil {
var voiceSamples []map[string]any
chars, _ := t.store.LoadCharacters()
for _, c := range chars {
// 只为 core/important 角色提取声纹
if c.Tier == "secondary" || c.Tier == "decorative" {
continue
}
samples := t.store.ExtractDialogue(c.Name, c.Aliases, 3)
if len(samples) > 0 {
voiceSamples = append(voiceSamples, map[string]any{
"character": c.Name,
"samples": samples,
})
}
if len(voiceSamples) >= 5 {
break
}
}
if len(voiceSamples) > 0 {
result["voice_samples"] = voiceSamples
}
}
// 写作参考资料分阶段加载 // 写作参考资料分阶段加载
result["references"] = t.writerReferences(a.Chapter) result["references"] = t.writerReferences(a.Chapter)
} else { } else {

View File

@@ -10,7 +10,7 @@ import (
"github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/state"
) )
// PlanChapterTool 生成章节规划 // PlanChapterTool 保存章节构思Agent 自主决定规划粒度
type PlanChapterTool struct { type PlanChapterTool struct {
store *state.Store store *state.Store
} }
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
func (t *PlanChapterTool) Name() string { return "plan_chapter" } func (t *PlanChapterTool) Name() string { return "plan_chapter" }
func (t *PlanChapterTool) Description() string { func (t *PlanChapterTool) Description() string {
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用" return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
} }
func (t *PlanChapterTool) Label() string { return "规划章节" } func (t *PlanChapterTool) Label() string { return "规划章节" }
func (t *PlanChapterTool) Schema() map[string]any { func (t *PlanChapterTool) Schema() map[string]any {
sceneSchema := schema.Object(
schema.Property("index", schema.Int("场景编号,从 1 开始")).Required(),
schema.Property("summary", schema.String("场景概要")).Required(),
schema.Property("pov", schema.String("视角人物")),
schema.Property("location", schema.String("场景地点")),
)
return schema.Object( return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(), schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("title", schema.String("章节标题")).Required(), schema.Property("title", schema.String("章节标题")).Required(),
schema.Property("goal", schema.String("本章目标")).Required(), schema.Property("goal", schema.String("本章目标")).Required(),
schema.Property("conflict", schema.String("核心冲突")).Required(), schema.Property("conflict", schema.String("核心冲突")).Required(),
schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(),
schema.Property("hook", schema.String("章末钩子")).Required(), schema.Property("hook", schema.String("章末钩子")).Required(),
schema.Property("emotion_arc", schema.String("情绪曲线")), schema.Property("emotion_arc", schema.String("情绪曲线")),
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
) )
} }
@@ -51,9 +45,6 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
if plan.Chapter <= 0 { if plan.Chapter <= 0 {
return nil, fmt.Errorf("chapter must be > 0") return nil, fmt.Errorf("chapter must be > 0")
} }
if len(plan.Scenes) == 0 {
return nil, fmt.Errorf("scenes must not be empty")
}
if err := t.store.SaveChapterPlan(plan); err != nil { if err := t.store.SaveChapterPlan(plan); err != nil {
return nil, fmt.Errorf("save chapter plan: %w", err) return nil, fmt.Errorf("save chapter plan: %w", err)
@@ -62,6 +53,5 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
return json.Marshal(map[string]any{ return json.Marshal(map[string]any{
"planned": true, "planned": true,
"chapter": plan.Chapter, "chapter": plan.Chapter,
"scene_count": len(plan.Scenes),
}) })
} }

View File

@@ -1,59 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"unicode/utf8"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/state"
)
// PolishChapterTool 保存打磨后的章节正文,替换原始场景拼接。
type PolishChapterTool struct {
store *state.Store
}
func NewPolishChapterTool(store *state.Store) *PolishChapterTool {
return &PolishChapterTool{store: store}
}
func (t *PolishChapterTool) Name() string { return "polish_chapter" }
func (t *PolishChapterTool) Description() string {
return "保存打磨后的章节正文。在 write_scene 全部完成后、commit_chapter 之前调用。提交时会优先使用打磨版本"
}
func (t *PolishChapterTool) Label() string { return "打磨章节" }
func (t *PolishChapterTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("content", schema.String("打磨后的完整章节正文")).Required(),
)
}
func (t *PolishChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
if a.Chapter <= 0 {
return nil, fmt.Errorf("chapter must be > 0")
}
if a.Content == "" {
return nil, fmt.Errorf("content must not be empty")
}
if err := t.store.SavePolished(a.Chapter, a.Content); err != nil {
return nil, fmt.Errorf("save polished: %w", err)
}
return json.Marshal(map[string]any{
"polished": true,
"chapter": a.Chapter,
"word_count": utf8.RuneCountInString(a.Content),
})
}

116
tools/read_chapter.go Normal file
View File

@@ -0,0 +1,116 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/state"
)
// ReadChapterTool 读取章节原文,让 Agent 能回读自己和前文的文字。
type ReadChapterTool struct {
store *state.Store
}
func NewReadChapterTool(store *state.Store) *ReadChapterTool {
return &ReadChapterTool{store: store}
}
func (t *ReadChapterTool) Name() string { return "read_chapter" }
func (t *ReadChapterTool) Description() string { return "读取章节原文。可读终稿、草稿,或提取角色对话片段" }
func (t *ReadChapterTool) Label() string { return "读取章节" }
func (t *ReadChapterTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号(读单章时必填)")),
schema.Property("from", schema.Int("起始章节号(读范围时使用)")),
schema.Property("to", schema.Int("结束章节号(读范围时使用)")),
schema.Property("source", schema.Enum("来源", "final", "draft")).Required(),
schema.Property("character", schema.String("角色名(提取对话片段时使用)")),
schema.Property("max_runes", schema.Int("每章最大字符数(范围读取时截取,默认 2000")),
)
}
func (t *ReadChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
From int `json:"from"`
To int `json:"to"`
Source string `json:"source"`
Character string `json:"character"`
MaxRunes int `json:"max_runes"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
// 模式 1提取角色对话
if a.Character != "" {
chars, _ := t.store.LoadCharacters()
var aliases []string
for _, c := range chars {
if c.Name == a.Character {
aliases = c.Aliases
break
}
}
samples := t.store.ExtractDialogue(a.Character, aliases, 8)
return json.Marshal(map[string]any{
"character": a.Character,
"samples": samples,
})
}
// 模式 2范围读取
if a.From > 0 && a.To > 0 {
maxRunes := a.MaxRunes
if maxRunes <= 0 {
maxRunes = 2000
}
texts, err := t.store.LoadChapterRange(a.From, a.To, maxRunes)
if err != nil {
return nil, fmt.Errorf("load chapter range: %w", err)
}
return json.Marshal(map[string]any{
"chapters": texts,
"from": a.From,
"to": a.To,
})
}
// 模式 3单章读取
if a.Chapter <= 0 {
return nil, fmt.Errorf("chapter is required")
}
var content string
var err error
switch a.Source {
case "draft":
content, err = t.store.LoadDraft(a.Chapter)
default: // final
content, err = t.store.LoadChapterText(a.Chapter)
if (err == nil && content == "") {
// 回退到草稿
content, err = t.store.LoadDraft(a.Chapter)
}
}
if err != nil {
return nil, fmt.Errorf("read chapter %d: %w", a.Chapter, err)
}
if content == "" {
return json.Marshal(map[string]any{
"chapter": a.Chapter,
"content": "",
"note": "章节不存在",
})
}
return json.Marshal(map[string]any{
"chapter": a.Chapter,
"content": content,
"word_count": len([]rune(content)),
})
}

215
tools/read_draft_test.go Normal file
View File

@@ -0,0 +1,215 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
func TestReadChapterFinal(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveFinalChapter(1, "第一章的终稿正文。"); err != nil {
t.Fatalf("SaveFinalChapter: %v", err)
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"chapter": 1, "source": "final"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Chapter int `json:"chapter"`
Content string `json:"content"`
WordCount int `json:"word_count"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Content == "" {
t.Fatal("expected non-empty content")
}
if payload.WordCount == 0 {
t.Fatal("expected non-zero word count")
}
}
func TestReadChapterDraft(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveDraft(3, "第三章的草稿内容。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"chapter": 3, "source": "draft"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Content string `json:"content"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Content == "" {
t.Fatal("expected draft content")
}
}
func TestReadChapterDialogue(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveCharacters([]domain.Character{
{Name: "张三", Aliases: []string{"老张"}},
}); err != nil {
t.Fatalf("SaveCharacters: %v", err)
}
if err := store.SaveFinalChapter(1, "张三站起身来。\u201c我不同意这个方案\u201d张三冷冷地说。"); err != nil {
t.Fatalf("SaveFinalChapter: %v", err)
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"source": "final", "character": "张三"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Character string `json:"character"`
Samples []string `json:"samples"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Character != "张三" {
t.Fatalf("expected character 张三, got %s", payload.Character)
}
}
func TestReadChapterRange(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
for i := 1; i <= 3; i++ {
if err := store.SaveFinalChapter(i, "这是一段正文内容。"); err != nil {
t.Fatalf("SaveFinalChapter(%d): %v", i, err)
}
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"from": 1, "to": 3, "source": "final"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Chapters map[string]string `json:"chapters"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if len(payload.Chapters) != 3 {
t.Fatalf("expected 3 chapters, got %d", len(payload.Chapters))
}
}
func TestDraftChapterWrite(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
tool := NewDraftChapterTool(store)
args, _ := json.Marshal(map[string]any{
"chapter": 1,
"content": "这是整章的正文内容,一次写完。",
"mode": "write",
})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Written bool `json:"written"`
WordCount int `json:"word_count"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if !payload.Written {
t.Fatal("expected written=true")
}
if payload.WordCount == 0 {
t.Fatal("expected non-zero word count")
}
// 验证能读回来
content, err := store.LoadDraft(1)
if err != nil {
t.Fatalf("LoadDraft: %v", err)
}
if content == "" {
t.Fatal("expected non-empty draft")
}
}
func TestDraftChapterAppend(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveDraft(2, "前半部分。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewDraftChapterTool(store)
args, _ := json.Marshal(map[string]any{
"chapter": 2,
"content": "后半部分。",
"mode": "append",
})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Mode string `json:"mode"`
WordCount int `json:"word_count"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Mode != "append" {
t.Fatalf("expected mode=append, got %s", payload.Mode)
}
content, _ := store.LoadDraft(2)
if content == "" || content == "前半部分。" {
t.Fatal("expected appended content")
}
}

View File

@@ -21,14 +21,16 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
func (t *SaveFoundationTool) Name() string { return "save_foundation" } func (t *SaveFoundationTool) Name() string { return "save_foundation" }
func (t *SaveFoundationTool) Description() string { func (t *SaveFoundationTool) Description() string {
return "保存小说基础设定。type=premise 时 content 为 Markdowntype=outline 时 content 为 JSON 数组type=characters 时 content 为 JSON 数组type=world_rules 时 content JSON 数组。scale 可选,用于记录 short/mid/long 规划级别" return "保存小说基础设定。参数固定为 {type, content, scale?}。type 可选 premise / outline / layered_outline / characters / world_rules。premise 时 content 必须是 Markdown 字符串outline、layered_outline、characters、world_rules 时 content 优先直接传 JSON 数组或对象,不要再手动包一层转义字符串;工具也兼容传入 JSON 字符串。scale 可选,仅允许 short / mid / long。"
} }
func (t *SaveFoundationTool) Label() string { return "保存设定" } 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", "layered_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/layered_outline/characters/world_rules 为 JSON 字符串")).Required(), schema.Property("content", map[string]any{
"description": "内容。premise 传 Markdown 字符串outline/layered_outline/characters/world_rules 直接传 JSON 数组或对象即可,也兼容传 JSON 字符串。不要把数组再次手动转义成难读的字符串。",
}).Required(),
schema.Property("scale", schema.Enum("规划级别", "short", "mid", "long")), schema.Property("scale", schema.Enum("规划级别", "short", "mid", "long")),
) )
} }
@@ -36,12 +38,16 @@ func (t *SaveFoundationTool) Schema() map[string]any {
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct { var a struct {
Type string `json:"type"` Type string `json:"type"`
Content string `json:"content"` Content json.RawMessage `json:"content"`
Scale string `json:"scale"` Scale string `json:"scale"`
} }
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err) return nil, fmt.Errorf("invalid args: %w", err)
} }
content, err := normalizeFoundationContent(a.Content)
if err != nil {
return nil, err
}
if a.Scale != "" { if a.Scale != "" {
switch domain.PlanningTier(a.Scale) { switch domain.PlanningTier(a.Scale) {
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong: case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
@@ -55,7 +61,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
switch a.Type { switch a.Type {
case "premise": case "premise":
if err := t.store.SavePremise(a.Content); err != nil { if err := t.store.SavePremise(content); err != nil {
return nil, fmt.Errorf("save premise: %w", err) return nil, fmt.Errorf("save premise: %w", err)
} }
_ = t.store.UpdatePhase(domain.PhasePremise) _ = t.store.UpdatePhase(domain.PhasePremise)
@@ -63,7 +69,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
case "outline": case "outline":
var entries []domain.OutlineEntry var entries []domain.OutlineEntry
if err := json.Unmarshal([]byte(a.Content), &entries); err != nil { if err := json.Unmarshal([]byte(content), &entries); err != nil {
return nil, fmt.Errorf("parse outline JSON: %w", err) return nil, fmt.Errorf("parse outline JSON: %w", err)
} }
if err := t.store.SaveOutline(entries); err != nil { if err := t.store.SaveOutline(entries); err != nil {
@@ -81,7 +87,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
case "layered_outline": case "layered_outline":
var volumes []domain.VolumeOutline var volumes []domain.VolumeOutline
if err := json.Unmarshal([]byte(a.Content), &volumes); err != nil { if err := json.Unmarshal([]byte(content), &volumes); err != nil {
return nil, fmt.Errorf("parse layered_outline JSON: %w", err) return nil, fmt.Errorf("parse layered_outline JSON: %w", err)
} }
if err := t.store.SaveLayeredOutline(volumes); err != nil { if err := t.store.SaveLayeredOutline(volumes); err != nil {
@@ -107,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
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(content), &chars); err != nil {
return nil, fmt.Errorf("parse characters JSON: %w", err) return nil, fmt.Errorf("parse characters JSON: %w", err)
} }
if err := t.store.SaveCharacters(chars); err != nil { if err := t.store.SaveCharacters(chars); err != nil {
@@ -117,7 +123,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
case "world_rules": case "world_rules":
var rules []domain.WorldRule var rules []domain.WorldRule
if err := json.Unmarshal([]byte(a.Content), &rules); err != nil { if err := json.Unmarshal([]byte(content), &rules); err != nil {
return nil, fmt.Errorf("parse world_rules JSON: %w", err) return nil, fmt.Errorf("parse world_rules JSON: %w", err)
} }
if err := t.store.SaveWorldRules(rules); err != nil { if err := t.store.SaveWorldRules(rules); err != nil {
@@ -129,3 +135,19 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type) return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type)
} }
} }
func normalizeFoundationContent(raw json.RawMessage) (string, error) {
if len(raw) == 0 {
return "", fmt.Errorf("content is required")
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text, nil
}
if !json.Valid(raw) {
return "", fmt.Errorf("invalid content: expected Markdown string or valid JSON value")
}
return string(raw), nil
}

View File

@@ -111,3 +111,41 @@ func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(t *testing.T) {
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier) t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier)
} }
} }
func TestSaveFoundationAcceptsDirectJSONArrayContent(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
tool := NewSaveFoundationTool(store)
args, err := json.Marshal(map[string]any{
"type": "outline",
"content": []map[string]any{
{
"chapter": 1,
"title": "第一章",
"core_event": "主角登场",
"hook": "继续",
"scenes": []string{"场景一", "场景二"},
},
},
"scale": "short",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if _, err := tool.Execute(context.Background(), args); err != nil {
t.Fatalf("Execute: %v", err)
}
outline, err := store.LoadOutline()
if err != nil {
t.Fatalf("LoadOutline: %v", err)
}
if len(outline) != 1 || outline[0].Title != "第一章" {
t.Fatalf("unexpected outline: %+v", outline)
}
}

View File

@@ -1,76 +0,0 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"unicode/utf8"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
// WriteSceneTool 写入单个场景草稿。
type WriteSceneTool struct {
store *state.Store
}
func NewWriteSceneTool(store *state.Store) *WriteSceneTool {
return &WriteSceneTool{store: store}
}
func (t *WriteSceneTool) Name() string { return "write_scene" }
func (t *WriteSceneTool) Description() string {
return "写入单个场景草稿。严格按场景级写作,每次只写一个场景。必须先调用 plan_chapter"
}
func (t *WriteSceneTool) Label() string { return "写入场景" }
func (t *WriteSceneTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("scene", schema.Int("场景编号,从 1 开始")).Required(),
schema.Property("content", schema.String("场景正文")).Required(),
)
}
func (t *WriteSceneTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
Scene int `json:"scene"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
if a.Chapter <= 0 || a.Scene <= 0 {
return nil, fmt.Errorf("chapter and scene must be > 0")
}
if a.Content == "" {
return nil, fmt.Errorf("content must not be empty")
}
wordCount := utf8.RuneCountInString(a.Content)
draft := domain.SceneDraft{
Chapter: a.Chapter,
Scene: a.Scene,
Content: a.Content,
WordCount: wordCount,
}
if err := t.store.SaveSceneDraft(draft); err != nil {
return nil, fmt.Errorf("save scene draft: %w", err)
}
// 场景级 checkpoint
if err := t.store.MarkSceneComplete(a.Chapter, a.Scene); err != nil {
return nil, fmt.Errorf("mark scene complete: %w", err)
}
return json.Marshal(map[string]any{
"written": true,
"chapter": a.Chapter,
"scene": a.Scene,
"word_count": wordCount,
})
}

View File

@@ -84,7 +84,7 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string {
b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount))) b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
if snap.InProgressChapter > 0 { if snap.InProgressChapter > 0 {
b.WriteString(renderField("Writing", fmt.Sprintf("第%d章 场景%d", snap.InProgressChapter, snap.CompletedScenes))) b.WriteString(renderField("Writing", fmt.Sprintf("第%d章", snap.InProgressChapter)))
} }
if len(snap.PendingRewrites) > 0 { if len(snap.PendingRewrites) > 0 {