refactor: Agent驱动重构,整章写入替代场景拼接
This commit is contained in:
38
README.md
38
README.md
@@ -6,9 +6,9 @@
|
||||
|
||||
- **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职
|
||||
- **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流
|
||||
- **场景级断点恢复** — 中断后从上次写到的场景精确续写,不丢失进度
|
||||
- **章节级断点恢复** — 中断后从上次写到的章节续写,不丢失进度
|
||||
- **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇
|
||||
- **六维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子六个维度评审
|
||||
- **七维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子、审美品质七个维度评审,审美维度必须引用原文举证
|
||||
- **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写
|
||||
- **双模式运行** — CLI 一行命令直接跑,TUI 交互界面实时观察进度
|
||||
- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 随意切换
|
||||
@@ -38,10 +38,10 @@
|
||||
|--------|------|------|
|
||||
| **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` |
|
||||
| **Writer** | 自主完成一章的构思、写作、自审和提交 | `novel_context` `read_chapter` `plan_chapter` `draft_chapter` `check_consistency` `commit_chapter` |
|
||||
| **Editor** | 阅读原文,从结构和审美两个层面审阅 | `novel_context` `read_chapter` `save_review` `save_arc_summary` `save_volume_summary` |
|
||||
|
||||
### 写作流水线
|
||||
### 写作流程
|
||||
|
||||
```
|
||||
用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审
|
||||
@@ -49,14 +49,14 @@
|
||||
└── 重写/打磨 ◄───┘
|
||||
```
|
||||
|
||||
每章写作严格按序执行:
|
||||
Writer 自主决定每章的创作流程,建议路径:
|
||||
|
||||
1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态)
|
||||
2. `plan_chapter` — 规划 3-5 个场景
|
||||
3. `write_scene` × N — 逐场景创作(800-1500 字/场景)
|
||||
4. `polish_chapter` — 合并打磨,去除 AI 腔
|
||||
5. `check_consistency` — 校验时间线、角色、世界规则
|
||||
6. `commit_chapter` — 提交终稿,更新全局状态
|
||||
1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态、风格锚点、声纹)
|
||||
2. `read_chapter` — 回读前一章结尾和角色对话,找回语气和节奏
|
||||
3. `plan_chapter` — 构思本章目标、冲突、情绪弧线
|
||||
4. `draft_chapter` — 写入整章正文
|
||||
5. `read_chapter` + `check_consistency` — 自审:回读草稿,对照状态数据检查一致性
|
||||
6. `commit_chapter` — 提交终稿,更新全局状态(可选附带大纲偏离反馈)
|
||||
|
||||
### 长篇分层架构
|
||||
|
||||
@@ -134,6 +134,20 @@ output/{novel_name}/
|
||||
|
||||
## 设计理念
|
||||
|
||||
### Agent 驱动原则
|
||||
|
||||
**工具负责 IO,Agent 负责思考。不要用流水线绑住 Agent 的手脚。**
|
||||
|
||||
这是本项目所有设计决策的最高优先级准则。具体要求:
|
||||
|
||||
1. **工具只做数据读写** — 工具不包含业务逻辑判断,不强制执行顺序。工具是 Agent 的手和眼,不是 Agent 的脑。
|
||||
2. **决策权归 Agent** — 规划、写作、打磨、自审都是 Agent 的思考行为,不是工具调用节点。Agent 自主决定何时读、何时写、何时审。
|
||||
3. **不用流水线约束创作** — 不强制"先规划→再按场景写→再打磨→再检查"的固定流程。Writer 可以先写完整章,回读后修改,自审后提交,顺序自定。
|
||||
4. **给 Agent 感知能力** — Agent 能回读自己写的文字和前文原文,而非只看结构化摘要。风格保持靠阅读原文,不靠字段描述。
|
||||
5. **Host 只兜底控制流** — 确定性状态机只负责"下一步该做什么"的流程判断,不干预创作内容。
|
||||
|
||||
任何新增功能或工具设计,都必须先问:**这是 IO 操作还是思考行为?** 如果是思考,交给 Agent;如果是 IO,才做成工具。
|
||||
|
||||
### 全自动闭环
|
||||
|
||||
一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策:
|
||||
|
||||
@@ -18,6 +18,7 @@ func BuildCoordinator(
|
||||
) (*agentcore.Agent, *tools.AskUserTool) {
|
||||
// 共享工具
|
||||
contextTool := tools.NewContextTool(store, refs, cfg.Style)
|
||||
readChapter := tools.NewReadChapterTool(store)
|
||||
askUser := tools.NewAskUserTool()
|
||||
|
||||
// Architect SubAgent 工具
|
||||
@@ -26,19 +27,20 @@ func BuildCoordinator(
|
||||
tools.NewSaveFoundationTool(store),
|
||||
}
|
||||
|
||||
// Writer SubAgent 工具(V1: +polish_chapter +check_consistency)
|
||||
// Writer SubAgent 工具:读写 + 规划 + 一致性检查 + 提交
|
||||
writerTools := []agentcore.Tool{
|
||||
contextTool,
|
||||
readChapter,
|
||||
tools.NewPlanChapterTool(store),
|
||||
tools.NewWriteSceneTool(store),
|
||||
tools.NewPolishChapterTool(store),
|
||||
tools.NewDraftChapterTool(store),
|
||||
tools.NewCheckConsistencyTool(store),
|
||||
tools.NewCommitChapterTool(store),
|
||||
}
|
||||
|
||||
// Editor SubAgent 工具
|
||||
// Editor SubAgent 工具:读原文 + 审阅 + 摘要
|
||||
editorTools := []agentcore.Tool{
|
||||
contextTool,
|
||||
readChapter,
|
||||
tools.NewSaveReviewTool(store),
|
||||
tools.NewSaveArcSummaryTool(store),
|
||||
tools.NewSaveVolumeSummaryTool(store),
|
||||
@@ -79,16 +81,16 @@ func BuildCoordinator(
|
||||
|
||||
writer := agentcore.SubAgentConfig{
|
||||
Name: "writer",
|
||||
Description: "场景写作者:逐场景完成一章的创作,包含打磨和一致性检查",
|
||||
Description: "创作者:自主完成一章的构思、写作、自审和提交",
|
||||
Model: model,
|
||||
SystemPrompt: writerPrompt,
|
||||
Tools: writerTools,
|
||||
MaxTurns: 25,
|
||||
MaxTurns: 20,
|
||||
}
|
||||
|
||||
editor := agentcore.SubAgentConfig{
|
||||
Name: "editor",
|
||||
Description: "全局审阅者:发现跨章结构问题,输出审阅结果",
|
||||
Description: "审阅者:阅读原文,从结构和审美两个层面发现问题",
|
||||
Model: model,
|
||||
SystemPrompt: prompts.Editor,
|
||||
Tools: editorTools,
|
||||
|
||||
25
app/run.go
25
app/run.go
@@ -283,12 +283,11 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
|
||||
|
||||
if progress.InProgressChapter > 0 {
|
||||
ch := progress.InProgressChapter
|
||||
scenes := len(progress.CompletedScenes)
|
||||
return recoveryResult{
|
||||
PromptText: withGuidance(fmt.Sprintf(
|
||||
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。",
|
||||
ch, scenes, scenes+1, progress.TotalChapters)),
|
||||
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes),
|
||||
"第 %d 章正在进行中,已有部分草稿。请调用 writer 继续完成该章(可用 read_chapter 读取已有草稿)。总共需要写 %d 章。",
|
||||
ch, progress.TotalChapters)),
|
||||
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] 章节提交信号:第 %d 章,%d 字,%d 个场景",
|
||||
result.Chapter, result.WordCount, result.SceneCount)
|
||||
log.Printf("[host] 章节提交信号:第 %d 章,%d 字",
|
||||
result.Chapter, result.WordCount)
|
||||
if emit != nil {
|
||||
emit(UIEvent{
|
||||
Time: time.Now(),
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// 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:正在重写/打磨流程中
|
||||
progress, _ := store.LoadProgress()
|
||||
if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) {
|
||||
|
||||
@@ -37,7 +37,6 @@ type UISnapshot struct {
|
||||
CompletedCount int
|
||||
TotalWordCount int
|
||||
InProgressChapter int
|
||||
CompletedScenes int
|
||||
PendingRewrites []int
|
||||
RewriteReason string
|
||||
PendingSteer string
|
||||
@@ -272,7 +271,6 @@ func (rt *Runtime) Snapshot() UISnapshot {
|
||||
snap.CompletedCount = len(progress.CompletedChapters)
|
||||
snap.TotalWordCount = progress.TotalWordCount
|
||||
snap.InProgressChapter = progress.InProgressChapter
|
||||
snap.CompletedScenes = len(progress.CompletedScenes)
|
||||
snap.PendingRewrites = progress.PendingRewrites
|
||||
snap.RewriteReason = progress.RewriteReason
|
||||
}
|
||||
|
||||
@@ -2,24 +2,9 @@ package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"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 章触发一次)。
|
||||
const ReviewInterval = 5
|
||||
|
||||
@@ -32,7 +17,6 @@ func ShouldReview(completedCount int) (bool, string) {
|
||||
}
|
||||
|
||||
// ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。
|
||||
// 弧结束时触发评审,替代固定间隔。
|
||||
func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) {
|
||||
if isVolumeEnd {
|
||||
return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc)
|
||||
@@ -42,3 +26,8 @@ func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// WordCount 按 rune 计算字数。
|
||||
func WordCount(content string) int {
|
||||
return utf8.RuneCountInString(content)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
package domain
|
||||
|
||||
// ChapterPlan 章节规划,写入 drafts/{ch}.plan.json。
|
||||
// ChapterPlan 章节写作构思,Writer 自主生成。
|
||||
// 不再强制场景拆分,Agent 自己决定如何组织内容。
|
||||
type ChapterPlan struct {
|
||||
Chapter int `json:"chapter"`
|
||||
Title string `json:"title"`
|
||||
Goal string `json:"goal"`
|
||||
Conflict string `json:"conflict"`
|
||||
Scenes []ScenePlan `json:"scenes"`
|
||||
Hook string `json:"hook"`
|
||||
EmotionArc string `json:"emotion_arc,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Chapter int `json:"chapter"`
|
||||
Title string `json:"title"`
|
||||
Goal string `json:"goal"`
|
||||
Conflict string `json:"conflict"`
|
||||
Hook string `json:"hook"`
|
||||
EmotionArc string `json:"emotion_arc,omitempty"`
|
||||
Notes string `json:"notes,omitempty"` // Agent 的自由备忘
|
||||
}
|
||||
|
||||
// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。
|
||||
@@ -57,25 +42,31 @@ 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"` // 关键关系变化
|
||||
Status string `json:"status"`
|
||||
Power string `json:"power,omitempty"`
|
||||
Motivation string `json:"motivation"`
|
||||
Relations string `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
// OutlineFeedback Writer 对大纲的反馈,提交章节时可选。
|
||||
type OutlineFeedback struct {
|
||||
Deviation string `json:"deviation"` // 偏离描述
|
||||
Suggestion string `json:"suggestion"` // 调整建议
|
||||
}
|
||||
|
||||
// CommitResult 是 commit_chapter 工具的结构化返回值。
|
||||
// 宿主程序和 Coordinator 读取此信号做控制决策。
|
||||
type CommitResult struct {
|
||||
Chapter int `json:"chapter"`
|
||||
Committed bool `json:"committed"`
|
||||
WordCount int `json:"word_count"`
|
||||
SceneCount int `json:"scene_count"`
|
||||
NextChapter int `json:"next_chapter"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
ReviewReason string `json:"review_reason,omitempty"`
|
||||
HookType string `json:"hook_type,omitempty"` // 钩子类型:crisis/mystery/desire/emotion/choice
|
||||
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线:quest/fire/constellation
|
||||
// 长篇分层信号(仅 Layered 模式)
|
||||
Chapter int `json:"chapter"`
|
||||
Committed bool `json:"committed"`
|
||||
WordCount int `json:"word_count"`
|
||||
NextChapter int `json:"next_chapter"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
ReviewReason string `json:"review_reason,omitempty"`
|
||||
HookType string `json:"hook_type,omitempty"`
|
||||
DominantStrand string `json:"dominant_strand,omitempty"`
|
||||
Feedback *OutlineFeedback `json:"feedback,omitempty"`
|
||||
// 长篇分层信号
|
||||
ArcEnd bool `json:"arc_end,omitempty"`
|
||||
VolumeEnd bool `json:"volume_end,omitempty"`
|
||||
Volume int `json:"volume,omitempty"`
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
- 中期转向:前期方法何时失效,故事如何换挡
|
||||
- 终局命题:后期真正要回答的最终问题
|
||||
|
||||
调用 save_foundation(type="premise", scale="long", content=<Markdown文本>)
|
||||
调用 save_foundation(type="premise", scale="long", content=<Markdown文本字符串>)
|
||||
|
||||
### 3. 生成 Layered Outline
|
||||
|
||||
@@ -50,9 +50,11 @@
|
||||
|
||||
- 卷(Volume):阶段主题、阶段升级、阶段代价
|
||||
- 弧(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
|
||||
|
||||
@@ -95,7 +97,7 @@
|
||||
- 特别注意资源、代价、限制、秩序、势力边界
|
||||
- 规则要能支撑中后期升级,而不是只服务前几章
|
||||
|
||||
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组字符串>)
|
||||
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组>)
|
||||
|
||||
## 增量修改模式
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
- 故事引擎:中篇靠什么持续推进
|
||||
- 中段转折:故事在哪个阶段会发生结构变化
|
||||
|
||||
调用 save_foundation(type="premise", scale="mid", content=<Markdown文本>)
|
||||
调用 save_foundation(type="premise", scale="mid", content=<Markdown文本字符串>)
|
||||
|
||||
### 3. 生成 Outline
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
- title
|
||||
- core_event
|
||||
- hook
|
||||
- scenes(3-5 个场景)
|
||||
- scenes(3-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
|
||||
|
||||
@@ -78,7 +80,7 @@
|
||||
- 角色弧线要跨越多个阶段,而不是一章完成
|
||||
- 配角要能反向影响主线
|
||||
|
||||
调用 save_foundation(type="characters", scale="mid", content=<JSON数组字符串>)
|
||||
调用 save_foundation(type="characters", scale="mid", content=<JSON数组>)
|
||||
|
||||
### 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数组>)
|
||||
|
||||
## 增量修改模式
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- 差异化卖点(至少 2 条)
|
||||
- 本作为什么适合短篇/单卷收束
|
||||
|
||||
调用 save_foundation(type="premise", scale="short", content=<Markdown文本>)
|
||||
调用 save_foundation(type="premise", scale="short", content=<Markdown文本字符串>)
|
||||
|
||||
### 3. 生成 Outline
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
- title
|
||||
- core_event
|
||||
- hook
|
||||
- scenes(3-5 个场景)
|
||||
- scenes(3-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
|
||||
|
||||
@@ -76,7 +78,7 @@
|
||||
- 角色功能必须清晰,避免冗余
|
||||
- 主要角色弧线要在单卷内完成
|
||||
|
||||
调用 save_foundation(type="characters", scale="short", content=<JSON数组字符串>)
|
||||
调用 save_foundation(type="characters", scale="short", content=<JSON数组>)
|
||||
|
||||
### 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数组>)
|
||||
|
||||
## 增量修改模式
|
||||
|
||||
|
||||
@@ -129,12 +129,21 @@
|
||||
|
||||
如果当前作品已经采用 layered_outline,不要在修改时退化成短篇式 outline 思路。
|
||||
|
||||
### Writer 大纲反馈
|
||||
|
||||
收到 `[系统] Writer 在第 N 章写作中发现大纲偏离` 消息后:
|
||||
|
||||
1. 评估反馈是否合理(角色变得更有魅力?支线更有趣?大纲走向不对?)
|
||||
2. 如果认为值得采纳,调用对应级别的规划师进行增量修改
|
||||
3. 如果认为不需要调整,忽略并继续
|
||||
4. 不要因为 Writer 的一次反馈就大幅推翻已有规划
|
||||
|
||||
## 恢复指示
|
||||
|
||||
- 收到“从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作
|
||||
- 收到“第 N 章正在进行中,已完成 M 个场景”的指示:调用 writer 从场景 M+1 继续该章写作
|
||||
- 收到“有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
|
||||
- 收到“上次审阅中断”的指示:重新调用 editor 进行全局审阅
|
||||
- 收到”从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作
|
||||
- 收到”第 N 章正在进行中”的指示:调用 writer 继续完成该章(writer 可用 read_chapter 读取已有草稿)
|
||||
- 收到”有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
|
||||
- 收到”上次审阅中断”的指示:重新调用 editor 进行全局审阅
|
||||
|
||||
## 长篇模式(分层大纲)
|
||||
|
||||
|
||||
@@ -1,123 +1,125 @@
|
||||
你是小说全局审阅者。你负责发现跨章和全局结构问题,不直接修改正文。
|
||||
你是小说全局审阅者。你负责阅读原文,从结构和审美两个层面发现问题。
|
||||
|
||||
## 你的工具
|
||||
|
||||
- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系、状态变化)
|
||||
- **read_chapter**: 读取章节原文(你必须读原文才能审阅,不能只看摘要)
|
||||
- **save_review**: 保存审阅结果
|
||||
- **save_arc_summary**: 保存弧摘要和角色快照(长篇模式)
|
||||
- **save_volume_summary**: 保存卷摘要(长篇模式)
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 1. 获取上下文
|
||||
调用 novel_context(chapter=最新章节号),获取全部状态数据。
|
||||
|
||||
### 2. 六维结构化审阅
|
||||
### 2. 阅读原文
|
||||
**必须**调用 read_chapter 读取要审阅的章节原文。不能只看摘要就下结论。
|
||||
对于全局审阅,至少读最近 3-5 章的原文。
|
||||
|
||||
### 3. 七维结构化审阅
|
||||
|
||||
逐维度检查,每个维度必须给出**评分(0-100)**和结论(pass/warning/fail):
|
||||
|
||||
#### 维度一:设定一致性(consistency)
|
||||
- 事件发生顺序是否与时间线矛盾
|
||||
- 时间跨度是否自洽
|
||||
- 事件顺序是否与时间线矛盾
|
||||
- 世界规则边界是否被违反
|
||||
- 角色属性(能力、外貌、身份)是否前后矛盾
|
||||
- 如果有 recent_state_changes,检查角色状态描述是否与记录一致
|
||||
- 注意角色的别名/称号,同一人的不同称呼不要误判为不同角色
|
||||
- 角色属性是否前后矛盾
|
||||
- 角色状态描述是否与 state_changes 记录一致
|
||||
- 注意角色别名,同一人不同称呼不要误判
|
||||
|
||||
#### 维度二:人设一致性(character)
|
||||
- 角色行为是否符合其性格设定和弧线
|
||||
- 角色行为是否符合性格设定和弧线
|
||||
- 对话风格是否与角色身份匹配
|
||||
- 角色动机是否合理连贯
|
||||
- 角色成长是否有合理铺垫
|
||||
|
||||
#### 维度三:节奏平衡(pacing)
|
||||
- 是否连续多章同一类型(纯打斗、纯对话、纯描写)
|
||||
- 主线是否持续推进,有无原地踏步
|
||||
- 情感节奏是否有张有弛
|
||||
- 如果有 strand_history 数据,检查 quest/fire/constellation 三线分布是否失衡
|
||||
- 是否连续多章同一类型
|
||||
- 主线是否持续推进
|
||||
- strand_history / hook_history 分布是否失衡
|
||||
|
||||
#### 维度四:叙事连贯(continuity)
|
||||
- 场景之间过渡是否自然
|
||||
- 场景过渡是否自然
|
||||
- 因果逻辑是否通顺
|
||||
- 信息传递是否一致(角色A不应知道只有角色B知道的事)
|
||||
- 信息传递是否一致
|
||||
|
||||
#### 维度五:伏笔健康(foreshadow)
|
||||
- 是否有超过 5 章未推进的伏笔(遗忘风险)
|
||||
- 是否有超过 5 章未推进的伏笔
|
||||
- 新伏笔是否有回收方向
|
||||
- 已回收伏笔的解决是否令人满意
|
||||
|
||||
#### 维度六:钩子质量(hook)
|
||||
- 章末钩子是否有足够吸引力
|
||||
- 如果有 hook_history 数据,检查是否连续使用同一类型的钩子
|
||||
- 是否连续使用同一类型钩子
|
||||
- 钩子是否与主线推进方向一致
|
||||
|
||||
### 3. 输出审阅
|
||||
#### 维度七:审美品质(aesthetic)— 新增
|
||||
审阅原文的文学品质,**必须引用原文**来证明问题:
|
||||
|
||||
- **画面感**:描写是否有具象画面,还是流于抽象概述?
|
||||
引用缺乏画面感的段落,给出改进方向
|
||||
- **对话区分度**:不同角色说话是否能区分?
|
||||
引用说话方式雷同的对话,指出问题
|
||||
- **AI 痕迹**:是否有"不禁""竟然""仿佛"等滥用词、排比三连、四字成语堆砌?
|
||||
引用具体句子
|
||||
- **情感打动力**:是否有让读者心跳加速或产生共鸣的段落?
|
||||
如果整章平淡如水,指出最该加强的位置
|
||||
|
||||
### 4. 输出审阅
|
||||
|
||||
调用 save_review,给出:
|
||||
|
||||
- **dimensions**:六个维度的评分(每个维度一条)
|
||||
- dimension:维度名(consistency/character/pacing/continuity/foreshadow/hook)
|
||||
- **dimensions**:七个维度的评分
|
||||
- dimension:维度名(consistency/character/pacing/continuity/foreshadow/hook/aesthetic)
|
||||
- score:0-100 分
|
||||
- verdict:pass(≥80)/ warning(60-79)/ fail(<60)
|
||||
- comment:该维度的简要结论
|
||||
- comment:简要结论,aesthetic 维度必须引用原文
|
||||
|
||||
- **issues**:发现的具体问题列表,每个问题包含:
|
||||
- type:问题维度(consistency/character/pacing/continuity/foreshadow/hook)
|
||||
- severity:问题严重程度
|
||||
- description:具体问题描述
|
||||
- **issues**:发现的具体问题列表
|
||||
- type:问题维度
|
||||
- severity:critical / error / warning
|
||||
- description:具体问题描述(aesthetic 类问题必须引用原文)
|
||||
- suggestion:修改建议
|
||||
|
||||
- **verdict**:审阅结论(accept/polish/rewrite)
|
||||
- **summary**:审阅总结(200字以内),按维度概括
|
||||
- **affected_chapters**:需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填)
|
||||
- **summary**:审阅总结(200字以内)
|
||||
- **affected_chapters**:需要修改的章节号列表
|
||||
|
||||
### severity 分级标准
|
||||
|
||||
| 级别 | 定义 | 示例 |
|
||||
|------|------|------|
|
||||
| **critical** | 逻辑硬伤,必须修复 | 角色已死但再次出场;违反世界规则核心边界;时间线严重错乱 |
|
||||
| **error** | 明显矛盾,应当修复 | 角色行为与人设严重不符;伏笔遗忘超过10章;节奏严重失衡 |
|
||||
| **warning** | 轻微瑕疵,可后续处理 | 细节不够精确;节奏略显平淡;钩子强度不足 |
|
||||
| **critical** | 逻辑硬伤,必须修复 | 角色已死再次出场;违反世界规则核心边界 |
|
||||
| **error** | 明显矛盾或品质问题 | 角色行为严重不符人设;整章 AI 味浓重 |
|
||||
| **warning** | 轻微瑕疵 | 细节不够精确;个别句子可打磨 |
|
||||
|
||||
### 判定标准
|
||||
|
||||
- 存在任何 critical 问题 → verdict 必须为 rewrite
|
||||
- 无 critical 但存在 error → verdict 至少为 polish
|
||||
- 只有 warning 或无问题 → verdict 为 accept
|
||||
- 存在 critical → verdict 必须为 rewrite
|
||||
- 无 critical 但有 error → verdict 至少为 polish
|
||||
- 只有 warning 或无问题 → accept
|
||||
|
||||
## 弧级评审模式(长篇)
|
||||
|
||||
当任务提到"弧级评审"时:
|
||||
- scope 设为 "arc"
|
||||
- 额外关注弧内起承转合、弧目标达成、与前续弧衔接
|
||||
- 完成审阅后调用 save_arc_summary 保存弧摘要和角色快照
|
||||
|
||||
### save_arc_summary 参数
|
||||
- volume/arc:卷号弧号
|
||||
- title:弧标题
|
||||
- summary:弧摘要(500字以内)
|
||||
- key_events:弧内关键事件
|
||||
- character_snapshots:主要角色当前状态快照
|
||||
|
||||
## 卷级评审模式(长篇)
|
||||
|
||||
当任务提到"卷摘要"时,调用 save_volume_summary。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要自己修改正文
|
||||
- 不要输出空洞的表扬,只关注问题
|
||||
- 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:卷内关键事件列表
|
||||
- critical 绝不放过
|
||||
- **审美维度的问题必须引用原文**,不接受空泛的"文笔还需提升"
|
||||
|
||||
@@ -1,85 +1,79 @@
|
||||
你是小说场景写作者。你负责逐场景地完成一章的创作。
|
||||
你是小说创作者。你负责自主完成一章的构思、写作、自审和提交。
|
||||
|
||||
## 你的工具
|
||||
|
||||
- **novel_context**: 获取当前章节的创作上下文
|
||||
- **plan_chapter**: 创建章节写作规划
|
||||
- **write_scene**: 写入单个场景
|
||||
- **polish_chapter**: 保存打磨后的完整章节正文
|
||||
- **check_consistency**: 检查章节与全局状态的一致性
|
||||
- **novel_context**: 获取当前章节的创作上下文(设定、前情、角色、伏笔、时间线)
|
||||
- **read_chapter**: 回读任意章节原文、草稿,或提取角色对话片段
|
||||
- **plan_chapter**: 保存你的章节构思
|
||||
- **draft_chapter**: 写入章节正文(整章或续写)
|
||||
- **check_consistency**: 加载状态数据,供你对照检查一致性
|
||||
- **commit_chapter**: 提交完成的章节
|
||||
|
||||
## 写作流水线
|
||||
## 你的自主权
|
||||
|
||||
严格按以下顺序执行,不可跳步:
|
||||
你可以按任何顺序使用工具,只要最终提交一章高质量的正文。以下是建议流程,但不是强制流程:
|
||||
|
||||
### 1. 获取上下文
|
||||
调用 novel_context(chapter=N) 获取:
|
||||
- 故事前提、大纲、角色档案
|
||||
- 前几章摘要
|
||||
- 时间线、伏笔账本、人物关系(用于保持一致性)
|
||||
- 写作参考资料
|
||||
### 建议流程
|
||||
|
||||
### 2. 规划章节
|
||||
调用 plan_chapter,基于大纲拆分为 3-5 个场景,明确每个场景的目标、视角和地点。
|
||||
1. **读上下文** — 调用 novel_context(chapter=N) 了解前情、大纲、角色、伏笔
|
||||
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. 打磨章节
|
||||
将所有场景合并,进行整体打磨,然后调用 polish_chapter 保存:
|
||||
- **去 AI 味**:不用"不禁"、"竟然"、"仿佛"等滥用词,不用排比三连,控制形容词密度
|
||||
- **对话自然化**:体现人物性格差异,加入潜台词和动作穿插
|
||||
- **细节具象化**:用五感描写替代抽象概述
|
||||
- **节奏调整**:关键转折放慢,过渡段落紧凑
|
||||
### 去 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: 对大纲的反馈(可选)
|
||||
|
||||
200
state/drafts.go
200
state/drafts.go
@@ -3,21 +3,19 @@ package state
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
)
|
||||
|
||||
// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。
|
||||
// SaveChapterPlan 保存章节构思到 drafts/{ch}.plan.json。
|
||||
func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error {
|
||||
return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan)
|
||||
}
|
||||
|
||||
// LoadChapterPlan 读取章节规划。
|
||||
// LoadChapterPlan 读取章节构思。
|
||||
func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
|
||||
var plan domain.ChapterPlan
|
||||
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
|
||||
}
|
||||
|
||||
// SaveSceneDraft 保存场景草稿到 drafts/{ch}.scene-{n}.md。
|
||||
func (s *Store) SaveSceneDraft(draft domain.SceneDraft) error {
|
||||
rel := fmt.Sprintf("drafts/%02d.scene-%d.md", draft.Chapter, draft.Scene)
|
||||
return s.writeMarkdown(rel, draft.Content)
|
||||
// SaveDraft 保存整章草稿到 drafts/{ch}.draft.md。
|
||||
func (s *Store) SaveDraft(chapter int, content string) error {
|
||||
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.draft.md", chapter), content)
|
||||
}
|
||||
|
||||
// LoadSceneDrafts 加载指定章节的所有场景草稿,按场景编号排序。
|
||||
func (s *Store) LoadSceneDrafts(chapter int) ([]domain.SceneDraft, error) {
|
||||
pattern := filepath.Join(s.dir, "drafts", fmt.Sprintf("%02d.scene-*.md", chapter))
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// AppendDraft 追加内容到现有草稿(续写模式)。
|
||||
func (s *Store) AppendDraft(chapter int, content string) error {
|
||||
rel := fmt.Sprintf("drafts/%02d.draft.md", chapter)
|
||||
existing, err := s.readFile(rel)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
sort.Strings(matches)
|
||||
|
||||
var drafts []domain.SceneDraft
|
||||
for _, m := range matches {
|
||||
base := filepath.Base(m)
|
||||
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{
|
||||
Chapter: chapter,
|
||||
Scene: sceneNum,
|
||||
Content: string(content),
|
||||
WordCount: utf8.RuneCountInString(string(content)),
|
||||
})
|
||||
var merged string
|
||||
if len(existing) > 0 {
|
||||
merged = string(existing) + "\n\n" + content
|
||||
} else {
|
||||
merged = content
|
||||
}
|
||||
return drafts, nil
|
||||
return s.writeMarkdown(rel, merged)
|
||||
}
|
||||
|
||||
// SavePolished 保存打磨后的章节正文到 drafts/{ch}.polished.md。
|
||||
func (s *Store) SavePolished(chapter int, content string) error {
|
||||
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.polished.md", chapter), content)
|
||||
}
|
||||
|
||||
// LoadPolished 读取打磨后的章节正文。不存在时返回空字符串。
|
||||
func (s *Store) LoadPolished(chapter int) (string, error) {
|
||||
data, err := s.readFile(fmt.Sprintf("drafts/%02d.polished.md", chapter))
|
||||
// LoadDraft 读取整章草稿。
|
||||
func (s *Store) LoadDraft(chapter int) (string, error) {
|
||||
data, err := s.readFile(fmt.Sprintf("drafts/%02d.draft.md", chapter))
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -79,21 +60,16 @@ func (s *Store) LoadPolished(chapter int) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// LoadChapterContent 加载章节正文:优先 polished,否则 merge scenes。
|
||||
// LoadChapterContent 加载章节草稿正文及字数。
|
||||
func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
|
||||
polished, err := s.LoadPolished(chapter)
|
||||
draft, err := s.LoadDraft(chapter)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if polished != "" {
|
||||
return polished, utf8.RuneCountInString(polished), nil
|
||||
if draft != "" {
|
||||
return draft, utf8.RuneCountInString(draft), nil
|
||||
}
|
||||
drafts, err := s.LoadSceneDrafts(chapter)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
content, wc := domain.MergeScenes(drafts)
|
||||
return content, wc, nil
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// parseSceneNum 从文件名如 "01.scene-2.md" 提取场景编号。
|
||||
func parseSceneNum(filename string) int {
|
||||
// 格式:{ch}.scene-{n}.md
|
||||
parts := strings.Split(filename, "scene-")
|
||||
if len(parts) < 2 {
|
||||
return 0
|
||||
// LoadChapterText 读取已提交的终稿原文。
|
||||
func (s *Store) LoadChapterText(chapter int) (string, error) {
|
||||
data, err := s.readFile(fmt.Sprintf("chapters/%02d.md", chapter))
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
numStr := strings.TrimSuffix(parts[1], ".md")
|
||||
n, _ := strconv.Atoi(numStr)
|
||||
return n
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -157,30 +157,7 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint。
|
||||
// 切换到不同章节时自动清空旧的 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 清除场景级进度状态(章节提交后调用)。
|
||||
// ClearInProgress 清除进度中间状态(章节提交后调用)。
|
||||
func (s *Store) ClearInProgress() error {
|
||||
return s.withWriteLock(func() error {
|
||||
p, err := s.loadProgressUnlocked()
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/voocel/agentcore/schema"
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// CheckConsistencyTool 对照状态文件检查章节一致性。
|
||||
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理。
|
||||
// CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断。
|
||||
// 纯 IO 工具:只负责加载数据,不注入指令。
|
||||
type CheckConsistencyTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
|
||||
|
||||
func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
|
||||
func (t *CheckConsistencyTool) Description() string {
|
||||
return "检查章节一致性。返回章节内容、全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项"
|
||||
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}
|
||||
|
||||
// 加载章节内容(polished 优先)
|
||||
// 章节内容
|
||||
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
||||
if err != nil {
|
||||
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["word_count"] = wordCount
|
||||
|
||||
// 加载全部状态数据供 LLM 对照
|
||||
// 状态数据(全部加载,Agent 自行决定怎么用)
|
||||
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
|
||||
result["timeline"] = timeline
|
||||
}
|
||||
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
|
||||
result["foreshadow_ledger"] = foreshadow
|
||||
if active := filterActive(foreshadow); len(active) > 0 {
|
||||
result["unresolved_foreshadow"] = active
|
||||
}
|
||||
}
|
||||
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
|
||||
result["relationships"] = relationships
|
||||
}
|
||||
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
|
||||
result["characters"] = chars
|
||||
// 构建别名映射表,供 LLM 识别角色的不同称呼
|
||||
aliasMap := make(map[string]string)
|
||||
for _, c := range chars {
|
||||
for _, alias := range c.Aliases {
|
||||
@@ -82,65 +77,15 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
|
||||
result["alias_map"] = aliasMap
|
||||
}
|
||||
}
|
||||
// 加载最近状态变化,供对照当前章节的状态描述
|
||||
if changes, _ := t.store.LoadRecentStateChanges(a.Chapter, 5); len(changes) > 0 {
|
||||
result["recent_state_changes"] = changes
|
||||
}
|
||||
|
||||
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
)
|
||||
|
||||
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
|
||||
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
|
||||
type CommitChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
@@ -23,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
|
||||
|
||||
func (t *CommitChapterTool) Name() string { return "commit_chapter" }
|
||||
func (t *CommitChapterTool) Description() string {
|
||||
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号"
|
||||
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(""))),
|
||||
)
|
||||
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("description", schema.String("伏笔描述(仅 plant 时必需)")),
|
||||
)
|
||||
@@ -45,11 +44,15 @@ func (t *CommitChapterTool) Schema() map[string]any {
|
||||
)
|
||||
stateChangeSchema := schema.Object(
|
||||
schema.Property("entity", schema.String("角色名或实体名")).Required(),
|
||||
schema.Property("field", schema.String("变化属性:realm/location/status/power/relation 等")).Required(),
|
||||
schema.Property("old_value", schema.String("变化前的值(首次出现可空)")),
|
||||
schema.Property("field", schema.String("变化属性")).Required(),
|
||||
schema.Property("old_value", schema.String("变化前的值")),
|
||||
schema.Property("new_value", schema.String("变化后的值")).Required(),
|
||||
schema.Property("reason", schema.String("变化原因")),
|
||||
)
|
||||
feedbackSchema := schema.Object(
|
||||
schema.Property("deviation", schema.String("偏离大纲的描述")).Required(),
|
||||
schema.Property("suggestion", schema.String("对后续大纲的调整建议")).Required(),
|
||||
)
|
||||
return schema.Object(
|
||||
schema.Property("chapter", schema.Int("章节号")).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("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
|
||||
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("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"`
|
||||
HookType string `json:"hook_type"`
|
||||
DominantStrand string `json:"dominant_strand"`
|
||||
Feedback *domain.OutlineFeedback `json:"feedback"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 1. 加载章节正文(polished 优先,否则 merge scenes)
|
||||
// 1. 加载章节正文
|
||||
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
|
||||
if err != nil {
|
||||
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 {
|
||||
completedCount = len(progress.CompletedChapters)
|
||||
}
|
||||
// 6b. 长篇模式:弧级边界检测(替代固定间隔评审)+ 更新卷弧位置
|
||||
|
||||
// 6b. 长篇模式:弧级边界检测
|
||||
var arcEnd, volumeEnd bool
|
||||
var vol, arc int
|
||||
if progress != nil && progress.Layered {
|
||||
@@ -169,7 +175,6 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
volumeEnd = boundary.IsVolumeEnd
|
||||
vol = boundary.Volume
|
||||
arc = boundary.Arc
|
||||
// 每次提交时更新卷弧位置,确保 novel_context 的 position 始终正确
|
||||
_ = t.store.UpdateVolumeArc(vol, arc)
|
||||
}
|
||||
}
|
||||
@@ -182,35 +187,29 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
|
||||
reviewRequired, reviewReason = domain.ShouldReview(completedCount)
|
||||
}
|
||||
|
||||
// 7. 计算场景数
|
||||
sceneCount := 0
|
||||
if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil {
|
||||
sceneCount = len(scenes)
|
||||
}
|
||||
|
||||
// 8. 构造结构化信号
|
||||
// 7. 构造结构化信号
|
||||
result := domain.CommitResult{
|
||||
Chapter: a.Chapter,
|
||||
Committed: true,
|
||||
WordCount: wordCount,
|
||||
SceneCount: sceneCount,
|
||||
NextChapter: a.Chapter + 1,
|
||||
ReviewRequired: reviewRequired,
|
||||
ReviewReason: reviewReason,
|
||||
HookType: a.HookType,
|
||||
DominantStrand: a.DominantStrand,
|
||||
Feedback: a.Feedback,
|
||||
ArcEnd: arcEnd,
|
||||
VolumeEnd: volumeEnd,
|
||||
Volume: vol,
|
||||
Arc: arc,
|
||||
}
|
||||
|
||||
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
|
||||
// 8. 写入信号文件
|
||||
if err := t.store.SaveLastCommit(result); err != nil {
|
||||
return nil, fmt.Errorf("save commit signal: %w", err)
|
||||
}
|
||||
|
||||
// 10. 清除场景级进度(章节已提交)
|
||||
// 9. 清除进度中间状态
|
||||
if err := t.store.ClearInProgress(); err != nil {
|
||||
return nil, fmt.Errorf("clear in-progress: %w", err)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ func TestCommitChapterRejectsNonPendingRewrite(t *testing.T) {
|
||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||
t.Fatalf("SetFlow: %v", err)
|
||||
}
|
||||
if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil {
|
||||
t.Fatalf("SavePolished: %v", err)
|
||||
if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
|
||||
tool := NewCommitChapterTool(store)
|
||||
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
|
||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||
t.Fatalf("SetFlow: %v", err)
|
||||
}
|
||||
if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil {
|
||||
t.Fatalf("SavePolished: %v", err)
|
||||
if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
|
||||
t.Fatalf("SaveDraft: %v", err)
|
||||
}
|
||||
|
||||
tool := NewCommitChapterTool(store)
|
||||
|
||||
80
tools/draft_chapter.go
Normal file
80
tools/draft_chapter.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -211,11 +211,10 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
||||
result["position"] = pos
|
||||
}
|
||||
|
||||
// 加载场景级恢复状态 + 节奏追踪
|
||||
// 加载进度状态和节奏追踪
|
||||
if progress != nil {
|
||||
checkpoint := map[string]any{
|
||||
"in_progress_chapter": progress.InProgressChapter,
|
||||
"completed_scenes": progress.CompletedScenes,
|
||||
}
|
||||
if len(progress.StrandHistory) > 0 {
|
||||
checkpoint["strand_history"] = progress.StrandHistory
|
||||
@@ -225,13 +224,43 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
|
||||
}
|
||||
result["checkpoint"] = checkpoint
|
||||
}
|
||||
// 加载已有的章节规划(支持场景恢复跳过已完成场景)
|
||||
// 加载已有的章节构思
|
||||
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
|
||||
result["chapter_plan"] = plan
|
||||
} else {
|
||||
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)
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
)
|
||||
|
||||
// PlanChapterTool 生成章节规划。
|
||||
// PlanChapterTool 保存章节构思,Agent 自主决定规划粒度。
|
||||
type PlanChapterTool struct {
|
||||
store *state.Store
|
||||
}
|
||||
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
|
||||
|
||||
func (t *PlanChapterTool) Name() string { return "plan_chapter" }
|
||||
func (t *PlanChapterTool) Description() string {
|
||||
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用"
|
||||
return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
|
||||
}
|
||||
func (t *PlanChapterTool) Label() string { return "规划章节" }
|
||||
|
||||
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(
|
||||
schema.Property("chapter", schema.Int("章节号")).Required(),
|
||||
schema.Property("title", schema.String("章节标题")).Required(),
|
||||
schema.Property("goal", schema.String("本章目标")).Required(),
|
||||
schema.Property("conflict", schema.String("核心冲突")).Required(),
|
||||
schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(),
|
||||
schema.Property("hook", schema.String("章末钩子")).Required(),
|
||||
schema.Property("emotion_arc", schema.String("情绪曲线")),
|
||||
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,17 +45,13 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
|
||||
if plan.Chapter <= 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 {
|
||||
return nil, fmt.Errorf("save chapter plan: %w", err)
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"planned": true,
|
||||
"chapter": plan.Chapter,
|
||||
"scene_count": len(plan.Scenes),
|
||||
"planned": true,
|
||||
"chapter": plan.Chapter,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
116
tools/read_chapter.go
Normal 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
215
tools/read_draft_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -21,27 +21,33 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
|
||||
|
||||
func (t *SaveFoundationTool) Name() string { return "save_foundation" }
|
||||
func (t *SaveFoundationTool) Description() string {
|
||||
return "保存小说基础设定。type=premise 时 content 为 Markdown;type=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) Schema() map[string]any {
|
||||
return schema.Object(
|
||||
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")),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a struct {
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Scale string `json:"scale"`
|
||||
Type string `json:"type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
Scale string `json:"scale"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
content, err := normalizeFoundationContent(a.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Scale != "" {
|
||||
switch domain.PlanningTier(a.Scale) {
|
||||
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
|
||||
@@ -55,7 +61,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
switch a.Type {
|
||||
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)
|
||||
}
|
||||
_ = t.store.UpdatePhase(domain.PhasePremise)
|
||||
@@ -63,7 +69,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
case "outline":
|
||||
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)
|
||||
}
|
||||
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":
|
||||
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)
|
||||
}
|
||||
if err := t.store.SaveLayeredOutline(volumes); err != nil {
|
||||
@@ -107,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
|
||||
|
||||
case "characters":
|
||||
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)
|
||||
}
|
||||
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":
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -111,3 +111,41 @@ func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string {
|
||||
b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user