diff --git a/README.md b/README.md index 9220ba9..cf1b41d 100644 --- a/README.md +++ b/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,才做成工具。 + ### 全自动闭环 一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策: diff --git a/app/agents.go b/app/agents.go index 9410fba..7d246ca 100644 --- a/app/agents.go +++ b/app/agents.go @@ -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, diff --git a/app/run.go b/app/run.go index 8d0a04b..d2b447b 100644 --- a/app/run.go +++ b/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) { diff --git a/app/runtime.go b/app/runtime.go index 16d3a99..5960359 100644 --- a/app/runtime.go +++ b/app/runtime.go @@ -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 } diff --git a/domain/chapter.go b/domain/chapter.go index d0279bc..d21d47a 100644 --- a/domain/chapter.go +++ b/domain/chapter.go @@ -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) +} diff --git a/domain/writing.go b/domain/writing.go index 6fdaa0f..4e8f5d5 100644 --- a/domain/writing.go +++ b/domain/writing.go @@ -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"` diff --git a/prompts/architect-long.md b/prompts/architect-long.md index b2b962c..7685a91 100644 --- a/prompts/architect-long.md +++ b/prompts/architect-long.md @@ -42,7 +42,7 @@ - 中期转向:前期方法何时失效,故事如何换挡 - 终局命题:后期真正要回答的最终问题 -调用 save_foundation(type="premise", scale="long", content=) +调用 save_foundation(type="premise", scale="long", content=) ### 3. 生成 Layered Outline @@ -50,9 +50,11 @@ - 卷(Volume):阶段主题、阶段升级、阶段代价 - 弧(Arc):局部目标、局部阻力、阶段转折 -- 章(Chapter):章节标题、核心事件、钩子、场景 +- 章(Chapter):章节标题、核心事件、钩子、要点 -调用 save_foundation(type="layered_outline", scale="long", content=) +调用 save_foundation(type="layered_outline", scale="long", content=) + +注意:`content` 对于 layered_outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。 要求: @@ -80,7 +82,7 @@ - 重要配角不能只是阶段性工具人 - 关系线必须具备长期张力,而不是只服务某一章剧情 -调用 save_foundation(type="characters", scale="long", content=) +调用 save_foundation(type="characters", scale="long", content=) ### 5. 生成 World Rules @@ -95,7 +97,7 @@ - 特别注意资源、代价、限制、秩序、势力边界 - 规则要能支撑中后期升级,而不是只服务前几章 -调用 save_foundation(type="world_rules", scale="long", content=) +调用 save_foundation(type="world_rules", scale="long", content=) ## 增量修改模式 diff --git a/prompts/architect-mid.md b/prompts/architect-mid.md index 6524b1a..91eabba 100644 --- a/prompts/architect-mid.md +++ b/prompts/architect-mid.md @@ -40,7 +40,7 @@ - 故事引擎:中篇靠什么持续推进 - 中段转折:故事在哪个阶段会发生结构变化 -调用 save_foundation(type="premise", scale="mid", content=) +调用 save_foundation(type="premise", scale="mid", content=) ### 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=) +调用 save_foundation(type="outline", scale="mid", content=) + +注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。 ### 4. 生成 Characters @@ -78,7 +80,7 @@ - 角色弧线要跨越多个阶段,而不是一章完成 - 配角要能反向影响主线 -调用 save_foundation(type="characters", scale="mid", content=) +调用 save_foundation(type="characters", scale="mid", content=) ### 5. 生成 World Rules @@ -92,7 +94,7 @@ - 规则必须制造选择或代价 - 不能只是背景百科 -调用 save_foundation(type="world_rules", scale="mid", content=) +调用 save_foundation(type="world_rules", scale="mid", content=) ## 增量修改模式 diff --git a/prompts/architect-short.md b/prompts/architect-short.md index 277e5d7..9022df5 100644 --- a/prompts/architect-short.md +++ b/prompts/architect-short.md @@ -38,7 +38,7 @@ - 差异化卖点(至少 2 条) - 本作为什么适合短篇/单卷收束 -调用 save_foundation(type="premise", scale="short", content=) +调用 save_foundation(type="premise", scale="short", content=) ### 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=) +调用 save_foundation(type="outline", scale="short", content=) + +注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。 ### 4. 生成 Characters @@ -76,7 +78,7 @@ - 角色功能必须清晰,避免冗余 - 主要角色弧线要在单卷内完成 -调用 save_foundation(type="characters", scale="short", content=) +调用 save_foundation(type="characters", scale="short", content=) ### 5. 生成 World Rules @@ -90,7 +92,7 @@ - 只保留必要规则,避免为短篇过度设计世界 - 规则必须直接服务当前冲突 -调用 save_foundation(type="world_rules", scale="short", content=) +调用 save_foundation(type="world_rules", scale="short", content=) ## 增量修改模式 diff --git a/prompts/coordinator.md b/prompts/coordinator.md index b720e33..753a73d 100644 --- a/prompts/coordinator.md +++ b/prompts/coordinator.md @@ -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 进行全局审阅 ## 长篇模式(分层大纲) diff --git a/prompts/editor.md b/prompts/editor.md index 56915ea..87b7675 100644 --- a/prompts/editor.md +++ b/prompts/editor.md @@ -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 绝不放过 +- **审美维度的问题必须引用原文**,不接受空泛的"文笔还需提升" diff --git a/prompts/writer.md b/prompts/writer.md index 0fdc12f..5e49378 100644 --- a/prompts/writer.md +++ b/prompts/writer.md @@ -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: 对大纲的反馈(可选) diff --git a/state/drafts.go b/state/drafts.go index 75013ed..fb38301 100644 --- a/state/drafts.go +++ b/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 } diff --git a/state/progress.go b/state/progress.go index 6ce8c36..41378bc 100644 --- a/state/progress.go +++ b/state/progress.go @@ -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() diff --git a/tools/check_consistency.go b/tools/check_consistency.go index c365f21..472d5ff 100644 --- a/tools/check_consistency.go +++ b/tools/check_consistency.go @@ -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 -} diff --git a/tools/commit_chapter.go b/tools/commit_chapter.go index 13ca305..186f95c 100644 --- a/tools/commit_chapter.go +++ b/tools/commit_chapter.go @@ -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) } diff --git a/tools/commit_chapter_test.go b/tools/commit_chapter_test.go index a226040..c24430f 100644 --- a/tools/commit_chapter_test.go +++ b/tools/commit_chapter_test.go @@ -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) diff --git a/tools/draft_chapter.go b/tools/draft_chapter.go new file mode 100644 index 0000000..b3914df --- /dev/null +++ b/tools/draft_chapter.go @@ -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), + }) + } +} diff --git a/tools/novel_context.go b/tools/novel_context.go index 7d112f5..201bcc7 100644 --- a/tools/novel_context.go +++ b/tools/novel_context.go @@ -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 { diff --git a/tools/plan_chapter.go b/tools/plan_chapter.go index 8784a9b..809c67c 100644 --- a/tools/plan_chapter.go +++ b/tools/plan_chapter.go @@ -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, }) } diff --git a/tools/polish_chapter.go b/tools/polish_chapter.go deleted file mode 100644 index aedadd6..0000000 --- a/tools/polish_chapter.go +++ /dev/null @@ -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), - }) -} diff --git a/tools/read_chapter.go b/tools/read_chapter.go new file mode 100644 index 0000000..557ccd4 --- /dev/null +++ b/tools/read_chapter.go @@ -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)), + }) +} diff --git a/tools/read_draft_test.go b/tools/read_draft_test.go new file mode 100644 index 0000000..eb046ee --- /dev/null +++ b/tools/read_draft_test.go @@ -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") + } +} diff --git a/tools/save_foundation.go b/tools/save_foundation.go index 1b00f9f..ff5e4de 100644 --- a/tools/save_foundation.go +++ b/tools/save_foundation.go @@ -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 +} diff --git a/tools/save_foundation_test.go b/tools/save_foundation_test.go index b542320..f306c3e 100644 --- a/tools/save_foundation_test.go +++ b/tools/save_foundation_test.go @@ -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) + } +} diff --git a/tools/write_scene.go b/tools/write_scene.go deleted file mode 100644 index 03aac11..0000000 --- a/tools/write_scene.go +++ /dev/null @@ -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, - }) -} diff --git a/tui/panels.go b/tui/panels.go index ed12967..9fa0fb8 100644 --- a/tui/panels.go +++ b/tui/panels.go @@ -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 {