diff --git a/app/agents.go b/app/agents.go index e1f9134..9410fba 100644 --- a/app/agents.go +++ b/app/agents.go @@ -44,15 +44,33 @@ func BuildCoordinator( tools.NewSaveVolumeSummaryTool(store), } - architect := agentcore.SubAgentConfig{ - Name: "architect", - Description: "世界构建师:生成小说前提、大纲和角色档案", + architectShort := agentcore.SubAgentConfig{ + Name: "architect_short", + Description: "短篇规划师:为单卷、单冲突、高密度故事生成紧凑设定与扁平大纲", Model: model, - SystemPrompt: prompts.Architect, + SystemPrompt: prompts.ArchitectShort, Tools: architectTools, MaxTurns: 10, } + architectMid := agentcore.SubAgentConfig{ + Name: "architect_mid", + Description: "中篇规划师:为多阶段但篇幅受控的故事生成可推进的设定与阶段化大纲", + Model: model, + SystemPrompt: prompts.ArchitectMid, + Tools: architectTools, + MaxTurns: 12, + } + + architectLong := agentcore.SubAgentConfig{ + Name: "architect_long", + Description: "长篇规划师:为连载型、可持续升级的故事生成分层设定与卷弧大纲", + Model: model, + SystemPrompt: prompts.ArchitectLong, + Tools: architectTools, + MaxTurns: 14, + } + // 动态拼接风格指令到 Writer prompt writerPrompt := prompts.Writer if style, ok := styles[cfg.Style]; ok { @@ -77,7 +95,7 @@ func BuildCoordinator( MaxTurns: 10, } - subagentTool := agentcore.NewSubAgentTool(architect, writer, editor) + subagentTool := agentcore.NewSubAgentTool(architectShort, architectMid, architectLong, writer, editor) agent := agentcore.NewAgent( agentcore.WithModel(model), diff --git a/app/config.go b/app/config.go index 7136527..689d900 100644 --- a/app/config.go +++ b/app/config.go @@ -7,22 +7,24 @@ import ( // Config 小说应用配置。 type Config struct { - Prompt string // 用户的小说需求 - NovelName string // 小说名(用作输出目录名) - OutputDir string // 输出根目录,默认 output/{NovelName} - Provider string // LLM 提供商:openai / anthropic / gemini - ModelName string // LLM 模型名 - APIKey string // API Key - BaseURL string // API Base URL(可选) - Style string // 写作风格(default/suspense/fantasy/romance) + Prompt string // 用户的小说需求 + NovelName string // 小说名(用作输出目录名) + OutputDir string // 输出根目录,默认 output/{NovelName} + Provider string // LLM 提供商:openai / anthropic / gemini + ModelName string // LLM 模型名 + APIKey string // API Key + BaseURL string // API Base URL(可选) + Style string // 写作风格(default/suspense/fantasy/romance) } // Prompts 嵌入的提示词。 type Prompts struct { - Coordinator string - Architect string - Writer string - Editor string + Coordinator string + ArchitectShort string + ArchitectMid string + ArchitectLong string + Writer string + Editor string } // Validate 校验配置(CLI 模式,要求 Prompt 非空)。 diff --git a/app/run.go b/app/run.go index cd46d52..3e75c08 100644 --- a/app/run.go +++ b/app/run.go @@ -92,7 +92,10 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s return fmt.Errorf("init progress: %w", err) } log.Printf("新建模式:%s", cfg.NovelName) - promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", cfg.Prompt) + promptText := fmt.Sprintf( + "请创作一部小说,章节数量由你根据故事需要自行决定。若题材与冲突天然适合长篇连载,请优先规划为分层长篇结构,而不是压缩成短篇式梗概。要求如下:\n\n%s", + cfg.Prompt, + ) if err := coordinator.Prompt(promptText); err != nil { return fmt.Errorf("prompt: %w", err) } @@ -204,6 +207,22 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, prov }) } +func planningTierGuidance(runMeta *domain.RunMeta) string { + if runMeta == nil { + return "" + } + switch runMeta.PlanningTier { + case domain.PlanningTierShort: + return "当前规划级别:short。如需调整设定或重做大纲,优先调用 architect_short。" + case domain.PlanningTierMid: + return "当前规划级别:mid。如需调整设定或重做大纲,优先调用 architect_mid。" + case domain.PlanningTierLong: + return "当前规划级别:long。如需调整设定或重做大纲,优先调用 architect_long,并保持分层大纲的一致性。" + default: + return "" + } +} + // submitSteer 提交用户干预(CLI 和 Runtime 共用)。 func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) { log.Printf("[steer] 用户干预: %s", text) @@ -219,8 +238,17 @@ func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) if err := store.SetFlow(domain.FlowSteering); err != nil { log.Printf("[warn] 设置流程状态失败: %v", err) } + runMeta, err := store.LoadRunMeta() + if err != nil { + log.Printf("[warn] 读取运行元信息失败: %v", err) + } + guidance := planningTierGuidance(runMeta) + message := fmt.Sprintf("[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text) + if guidance != "" { + message += "\n" + guidance + } coordinator.Steer(agentcore.UserMsg(fmt.Sprintf( - "[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text))) + "%s", message))) } // recoveryResult 恢复链的判断结果。 @@ -236,14 +264,21 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov if progress == nil { return recoveryResult{IsNew: true} } + guidance := planningTierGuidance(runMeta) + withGuidance := func(prompt string) string { + if guidance == "" { + return prompt + } + return prompt + "\n" + guidance + } if progress.InProgressChapter > 0 { ch := progress.InProgressChapter scenes := len(progress.CompletedScenes) return recoveryResult{ - PromptText: fmt.Sprintf( + PromptText: withGuidance(fmt.Sprintf( "第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", - ch, scenes, scenes+1, progress.TotalChapters), + ch, scenes, scenes+1, progress.TotalChapters)), Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes), } } @@ -254,18 +289,18 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov verb = "打磨" } return recoveryResult{ - PromptText: fmt.Sprintf( + PromptText: withGuidance(fmt.Sprintf( "有 %d 章待%s(受影响章节:%v)。原因:%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。", - len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters), + len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters)), Label: fmt.Sprintf("%s恢复:%d 章待处理 %v", verb, len(progress.PendingRewrites), progress.PendingRewrites), } } if progress.Flow == domain.FlowReviewing { return recoveryResult{ - PromptText: fmt.Sprintf( + PromptText: withGuidance(fmt.Sprintf( "上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。", - len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters), + len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters)), Label: "审阅恢复:上次审阅中断", } } @@ -273,9 +308,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" { next := progress.NextChapter() return recoveryResult{ - PromptText: fmt.Sprintf( + PromptText: withGuidance(fmt.Sprintf( "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", - next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer), + next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer)), Label: "Steer 恢复:上次干预未完成,重新注入", } } @@ -283,9 +318,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov if progress.IsResumable() { next := progress.NextChapter() return recoveryResult{ - PromptText: fmt.Sprintf( + PromptText: withGuidance(fmt.Sprintf( "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。", - next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters), + next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters)), Label: fmt.Sprintf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)", next, len(progress.CompletedChapters), progress.TotalWordCount), } diff --git a/app/run_test.go b/app/run_test.go index 52c3364..5bb95b7 100644 --- a/app/run_test.go +++ b/app/run_test.go @@ -2,6 +2,7 @@ package app import ( "encoding/json" + "strings" "testing" "github.com/voocel/agentcore" @@ -122,3 +123,31 @@ func TestCreateModelUsesOpenRouterProvider(t *testing.T) { t.Fatalf("expected provider openrouter, got %q", provider) } } + +func TestDetermineRecoveryIncludesPlanningTierGuidance(t *testing.T) { + progress := &domain.Progress{ + Phase: domain.PhaseWriting, + CurrentChapter: 3, + CompletedChapters: []int{1, 2}, + TotalWordCount: 2400, + TotalChapters: 12, + } + runMeta := &domain.RunMeta{ + PlanningTier: domain.PlanningTierLong, + } + + recovery := determineRecovery(progress, runMeta) + if !strings.Contains(recovery.PromptText, "architect_long") { + t.Fatalf("expected architect_long guidance, got %q", recovery.PromptText) + } + if !strings.Contains(recovery.PromptText, "分层大纲") { + t.Fatalf("expected layered-outline guidance, got %q", recovery.PromptText) + } +} + +func TestPlanningTierGuidanceForMid(t *testing.T) { + guidance := planningTierGuidance(&domain.RunMeta{PlanningTier: domain.PlanningTierMid}) + if !strings.Contains(guidance, "architect_mid") { + t.Fatalf("expected architect_mid guidance, got %q", guidance) + } +} diff --git a/app/runtime.go b/app/runtime.go index cb702a0..16d3a99 100644 --- a/app/runtime.go +++ b/app/runtime.go @@ -198,7 +198,10 @@ func (rt *Runtime) Start(prompt string) error { return fmt.Errorf("init progress: %w", err) } - promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", prompt) + promptText := fmt.Sprintf( + "请创作一部小说,章节数量由你根据故事需要自行决定。若题材与冲突天然适合长篇连载,请优先规划为分层长篇结构,而不是压缩成短篇式梗概。要求如下:\n\n%s", + prompt, + ) if err := rt.coordinator.Prompt(promptText); err != nil { return fmt.Errorf("prompt: %w", err) } diff --git a/domain/runtime.go b/domain/runtime.go index 4ae9d66..ac841d9 100644 --- a/domain/runtime.go +++ b/domain/runtime.go @@ -22,6 +22,15 @@ const ( FlowSteering FlowState = "steering" ) +// PlanningTier 表示作品规划的长度级别。 +type PlanningTier string + +const ( + PlanningTierShort PlanningTier = "short" + PlanningTierMid PlanningTier = "mid" + PlanningTierLong PlanningTier = "long" +) + // Progress 进度追踪,持久化到 meta/progress.json。 type Progress struct { NovelName string `json:"novel_name"` @@ -34,10 +43,10 @@ type Progress struct { InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复) CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号 Flow FlowState `json:"flow,omitempty"` // 当前流程 - PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列 - RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 - StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand - HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type + PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列 + RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 + StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand + HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type // 长篇分层追踪(仅长篇模式使用,短篇/中篇为零值) CurrentVolume int `json:"current_volume,omitempty"` CurrentArc int `json:"current_arc,omitempty"` @@ -89,6 +98,7 @@ type RunMeta struct { Provider string `json:"provider,omitempty"` Style string `json:"style"` Model string `json:"model"` + PlanningTier PlanningTier `json:"planning_tier,omitempty"` SteerHistory []SteerEntry `json:"steer_history,omitempty"` PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入 } diff --git a/main.go b/main.go index ad81755..a85adb8 100644 --- a/main.go +++ b/main.go @@ -90,6 +90,8 @@ func loadReferences(style string) tools.References { Consistency: mustRead(referencesFS, "references/consistency.md"), ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"), DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.md"), + LongformPlanning: mustRead(referencesFS, "references/longform-planning.md"), + Differentiation: mustRead(referencesFS, "references/differentiation.md"), } if style != "" && style != "default" { path := "references/" + style + "/style-references.md" @@ -102,10 +104,12 @@ func loadReferences(style string) tools.References { func loadPrompts() app.Prompts { return app.Prompts{ - Coordinator: mustRead(promptsFS, "prompts/coordinator.md"), - Architect: mustRead(promptsFS, "prompts/architect.md"), - Writer: mustRead(promptsFS, "prompts/writer.md"), - Editor: mustRead(promptsFS, "prompts/editor.md"), + Coordinator: mustRead(promptsFS, "prompts/coordinator.md"), + ArchitectShort: mustRead(promptsFS, "prompts/architect-short.md"), + ArchitectMid: mustRead(promptsFS, "prompts/architect-mid.md"), + ArchitectLong: mustRead(promptsFS, "prompts/architect-long.md"), + Writer: mustRead(promptsFS, "prompts/writer.md"), + Editor: mustRead(promptsFS, "prompts/editor.md"), } } diff --git a/prompts/architect-long.md b/prompts/architect-long.md new file mode 100644 index 0000000..b2b962c --- /dev/null +++ b/prompts/architect-long.md @@ -0,0 +1,114 @@ +你是长篇规划师。你负责把用户需求规划成一个可长期展开、可持续升级、可分卷分弧推进的连载型故事。 + +## 你的工具 + +- **novel_context**: 获取参考模板和当前状态 +- **save_foundation**: 保存基础设定 + +## 适用范围 + +适用于这些情况: + +- 题材天然适合长期升级或长期连载 +- 世界观、势力、关系、身份、谜团可以持续扩展 +- 故事存在多个阶段性目标和多个中后期转向 +- 适合 80 章以上,或明显需要卷弧结构 + +长篇规划默认使用 layered_outline。不要把长篇压缩成短篇式十几章梗概。 + +## 工作流程 + +### 1. 获取模板 + +先调用 novel_context(不传 chapter 参数)获取: +- outline_template +- character_template +- longform_planning +- differentiation +- style_reference(如有) + +### 2. 生成 Premise + +基于用户需求,撰写故事前提(Markdown 格式),至少包含: + +- 题材和基调 +- 核心冲突 +- 主角目标 +- 结局方向 +- 写作禁区 +- 差异化卖点(至少 3 条) +- 故事引擎:外部推进与内部推进分别是什么 +- 升级路径:前期、中期、后期靠什么升级 +- 中期转向:前期方法何时失效,故事如何换挡 +- 终局命题:后期真正要回答的最终问题 + +调用 save_foundation(type="premise", scale="long", content=) + +### 3. 生成 Layered Outline + +长篇默认使用分层结构,生成 JSON 格式的 layered_outline: + +- 卷(Volume):阶段主题、阶段升级、阶段代价 +- 弧(Arc):局部目标、局部阻力、阶段转折 +- 章(Chapter):章节标题、核心事件、钩子、场景 + +调用 save_foundation(type="layered_outline", scale="long", content=) + +要求: + +- 前 3 卷必须各自承担不同功能,而不是重复“升级打怪换地图” +- 每卷都必须回答:新增了什么、失去了什么、关系如何变化、为何必须进入下一卷 +- 每弧都必须有明确目标、阻力、转折和结果 +- 每章都必须服务于当前弧目标 +- 中期必须有结构转向,后期必须有终局级命题 +- 钩子类型要多样化,避免全靠“发现秘密” + +### 4. 生成 Characters + +基于 premise 和 layered_outline 生成角色档案(JSON 格式),每个角色包含: +- name +- aliases +- role +- description +- arc +- traits + +要求: + +- 主要角色必须与长期故事引擎有关 +- 角色弧线要能跨卷演化 +- 重要配角不能只是阶段性工具人 +- 关系线必须具备长期张力,而不是只服务某一章剧情 + +调用 save_foundation(type="characters", scale="long", content=) + +### 5. 生成 World Rules + +基于 premise 和世界观设定,生成世界规则(JSON 格式),每条规则包含: +- category +- rule +- boundary + +要求: + +- 规则必须会持续影响剧情决策 +- 特别注意资源、代价、限制、秩序、势力边界 +- 规则要能支撑中后期升级,而不是只服务前几章 + +调用 save_foundation(type="world_rules", scale="long", content=) + +## 增量修改模式 + +当任务中提到“增量修改”时: + +1. 先调用 novel_context 获取当前 premise、outline、layered_outline、characters、world_rules +2. 保持已完成章节的一致性 +3. 保持卷弧结构稳定,避免修改后退化成短篇式节奏 +4. 若需调整长期规划,优先调整未展开卷弧 + +## 注意事项 + +- 长篇的核心是可持续展开,而不是简单变长 +- 不要过早透支所有高潮和谜底 +- 不要把同一种爽点反复复制到每一卷 +- 不要让中后期只是前期的放大版 diff --git a/prompts/architect-mid.md b/prompts/architect-mid.md new file mode 100644 index 0000000..6524b1a --- /dev/null +++ b/prompts/architect-mid.md @@ -0,0 +1,109 @@ +你是中篇规划师。你负责把用户需求规划成一个多阶段推进、篇幅受控、能够稳定展开但不过度膨胀的故事。 + +## 你的工具 + +- **novel_context**: 获取参考模板和当前状态 +- **save_foundation**: 保存基础设定 + +## 适用范围 + +适用于这些情况: + +- 有阶段性升级,但不需要超长连载 +- 有 2-4 条重要支线或关系线 +- 存在明显的中段转折与后段收束 +- 适合 25-60 章 + +如果题材明显具备长期世界扩张、长期升级、长期关系博弈、多卷结构,优先交给长篇规划师。 + +## 工作流程 + +### 1. 获取模板 + +先调用 novel_context(不传 chapter 参数)获取: +- outline_template +- character_template +- longform_planning +- differentiation +- style_reference(如有) + +### 2. 生成 Premise + +基于用户需求,撰写故事前提(Markdown 格式),至少包含: + +- 题材和基调 +- 核心冲突 +- 主角目标 +- 结局方向 +- 写作禁区 +- 差异化卖点(至少 2-3 条) +- 故事引擎:中篇靠什么持续推进 +- 中段转折:故事在哪个阶段会发生结构变化 + +调用 save_foundation(type="premise", scale="mid", content=) + +### 3. 生成 Outline + +中篇默认使用扁平 outline;只有当阶段差异很强、用户明确要求更强结构时,才考虑用 layered_outline。 + +生成章节大纲(JSON 格式),每章包含: +- chapter +- title +- core_event +- hook +- scenes(3-5 个场景) + +要求: + +- 至少划分出 3 个阶段:建立、升级、收束 +- 每个阶段的主问题要有区别 +- 中段必须出现一次改变后续推进方式的转折 +- 支线不能游离,必须服务主线或人物关系变化 + +调用 save_foundation(type="outline", scale="mid", content=) + +### 4. 生成 Characters + +基于 premise 和 outline 生成角色档案(JSON 格式),每个角色包含: +- name +- aliases +- role +- description +- arc +- traits + +要求: + +- 主要角色要承担不同功能 +- 角色弧线要跨越多个阶段,而不是一章完成 +- 配角要能反向影响主线 + +调用 save_foundation(type="characters", scale="mid", content=) + +### 5. 生成 World Rules + +基于 premise 和世界观设定,生成世界规则(JSON 格式),每条规则包含: +- category +- rule +- boundary + +要求: + +- 规则必须制造选择或代价 +- 不能只是背景百科 + +调用 save_foundation(type="world_rules", scale="mid", content=) + +## 增量修改模式 + +当任务中提到“增量修改”时: + +1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules +2. 保持已完成章节的一致性 +3. 保持中篇节奏,不要因为补设定而破坏阶段推进 + +## 注意事项 + +- 中篇的关键是阶段推进和平衡 +- 不要像短篇那样过度压缩 +- 也不要像长篇那样预留过多远期空间 diff --git a/prompts/architect-short.md b/prompts/architect-short.md new file mode 100644 index 0000000..277e5d7 --- /dev/null +++ b/prompts/architect-short.md @@ -0,0 +1,107 @@ +你是短篇规划师。你负责把用户需求规划成一个高密度、强收束、单卷完成的故事。 + +## 你的工具 + +- **novel_context**: 获取参考模板和当前状态 +- **save_foundation**: 保存基础设定 + +## 适用范围 + +只适用于这些情况: + +- 单冲突、单目标、单段关键关系 +- 单案、单任务、单次危机、单次恋爱推进 +- 故事高潮和结局集中在一个阶段完成 +- 适合 8-25 章内收束 + +如果需求明显具备长期升级空间、持续展开世界、长期关系张力或多阶段主矛盾,不要用短篇思路硬压。 + +## 工作流程 + +### 1. 获取模板 + +先调用 novel_context(不传 chapter 参数)获取: +- outline_template +- character_template +- differentiation +- style_reference(如有) + +### 2. 生成 Premise + +基于用户需求,撰写故事前提(Markdown 格式),至少包含: + +- 题材和基调 +- 核心冲突 +- 主角目标 +- 结局方向 +- 写作禁区 +- 差异化卖点(至少 2 条) +- 本作为什么适合短篇/单卷收束 + +调用 save_foundation(type="premise", scale="short", content=) + +### 3. 生成 Outline + +短篇一律使用扁平 outline,不使用 layered_outline。 + +生成章节大纲(JSON 格式),每章包含: +- chapter +- title +- core_event +- hook +- scenes(3-5 个场景) + +要求: + +- 每章都必须推动主冲突 +- 不允许“中期再慢慢展开”的拖延式设计 +- 配角数量控制在必要范围 +- 世界规则只保留会直接影响剧情的部分 +- 结局必须回收核心承诺 + +调用 save_foundation(type="outline", scale="short", content=) + +### 4. 生成 Characters + +基于 premise 和 outline 生成角色档案(JSON 格式),每个角色包含: +- name +- aliases +- role +- description +- arc +- traits + +要求: + +- 角色功能必须清晰,避免冗余 +- 主要角色弧线要在单卷内完成 + +调用 save_foundation(type="characters", scale="short", content=) + +### 5. 生成 World Rules + +基于 premise 和世界观设定,生成世界规则(JSON 格式),每条规则包含: +- category +- rule +- boundary + +要求: + +- 只保留必要规则,避免为短篇过度设计世界 +- 规则必须直接服务当前冲突 + +调用 save_foundation(type="world_rules", scale="short", content=) + +## 增量修改模式 + +当任务中提到“增量修改”时: + +1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules +2. 保持已完成章节的一致性 +3. 保持短篇结构的紧凑性,不要越改越膨胀 + +## 注意事项 + +- 短篇最重要的是集中与收束 +- 不要预埋大量未来再说的线 +- 不要把短篇写成“长篇开头” diff --git a/prompts/architect.md b/prompts/architect.md deleted file mode 100644 index fe171dc..0000000 --- a/prompts/architect.md +++ /dev/null @@ -1,148 +0,0 @@ -你是小说世界构建师。你负责从用户需求出发,构建小说的基础设定。 - -## 你的工具 - -- **novel_context**: 获取参考模板和当前状态 -- **save_foundation**: 保存基础设定 - -## 工作流程 - -### 1. 获取模板 - -先调用 novel_context(不传 chapter 参数)获取大纲模板和角色模板。 - -### 2. 生成 Premise - -基于用户需求,撰写故事前提(Markdown 格式),包含: -- 题材和基调 -- 核心冲突 -- 主角目标 -- 结局方向 -- 写作禁区(不应出现的内容) - -调用 save_foundation(type="premise", content=) - -### 3. 生成 Outline - -基于 premise 生成章节大纲(JSON 格式),每章包含: -- chapter: 章节号 -- title: 章节标题 -- core_event: 核心事件 -- hook: 章末钩子 -- scenes: 场景概述列表(3-5 个场景) - -调用 save_foundation(type="outline", content=) - -示例: -```json -[ - { - "chapter": 1, - "title": "暗夜来客", - "core_event": "主角在暴雨夜收到神秘包裹", - "hook": "包裹里是一张二十年前失踪案的照片", - "scenes": ["雨夜独处", "快递到来", "打开包裹", "照片特写"] - } -] -``` - -### 4. 生成 Characters - -基于 premise 和 outline 生成角色档案(JSON 格式),每个角色包含: -- name: 姓名 -- aliases: 别名/称号/绰号列表(正文中可能使用的其他称呼,如"废物少年"、"炎哥") -- role: 角色定位(主角/配角/反派) -- description: 外貌与性格描写 -- arc: 角色弧线(从A到B的变化) -- traits: 标签特征列表 - -调用 save_foundation(type="characters", content=) - -### 5. 生成 World Rules - -基于 premise 和世界观设定,生成世界规则(JSON 格式),每条规则包含: -- category: 规则类别(magic / technology / geography / society / other) -- rule: 规则描述 -- boundary: 不可违反的边界 - -调用 save_foundation(type="world_rules", content=) - -示例: -```json -[ - { - "category": "magic", - "rule": "法术需要消耗精神力,精神力与修炼等级成正比", - "boundary": "不存在无消耗的法术,精神力耗尽会导致昏迷" - }, - { - "category": "society", - "rule": "王国实行严格的等级制度,平民不得直视贵族", - "boundary": "没有例外,违反者会被当场处刑" - } -] -``` - -注意:不是所有小说都需要复杂的世界规则。现实题材可以只记录少量社会规则或物理限制。 - -## 增量修改模式 - -当任务中提到"增量修改"或"在现有设定基础上修改"时: - -1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules -2. 仅修改受影响的部分,保持未受影响部分不变 -3. 特别注意:已完成章节的设定不应产生矛盾 -4. 修改 outline 时,已完成章节的大纲条目保持不变(除非明确要求重写) -5. 修改 characters 时,保持角色已展示的特征不变,只调整后续发展 -6. 修改 world_rules 时,不得删除已在正文中体现的规则,只能新增或放宽边界 - -所有被修改的设定都必须用 save_foundation 保存完整版本(全量覆盖),包括 world_rules。 -未修改的设定无需重新保存。 - -## 注意事项 - -- 大纲的场景拆分要具体,不要笼统 -- 每章至少 3 个场景 -- 角色弧线要有变化,不要扁平 -- 钩子要制造悬念,吸引读者继续阅读 - -## 长篇分层大纲模式 - -当任务中提到"分层大纲"或"长篇"时,使用分层结构: - -### 生成分层大纲 -生成 JSON 格式的分层大纲,结构为 卷 → 弧 → 章节: - -```json -[ - { - "index": 1, - "title": "第一卷标题", - "theme": "本卷核心冲突/主题", - "arcs": [ - { - "index": 1, - "title": "第一弧标题", - "goal": "弧目标(起承转合)", - "chapters": [ - { - "chapter": 1, - "title": "章节标题", - "core_event": "核心事件", - "hook": "章末钩子", - "scenes": ["场景1", "场景2", "场景3"] - } - ] - } - ] - } -] -``` - -调用 save_foundation(type="layered_outline", content=) - -### 弧级规划模式 -当任务中提到"细化下一弧的章节大纲"时: -1. 调用 novel_context 获取当前分层大纲和已完成弧摘要 -2. 为指定弧生成详细的章节大纲(复用现有 OutlineEntry 格式) -3. 调用 save_foundation(type="outline") 保存更新后的完整扁平大纲 diff --git a/prompts/coordinator.md b/prompts/coordinator.md index 3d84732..16d1dbc 100644 --- a/prompts/coordinator.md +++ b/prompts/coordinator.md @@ -1,21 +1,47 @@ -你是一个长篇小说创作的总协调者。你通过调度子 Agent 完成整本小说的创作。 +你是一个小说创作总协调者。你通过调度子 Agent 完成整本小说的创作。 + +你的职责不是追求最快开写,而是先选对规划策略,再进入写作。现在有三种不同长度级别的规划师: + +- **architect_short**:短篇/单卷故事,8-25 章,高密度、强收束 +- **architect_mid**:中篇/多阶段故事,25-60 章,阶段推进、平衡展开 +- **architect_long**:长篇/连载型故事,80 章以上或明显需要分卷分弧,强调持续升级与卷弧结构 ## 你的工具 -- **subagent**: 调度 architect、writer 和 editor 子 Agent +- **subagent**: 调度 architect_short、architect_mid、architect_long、writer 和 editor 子 Agent - **novel_context**: 检查当前创作状态 ## 工作流程 -### 第一阶段:基础设定 +### 第一阶段:选择合适的规划师并生成基础设定 -调用 architect 完成基础设定: +在第一次规划前,你必须先判断用户需求更适合哪一种长度级别: + +- **短篇**:单冲突、单案、单任务、单段关键关系、结局集中 +- **中篇**:有阶段性升级、几条重要支线、需要中段转折,但不需要超长连载 +- **长篇**:题材具备持续升级空间、可扩展世界、长期关系张力、多阶段目标、多卷推进 + +选择规则: + +- 只要题材明显适合长期展开,优先使用 `architect_long` +- 只有当需求明显更像单卷故事时,才使用 `architect_short` +- 不确定时,优先 `architect_mid`,但对连载型商业题材宁可偏长,不要误压成短篇 + +调用对应规划师完成基础设定: ```json -{"agent": "architect", "task": "根据以下需求生成小说基础设定(premise、outline、characters):\n\n<用户需求>"} +{"agent": "architect_short", "task": "根据以下需求生成短篇/单卷小说基础设定。\\n\\n<用户需求>"} ``` -architect 完成后,用 novel_context 确认设定已保存。 +```json +{"agent": "architect_mid", "task": "根据以下需求生成中篇/多阶段小说基础设定。\\n\\n<用户需求>"} +``` + +```json +{"agent": "architect_long", "task": "根据以下需求生成长篇/连载型小说基础设定。\\n\\n<用户需求>"} +``` + +规划完成后,用 novel_context 确认设定已保存,再开始写作。 ### 第二阶段:逐章写作 @@ -40,17 +66,10 @@ architect 完成后,用 novel_context 确认设定已保存。 收到 `[系统] Editor 审阅结论` 消息后,按 verdict 处理: - **accept**: 继续写下一章 -- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨。每次调用: - ```json - {"agent": "writer", "task": "打磨第 N 章。审阅意见:"} - ``` -- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写。每次调用: - ```json - {"agent": "writer", "task": "重写第 N 章。重写原因:"} - ``` - 重写完成后回到正常写作流程,继续写下一个未完成章节 +- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨 +- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写 -**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。中断退出后重启会自动恢复到重写状态。 +**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。 ### 系统消息 @@ -59,7 +78,7 @@ architect 完成后,用 novel_context 确认设定已保存。 - **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer - **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅 -你必须遵守系统消息中的确定性指令(如"不要再调用 writer")。 +你必须遵守系统消息中的确定性指令。 ### 第四阶段:完成 @@ -73,42 +92,32 @@ architect 完成后,用 novel_context 确认设定已保存。 收到 `[用户干预]` 消息后: -1. **评估影响范围**:判断用户的修改要求影响哪些内容 -2. **更新设定**(如需要):调用 architect 更新 premise、outline 或 characters - ```json - {"agent": "architect", "task": "用户要求修改:<干预内容>。请在现有设定基础上做增量修改,保持已完成章节的一致性。"} - ``` -3. **重写章节**(如需要):如果已完成章节受到影响,逐章调用 writer 重写 -4. **继续写作**:从下一个未完成章节继续 +1. 评估影响范围 +2. 如需更新设定,调用与当前作品长度级别一致的规划师进行增量修改 +3. 如需重写已完成章节,逐章调用 writer 重写 +4. 从下一个未完成章节继续 + +如果当前作品已经采用 layered_outline,不要在修改时退化成短篇式 outline 思路。 ## 恢复指示 -- 收到"从第 N 章继续写作"的指示:跳过第一阶段,直接从第 N 章开始逐章写作 -- 收到"第 N 章正在进行中,已完成 M 个场景"的指示:调用 writer 从场景 M+1 继续该章写作 -- 收到"有 N 章待重写"的指示:逐章调用 writer 重写/打磨受影响章节,**全部完成后**才能继续写新章节 -- 收到"上次审阅中断"的指示:重新调用 editor 进行全局审阅 - -## 注意事项 - -- 不要自己写正文,正文由 writer 完成 -- 不要自己创建设定,设定由 architect 完成 -- 不要自己做审阅,审阅由 editor 完成 -- 你的职责是调度和决策,不是创作 -- 章节完成/全书终止的判断由宿主程序通过系统消息控制 -- 重写章节时,writer 的流程与新写相同,旧文件会自动覆盖 +- 收到“从第 N 章继续写作”的指示:跳过第一阶段,直接从第 N 章开始逐章写作 +- 收到“第 N 章正在进行中,已完成 M 个场景”的指示:调用 writer 从场景 M+1 继续该章写作 +- 收到“有 N 章待重写”的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节 +- 收到“上次审阅中断”的指示:重新调用 editor 进行全局审阅 ## 长篇模式(分层大纲) -当系统消息包含"弧结束"或"卷结束"信号时,执行以下工作流: +当系统消息包含“弧结束”或“卷结束”信号时,执行以下工作流: ### 弧结束处理 -收到 `[系统] 第 V 卷第 A 弧结束` 消息后,按消息中的步骤依次执行: -1. 调用 editor 进行弧级评审(任务中说明 scope=arc) -2. 调用 editor 生成弧摘要和角色快照(editor 会调用 save_arc_summary 工具) +收到 `[系统] 第 V 卷第 A 弧结束` 消息后: +1. 调用 editor 进行弧级评审 +2. 调用 editor 生成弧摘要和角色快照 3. 继续写下一弧的章节 ### 卷结束处理 收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后: -1. 先完成弧结束处理(弧级评审 + 弧摘要) -2. 额外调用 editor 生成卷摘要(editor 会调用 save_volume_summary 工具) +1. 先完成弧结束处理 +2. 额外调用 editor 生成卷摘要 3. 继续写下一卷的章节 diff --git a/references/differentiation.md b/references/differentiation.md new file mode 100644 index 0000000..aecbd11 --- /dev/null +++ b/references/differentiation.md @@ -0,0 +1,86 @@ +# 通用差异化设计参考 + +这份参考用于避免同题材作品自动滑向“最高频模板”。 + +## 当用户只给出一个大题材词时,不能直接套最常见范式 + +例如用户说: + +- 都市 +- 奇幻 +- 修仙 +- 悬疑 +- 言情 +- 科幻 + +这不等于“照该题材最常见的开局写”。你必须先补足差异化维度。 + +## 差异化的五个维度 + +### 1. 主角维度 + +- 出身是否过于常见 +- 初始优势/劣势是否过于常见 +- 主角最强驱动力是什么 +- 主角最大的盲区是什么 + +### 2. 冲突维度 + +- 主冲突是否只是该题材默认矛盾 +- 有没有第二层冲突改变读者预期 +- 冲突是否会在中期转型 + +### 3. 世界维度 + +- 世界规则是否真的改变角色行为 +- 社会结构、资源结构、权力结构是否能持续制造问题 +- 世界是否存在非主角视角也合理运转的逻辑 + +### 4. 关系维度 + +- 主要关系是否只有“队友/恋人/敌人”三个静态功能 +- 是否存在长期互相塑造、互相伤害、互相利用、互相成全的关系 +- 关系线是否会反向推动主线 + +### 5. 节奏维度 + +- 爽点是否单一重复 +- 是否规划了不同阶段的阅读驱动力 +- 前期吸引力和中后期吸引力是否一致,还是有自然升级 + +## 常见同质化信号 + +出现越多,越说明作品在滑向通用模板: + +- 最常见的主角出身设定 +- 最常见的“被看不起”起手 +- 最常见的导师/宗门/学院/豪门/案件开场 +- 最常见的反派动机 +- 最常见的阶段升级节奏 +- 最常见的“发现秘密”型钩子反复出现 + +## 规划时必须主动给自己设限 + +在同题材下,至少给出 2-3 条反模板约束。例如: + +- 不使用最常见的开局身份 +- 不使用最常见的金手指/能力来源 +- 不使用最常见的中期升级路径 +- 不让主要关系线停留在单一功能 +- 不让终局只是“打败更大的敌人” + +## 差异化不是猎奇,而是重新分配重心 + +有效的差异化通常来自: + +- 更换主角真正关心的东西 +- 更换长期冲突的来源 +- 更换世界规则的压力点 +- 更换关系线在故事中的功能 +- 更换中期之后的推进方式 + +## 输出前自问 + +- 如果把角色名和设定名抹掉,这个故事还像同题材里另外十本书吗? +- 如果只看前 10 章,读者能说出这本书“独特在哪”吗? +- 如果写到 50 章后,作品的推进方式会不会和前 10 章完全重复? diff --git a/references/longform-planning.md b/references/longform-planning.md new file mode 100644 index 0000000..38dade0 --- /dev/null +++ b/references/longform-planning.md @@ -0,0 +1,105 @@ +# 通用长篇规划参考 + +这份参考用于“适合长篇连载”的题材,不限定具体品类。 + +## 长篇不是把短篇拉长 + +长篇的核心不是章节更多,而是具备长期展开能力。判断一部作品能否写长,关键看它是否具备以下“故事引擎”: + +- **目标引擎**:主角会不断追求新的阶段目标 +- **世界引擎**:世界规则、势力格局、资源结构可以持续制造新问题 +- **关系引擎**:主要人物关系会持续演化,而不是定型后停滞 +- **身份引擎**:主角的位置、身份、阵营、责任会变化 +- **代价引擎**:每次成长都带来新的约束、损失或风险 + +如果这几个引擎都很弱,再多章节也只会变成重复灌水。 + +## 长篇推荐规划顺序 + +### 1. 作品卖点 + +先明确: + +- 这本书最吸引读者的承诺是什么 +- 它和同题材常见写法最不同的点是什么 +- 读者为什么愿意跟随主角走到中后期 + +### 2. 长期冲突 + +不要只有一个“终极反派”。长篇更适合多阶段冲突: + +- 近程冲突:当前生存、当前任务、当前阶段目标 +- 中程冲突:势力博弈、关系重组、身份变化 +- 远程冲突:世界真相、时代命题、终局选择 + +### 3. 卷级设计 + +每一卷至少要有一个明确功能,常见功能包括: + +- 立足 +- 扩张 +- 试错 +- 反噬 +- 失去 +- 转向 +- 收束 +- 终局 + +每卷不只升级强度,还要升级问题类型。 + +### 4. 弧级设计 + +每一弧都应该像“一个可独立成立的小故事”: + +- 有明确目标 +- 有明确阻力 +- 有阶段转折 +- 有结果与代价 + +### 5. 章节设计 + +章节不是平均分配事件,而是为弧服务: + +- 关键推进章 +- 关系变化章 +- 代价兑现章 +- 误判与反噬章 +- 转折章 +- 收束与引出下弧章 + +## 避免长篇同质化 + +### 错误做法 + +- 每一卷都只是“换地图 + 换敌人” +- 每次升级都只是“主角更强了” +- 中期仍然重复前期的爽点结构 +- 配角只在需要时出现,没有独立动机 +- 世界规则只在设定里写,剧情中不产生压力 + +### 正确做法 + +- 升级“冲突类型”,不只升级“敌人强度” +- 升级“选择代价”,不只升级“资源规模” +- 升级“关系复杂度”,不只升级“出场人数” +- 升级“命题”,不只升级“舞台大小” + +## 中期转向必须提前规划 + +很多作品前 20 章能写,50 章后就开始重复,根因是没有中期转向。 + +在规划时必须提前想清楚: + +- 第一次结构转向发生在什么时候 +- 为什么前期方法在中期失效 +- 主角到中期后必须学会什么新的思维方式 +- 中后期的核心吸引力与前期有什么不同 + +## 长篇通用检查清单 + +- 这本书是否具备至少 3 个阶段性主矛盾? +- 前 3 卷是否各自承担不同功能? +- 主角的“得到”和“失去”是否同步增长? +- 主要配角是否会改变主线,而不是只被主角改变? +- 世界规则是否真的限制了剧情决策? +- 中期转向后,作品是否仍然成立? diff --git a/references/outline-template.md b/references/outline-template.md index 9465a72..f384e37 100644 --- a/references/outline-template.md +++ b/references/outline-template.md @@ -1,47 +1,119 @@ -# [小说名称] 大纲 +# 大纲规划模板 -## 基本信息 -- **题材**:[悬疑/奇幻/言情/科幻等] -- **预计章节数**:[10-20] 章 -- **目标字数**:每章 3000-5000 字,总计 [X] 万字 -- **核心冲突**:[主角想要什么?什么阻止了他?] +本模板的作用不是把所有作品都压成固定长度,而是帮助先判断作品级别,再选择大纲粒度。 -## TODO List +## 第一步:先判断作品长度级别 -### 待创作 -- [ ] 第[X]章:[章节标题] - [核心事件] +### 短篇 / 单卷故事 -### 进行中 -- [ ] 第[X]章:[章节标题] - [核心事件] +- 适用:单冲突、单目标、角色少、结局集中 +- 参考长度:8-25 章 +- 建议格式:扁平 `outline` -### 已完成 -- [x] 第[X]章:[章节标题] - [核心事件]([字数]字) -- [x] 第[X]章:[章节标题] - [核心事件]([字数]字) +### 中篇 / 多阶段故事 -## 章节规划 +- 适用:有阶段升级、数条支线、人物关系会变化 +- 参考长度:25-60 章 +- 建议格式:扁平 `outline` 或轻量分层 -| 章节 | 标题 | 核心事件 | 悬念钩子 | 字数 | 状态 | -|-----|------|---------|---------|------|------| -| 第1章 | | | | | 待创作 | -| 第2章 | | | | | 待创作 | +### 长篇连载 / 网文型故事 -## 全书悬念线 -- **主线悬念**:[核心谜题] -- **支线悬念**:[其他悬念] -- **终极揭秘**:[最终答案] +- 适用:题材天然具备持续升级空间、长期关系张力、多个阶段目标、可扩展世界、长期谜团或长期成长线 +- 参考长度:80-200+ 章 +- 建议格式:分层 `layered_outline` -## 字数统计 -- 已完成章节数:[0] 章 -- 累计字数:[0] 字 -- 完成进度:[0]% +## 第二步:判断是否必须使用分层大纲 ---- +只要满足下面任意 2 条,就优先使用 `layered_outline`: -## 章节摘要 +- 世界观需要逐步展开,而不是一次性讲完 +- 主角成长不是一次跃迁,而是多阶段升级 +- 人物关系会在多个阶段持续变化 +- 中期和后期存在不同类型的主矛盾 +- 需要多次地图/势力/身份/目标切换 +- 题材明显更像连载型商业小说,而不是单卷故事 -### 第[X]章:[章节标题] -**摘要**:[300-500字概括本章核心内容、重要情节、人物变化、悬念揭示等] +## 第三步:长篇时不要直接做“全书章节流水账” ---- +长篇规划顺序建议是: -(后续章节摘要依次追加) +1. 作品卖点与差异化 +2. 长期故事引擎 +3. 卷级主题与升级 +4. 弧级目标与阶段转折 +5. 章节级事件与钩子 + +错误做法: + +- 先写 20 章梗概,再强行拉长 +- 每卷都重复“遇敌-变强-换地图” +- 只有主线升级,没有关系升级 +- 前期把所有大秘密透支完,中后期只能重复套路 + +## 扁平大纲模板(短/中篇) + +```json +[ + { + "chapter": 1, + "title": "章节标题", + "core_event": "本章核心事件", + "hook": "章末钩子", + "scenes": ["场景1", "场景2", "场景3"] + } +] +``` + +## 分层大纲模板(长篇) + +```json +[ + { + "index": 1, + "title": "第一卷标题", + "theme": "这一卷新增的核心矛盾/主题", + "arcs": [ + { + "index": 1, + "title": "第一弧标题", + "goal": "这一弧的局部目标、局部阻力和阶段转折", + "chapters": [ + { + "chapter": 1, + "title": "章节标题", + "core_event": "核心事件", + "hook": "章末钩子", + "scenes": ["场景1", "场景2", "场景3"] + } + ] + } + ] + } +] +``` + +## 长篇卷级检查清单 + +每一卷都要回答: + +- 这一卷新增了什么世界信息? +- 这一卷升级了什么核心矛盾? +- 这一卷让主角得到什么,也失去什么? +- 这一卷如何改变主要人物关系? +- 这一卷结束后,故事为什么必须进入下一卷? + +## 长篇弧级检查清单 + +每一弧都要回答: + +- 这条弧的明确目标是什么? +- 阻力来自谁、什么规则、什么代价? +- 转折点是什么? +- 这条弧结束后,哪些状态发生了不可逆变化? + +## 章节级检查清单 + +- 每章必须服务于所在弧的目标 +- 每章必须包含一个不可删除的事件推进 +- 钩子要多样化,不要全靠“发现秘密”一种模式 +- 前期章节不能只是在“介绍世界”,必须同步推进人物和冲突 diff --git a/state/outline.go b/state/outline.go index a02008f..ecb8c4e 100644 --- a/state/outline.go +++ b/state/outline.go @@ -77,6 +77,16 @@ func (s *Store) LoadLayeredOutline() ([]domain.VolumeOutline, error) { return volumes, nil } +// ClearLayeredOutline 清理分层大纲文件,供从长篇降级为普通大纲时使用。 +func (s *Store) ClearLayeredOutline() error { + return s.withWriteLock(func() error { + if err := s.removeFileUnlocked("layered_outline.json"); err != nil { + return err + } + return s.removeFileUnlocked("layered_outline.md") + }) +} + // GetChapterFromLayered 从分层大纲中按全局章节号查找。 func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) { volumes, err := s.LoadLayeredOutline() @@ -151,11 +161,11 @@ func (s *Store) CheckArcBoundary(chapter int) (*ArcBoundary, error) { for ci := range a.Chapters { if ch == chapter { cur = &chapterPos{ - volume: v.Index, - arc: a.Index, + volume: v.Index, + arc: a.Index, indexInArc: ci, - arcLen: len(a.Chapters), - isLastArc: ai == len(v.Arcs)-1, + arcLen: len(a.Chapters), + isLastArc: ai == len(v.Arcs)-1, } } else if cur != nil && nextVol == 0 { // 紧跟 cur 的下一章 diff --git a/state/progress.go b/state/progress.go index 822f3a2..6ce8c36 100644 --- a/state/progress.go +++ b/state/progress.go @@ -10,8 +10,14 @@ import ( // LoadProgress 读取 meta/progress.json。不存在时返回 nil。 func (s *Store) LoadProgress() (*domain.Progress, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.loadProgressUnlocked() +} + +func (s *Store) loadProgressUnlocked() (*domain.Progress, error) { var p domain.Progress - if err := s.readJSON("meta/progress.json", &p); err != nil { + if err := s.readJSONUnlocked("meta/progress.json", &p); err != nil { if os.IsNotExist(err) { return nil, nil } @@ -22,7 +28,13 @@ func (s *Store) LoadProgress() (*domain.Progress, error) { // SaveProgress 保存进度到 meta/progress.json。 func (s *Store) SaveProgress(p *domain.Progress) error { - return s.writeJSON("meta/progress.json", p) + s.mu.Lock() + defer s.mu.Unlock() + return s.saveProgressUnlocked(p) +} + +func (s *Store) saveProgressUnlocked(p *domain.Progress) error { + return s.writeJSONUnlocked("meta/progress.json", p) } // InitProgress 创建初始进度。 @@ -36,84 +48,90 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error { // SetTotalChapters 根据大纲长度设定总章节数。 func (s *Store) SetTotalChapters(n int) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - p = &domain.Progress{} - } - p.TotalChapters = n - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + p = &domain.Progress{} + } + p.TotalChapters = n + return s.saveProgressUnlocked(p) + }) } // UpdatePhase 更新创作阶段。 func (s *Store) UpdatePhase(phase domain.Phase) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - p = &domain.Progress{} - } - p.Phase = phase - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + p = &domain.Progress{} + } + p.Phase = phase + return s.saveProgressUnlocked(p) + }) } // MarkChapterComplete 标记章节完成,原子性更新进度。 // 支持重写场景:如果章节已完成,先减去旧字数再加新字数。 // hookType 和 dominantStrand 用于节奏追踪,可为空。 func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return fmt.Errorf("progress not initialized, call InitProgress first") - } - if p.ChapterWordCounts == nil { - p.ChapterWordCounts = make(map[int]int) - } - // 重写场景:减去旧字数 - if oldWC, ok := p.ChapterWordCounts[chapter]; ok { - p.TotalWordCount -= oldWC - } - p.ChapterWordCounts[chapter] = wordCount - p.TotalWordCount += wordCount - if !slices.Contains(p.CompletedChapters, chapter) { - p.CompletedChapters = append(p.CompletedChapters, chapter) - } - // 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针 - if chapter+1 > p.CurrentChapter { - p.CurrentChapter = chapter + 1 - } - p.InProgressChapter = 0 - p.CompletedScenes = nil - p.Phase = domain.PhaseWriting + 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.ChapterWordCounts == nil { + p.ChapterWordCounts = make(map[int]int) + } + // 重写场景:减去旧字数 + if oldWC, ok := p.ChapterWordCounts[chapter]; ok { + p.TotalWordCount -= oldWC + } + p.ChapterWordCounts[chapter] = wordCount + p.TotalWordCount += wordCount + if !slices.Contains(p.CompletedChapters, chapter) { + p.CompletedChapters = append(p.CompletedChapters, chapter) + } + // 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针 + if chapter+1 > p.CurrentChapter { + p.CurrentChapter = chapter + 1 + } + p.InProgressChapter = 0 + p.CompletedScenes = nil + p.Phase = domain.PhaseWriting - // 节奏追踪:按章节顺序填充 history(确保索引对齐) - if dominantStrand != "" { - for len(p.StrandHistory) < chapter-1 { - p.StrandHistory = append(p.StrandHistory, "") + // 节奏追踪:按章节顺序填充 history(确保索引对齐) + if dominantStrand != "" { + for len(p.StrandHistory) < chapter-1 { + p.StrandHistory = append(p.StrandHistory, "") + } + if len(p.StrandHistory) < chapter { + p.StrandHistory = append(p.StrandHistory, dominantStrand) + } else { + p.StrandHistory[chapter-1] = dominantStrand + } } - if len(p.StrandHistory) < chapter { - p.StrandHistory = append(p.StrandHistory, dominantStrand) - } else { - p.StrandHistory[chapter-1] = dominantStrand + if hookType != "" { + for len(p.HookHistory) < chapter-1 { + p.HookHistory = append(p.HookHistory, "") + } + if len(p.HookHistory) < chapter { + p.HookHistory = append(p.HookHistory, hookType) + } else { + p.HookHistory[chapter-1] = hookType + } } - } - if hookType != "" { - for len(p.HookHistory) < chapter-1 { - p.HookHistory = append(p.HookHistory, "") - } - if len(p.HookHistory) < chapter { - p.HookHistory = append(p.HookHistory, hookType) - } else { - p.HookHistory[chapter-1] = hookType - } - } - return s.SaveProgress(p) + return s.saveProgressUnlocked(p) + }) } // MarkComplete 标记全书创作完成。 @@ -142,36 +160,40 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) { // MarkSceneComplete 标记场景完成,用于场景级 checkpoint。 // 切换到不同章节时自动清空旧的 CompletedScenes。 func (s *Store) MarkSceneComplete(chapter, scene int) error { - p, err := s.LoadProgress() - 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.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("progress not initialized, call InitProgress first") + } + // 章节切换:清空旧场景列表 + if p.InProgressChapter != chapter { + p.CompletedScenes = nil + } + p.InProgressChapter = chapter + if !slices.Contains(p.CompletedScenes, scene) { + p.CompletedScenes = append(p.CompletedScenes, scene) + } + return s.saveProgressUnlocked(p) + }) } // ClearInProgress 清除场景级进度状态(章节提交后调用)。 func (s *Store) ClearInProgress() error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - p.InProgressChapter = 0 - p.CompletedScenes = nil - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return nil + } + p.InProgressChapter = 0 + p.CompletedScenes = nil + return s.saveProgressUnlocked(p) + }) } // ClearLastCommit 清除 commit 信号文件,防止重复消费。 @@ -181,95 +203,107 @@ func (s *Store) ClearLastCommit() error { // UpdateVolumeArc 更新当前卷弧位置。 func (s *Store) UpdateVolumeArc(volume, arc int) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - p.CurrentVolume = volume - p.CurrentArc = arc - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return nil + } + p.CurrentVolume = volume + p.CurrentArc = arc + return s.saveProgressUnlocked(p) + }) } // SetLayered 设置分层模式标志。 func (s *Store) SetLayered(layered bool) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - p.Layered = layered - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return nil + } + p.Layered = layered + return s.saveProgressUnlocked(p) + }) } // SetFlow 更新当前流程状态。 func (s *Store) SetFlow(flow domain.FlowState) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - p.Flow = flow - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return nil + } + p.Flow = flow + return s.saveProgressUnlocked(p) + }) } // SetPendingRewrites 设置待重写章节队列和原因。 func (s *Store) SetPendingRewrites(chapters []int, reason string) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - p.PendingRewrites = chapters - p.RewriteReason = reason - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return nil + } + p.PendingRewrites = chapters + p.RewriteReason = reason + return s.saveProgressUnlocked(p) + }) } // CompleteRewrite 从待重写队列中移除已完成的章节。 // 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。 func (s *Store) CompleteRewrite(chapter int) error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - var remaining []int - for _, ch := range p.PendingRewrites { - if ch != chapter { - remaining = append(remaining, ch) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err } - } - p.PendingRewrites = remaining - if len(remaining) == 0 { - p.Flow = domain.FlowWriting - p.RewriteReason = "" - } - return s.SaveProgress(p) + if p == nil { + return nil + } + var remaining []int + for _, ch := range p.PendingRewrites { + if ch != chapter { + remaining = append(remaining, ch) + } + } + p.PendingRewrites = remaining + if len(remaining) == 0 { + p.Flow = domain.FlowWriting + p.RewriteReason = "" + } + return s.saveProgressUnlocked(p) + }) } // ClearPendingRewrites 强制清空重写队列。 func (s *Store) ClearPendingRewrites() error { - p, err := s.LoadProgress() - if err != nil { - return err - } - if p == nil { - return nil - } - p.PendingRewrites = nil - p.RewriteReason = "" - p.Flow = domain.FlowWriting - return s.SaveProgress(p) + return s.withWriteLock(func() error { + p, err := s.loadProgressUnlocked() + if err != nil { + return err + } + if p == nil { + return nil + } + p.PendingRewrites = nil + p.RewriteReason = "" + p.Flow = domain.FlowWriting + return s.saveProgressUnlocked(p) + }) } // ValidateChapterCommit 校验当前章节是否允许提交。 diff --git a/state/run_meta.go b/state/run_meta.go index c7f8b7b..2c0dd48 100644 --- a/state/run_meta.go +++ b/state/run_meta.go @@ -10,13 +10,21 @@ import ( // SaveRunMeta 保存运行元信息到 meta/run.json。 func (s *Store) SaveRunMeta(meta domain.RunMeta) error { - return s.writeJSON("meta/run.json", meta) + s.mu.Lock() + defer s.mu.Unlock() + return s.saveRunMetaUnlocked(meta) } // LoadRunMeta 读取运行元信息。 func (s *Store) LoadRunMeta() (*domain.RunMeta, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.loadRunMetaUnlocked() +} + +func (s *Store) loadRunMetaUnlocked() (*domain.RunMeta, error) { var meta domain.RunMeta - if err := s.readJSON("meta/run.json", &meta); err != nil { + if err := s.readJSONUnlocked("meta/run.json", &meta); err != nil { if os.IsNotExist(err) { return nil, nil } @@ -25,59 +33,90 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) { return &meta, nil } +func (s *Store) saveRunMetaUnlocked(meta domain.RunMeta) error { + return s.writeJSONUnlocked("meta/run.json", meta) +} + // InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。 func (s *Store) InitRunMeta(style, provider, model string) error { - existing, _ := s.LoadRunMeta() - meta := domain.RunMeta{ - StartedAt: time.Now().Format(time.RFC3339), - Provider: provider, - Style: style, - Model: model, - } - if existing != nil { - meta.SteerHistory = existing.SteerHistory - meta.PendingSteer = existing.PendingSteer - } - return s.SaveRunMeta(meta) + return s.withWriteLock(func() error { + existing, err := s.loadRunMetaUnlocked() + if err != nil { + return err + } + meta := domain.RunMeta{ + StartedAt: time.Now().Format(time.RFC3339), + Provider: provider, + Style: style, + Model: model, + } + if existing != nil { + meta.SteerHistory = existing.SteerHistory + meta.PendingSteer = existing.PendingSteer + meta.PlanningTier = existing.PlanningTier + } + return s.saveRunMetaUnlocked(meta) + }) } // AppendSteerEntry 追加用户干预记录到 meta/run.json。 func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error { - meta, err := s.LoadRunMeta() - if err != nil { - return err - } - if meta == nil { - meta = &domain.RunMeta{} - } - meta.SteerHistory = append(meta.SteerHistory, entry) - return s.SaveRunMeta(*meta) + return s.withWriteLock(func() error { + meta, err := s.loadRunMetaUnlocked() + if err != nil { + return err + } + if meta == nil { + meta = &domain.RunMeta{} + } + meta.SteerHistory = append(meta.SteerHistory, entry) + return s.saveRunMetaUnlocked(*meta) + }) } // SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。 func (s *Store) SetPendingSteer(input string) error { - meta, err := s.LoadRunMeta() - if err != nil { - return err - } - if meta == nil { - meta = &domain.RunMeta{} - } - meta.PendingSteer = input - return s.SaveRunMeta(*meta) + return s.withWriteLock(func() error { + meta, err := s.loadRunMetaUnlocked() + if err != nil { + return err + } + if meta == nil { + meta = &domain.RunMeta{} + } + meta.PendingSteer = input + return s.saveRunMetaUnlocked(*meta) + }) } // ClearPendingSteer 清除已处理的 Steer 指令。 func (s *Store) ClearPendingSteer() error { - meta, err := s.LoadRunMeta() - if err != nil { - return err - } - if meta == nil || meta.PendingSteer == "" { - return nil - } - meta.PendingSteer = "" - return s.SaveRunMeta(*meta) + return s.withWriteLock(func() error { + meta, err := s.loadRunMetaUnlocked() + if err != nil { + return err + } + if meta == nil || meta.PendingSteer == "" { + return nil + } + meta.PendingSteer = "" + return s.saveRunMetaUnlocked(*meta) + }) +} + +// SetPlanningTier 记录当前作品采用的规划级别。 +func (s *Store) SetPlanningTier(tier domain.PlanningTier) error { + return s.withWriteLock(func() error { + meta, err := s.loadRunMetaUnlocked() + if err != nil { + return err + } + if meta == nil { + meta = &domain.RunMeta{} + } + meta.PlanningTier = tier + return s.saveRunMetaUnlocked(*meta) + }) } // SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。 diff --git a/state/run_meta_test.go b/state/run_meta_test.go index 5ab7d13..b5b62b7 100644 --- a/state/run_meta_test.go +++ b/state/run_meta_test.go @@ -1,7 +1,9 @@ package state import ( + "fmt" "os" + "sync" "testing" "github.com/voocel/ainovel-cli/domain" @@ -77,6 +79,52 @@ func TestAppendSteerEntry(t *testing.T) { } } +func TestAppendSteerEntryConcurrent(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + const workers = 32 + var wg sync.WaitGroup + start := make(chan struct{}) + + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + entry := domain.SteerEntry{ + Input: fmt.Sprintf("steer-%02d", i), + Timestamp: fmt.Sprintf("ts-%02d", i), + } + if err := store.AppendSteerEntry(entry); err != nil { + t.Errorf("AppendSteerEntry(%d): %v", i, err) + } + }(i) + } + + close(start) + wg.Wait() + + meta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if meta == nil { + t.Fatal("expected run meta to exist") + } + if len(meta.SteerHistory) != workers { + t.Fatalf("expected %d steer entries, got %d", workers, len(meta.SteerHistory)) + } + + seen := make(map[string]struct{}, workers) + for _, entry := range meta.SteerHistory { + seen[entry.Input] = struct{}{} + } + if len(seen) != workers { + t.Fatalf("expected %d unique steer entries, got %d", workers, len(seen)) + } +} + func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) { dir := t.TempDir() store := NewStore(dir) @@ -165,6 +213,26 @@ func TestSetAndClearPendingSteer(t *testing.T) { } } +func TestSetPlanningTier(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + if err := store.SetPlanningTier(domain.PlanningTierLong); err != nil { + t.Fatalf("SetPlanningTier: %v", err) + } + + meta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if meta == nil { + t.Fatal("expected run meta to exist") + } + if meta.PlanningTier != domain.PlanningTierLong { + t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierLong, meta.PlanningTier) + } +} + func TestClearPendingSteer_Noop(t *testing.T) { dir := t.TempDir() store := NewStore(dir) diff --git a/state/store.go b/state/store.go index ff25655..095d490 100644 --- a/state/store.go +++ b/state/store.go @@ -5,11 +5,13 @@ import ( "fmt" "os" "path/filepath" + "sync" ) // Store 封装小说输出目录,提供所有状态读写操作。 type Store struct { dir string + mu sync.RWMutex } // NewStore 创建状态管理器,dir 为小说输出根目录。 @@ -36,19 +38,61 @@ func (s *Store) path(rel string) string { } func (s *Store) readFile(rel string) ([]byte, error) { - return os.ReadFile(s.path(rel)) + s.mu.RLock() + defer s.mu.RUnlock() + return s.readFileUnlocked(rel) } func (s *Store) writeFile(rel string, data []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeFileUnlocked(rel, data) +} + +func (s *Store) readFileUnlocked(rel string) ([]byte, error) { + return os.ReadFile(s.path(rel)) +} + +func (s *Store) writeFileUnlocked(rel string, data []byte) error { p := s.path(rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } - return os.WriteFile(p, data, 0o644) + tmp, err := os.CreateTemp(filepath.Dir(p), filepath.Base(p)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, p) } func (s *Store) readJSON(rel string, v any) error { - data, err := s.readFile(rel) + s.mu.RLock() + defer s.mu.RUnlock() + return s.readJSONUnlocked(rel, v) +} + +func (s *Store) readJSONUnlocked(rel string, v any) error { + data, err := s.readFileUnlocked(rel) if err != nil { return err } @@ -56,21 +100,41 @@ func (s *Store) readJSON(rel string, v any) error { } func (s *Store) writeJSON(rel string, v any) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeJSONUnlocked(rel, v) +} + +func (s *Store) writeJSONUnlocked(rel string, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err } - return s.writeFile(rel, data) + return s.writeFileUnlocked(rel, data) } func (s *Store) writeMarkdown(rel string, content string) error { - return s.writeFile(rel, []byte(content)) + s.mu.Lock() + defer s.mu.Unlock() + return s.writeFileUnlocked(rel, []byte(content)) } func (s *Store) removeFile(rel string) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.removeFileUnlocked(rel) +} + +func (s *Store) removeFileUnlocked(rel string) error { err := os.Remove(s.path(rel)) if os.IsNotExist(err) { return nil } return err } + +func (s *Store) withWriteLock(fn func() error) error { + s.mu.Lock() + defer s.mu.Unlock() + return fn() +} diff --git a/tools/novel_context.go b/tools/novel_context.go index 85b2bda..7d112f5 100644 --- a/tools/novel_context.go +++ b/tools/novel_context.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "github.com/voocel/agentcore/schema" @@ -25,7 +26,9 @@ type References struct { ContentExpansion string DialogueWriting string // V2 - StyleReference string // 风格补充参考(可为空) + StyleReference string // 风格补充参考(可为空) + LongformPlanning string // 通用长篇规划参考 + Differentiation string // 通用差异化设计参考 } // ContextTool 组装当前章节所需上下文。 @@ -60,22 +63,47 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw } result := make(map[string]any) + var warnings []string + seenWarnings := make(map[string]struct{}) + warn := func(scope string, err error) { + if err == nil || os.IsNotExist(err) { + return + } + msg := fmt.Sprintf("%s 读取失败: %v", scope, err) + if _, ok := seenWarnings[msg]; ok { + return + } + seenWarnings[msg] = struct{}{} + warnings = append(warnings, msg) + } // 加载基础设定 if premise, err := t.store.LoadPremise(); err == nil && premise != "" { result["premise"] = premise + } else { + warn("premise", err) } if outline, err := t.store.LoadOutline(); err == nil && outline != nil { result["outline"] = outline + } else { + warn("outline", err) } if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 { result["world_rules"] = rules + } else { + warn("world_rules", err) } if a.Chapter > 0 { // 根据总章节数计算上下文策略 profile := domain.NewContextProfile(0) - progress, _ := t.store.LoadProgress() + progress, err := t.store.LoadProgress() + warn("progress", err) + runMeta, err := t.store.LoadRunMeta() + warn("run_meta", err) + if runMeta != nil && runMeta.PlanningTier != "" { + result["planning_tier"] = runMeta.PlanningTier + } if progress != nil && progress.TotalChapters > 0 { profile = domain.NewContextProfile(progress.TotalChapters) } @@ -86,26 +114,32 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw // 角色加载:Layered 模式优先用快照,回退到原始设定 if profile.Layered { - t.loadLayeredCharacters(result, a.Chapter) + t.loadLayeredCharacters(result, a.Chapter, warn) } else { - t.loadFilteredCharacters(result, a.Chapter) + t.loadFilteredCharacters(result, a.Chapter, warn) } // Writer/Editor 模式:加载章节相关上下文 if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { result["current_chapter_outline"] = entry + } else { + warn("current_chapter_outline", err) } // 摘要加载:分层 vs 扁平 if profile.Layered { - t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow) + t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow, warn) } else if profile.FullContext { if summaries, err := t.store.LoadAllSummaries(a.Chapter); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries + } else { + warn("recent_summaries", err) } } else { if summaries, err := t.store.LoadRecentSummaries(a.Chapter, profile.SummaryWindow); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries + } else { + warn("recent_summaries", err) } } @@ -113,29 +147,41 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw if profile.FullContext { if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { result["timeline"] = timeline + } else { + warn("timeline", err) } } else { if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 { result["timeline"] = timeline + } else { + warn("timeline", err) } } // foreshadow:短篇全量,否则只取未回收条目 if profile.FullContext { if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 { result["foreshadow_ledger"] = foreshadow + } else { + warn("foreshadow_ledger", err) } } else { if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 { result["foreshadow_ledger"] = foreshadow + } else { + warn("foreshadow_ledger", err) } } // relationships:保持全量(pair-key 去重,数据量天然可控) if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 { result["relationship_state"] = relationships + } else { + warn("relationship_state", err) } // 状态变化:最近 5 章的角色/实体状态变化 if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 5); err == nil && len(changes) > 0 { result["recent_state_changes"] = changes + } else { + warn("recent_state_changes", err) } // Layered 模式:注入当前卷弧位置 + 弧目标/卷主题 @@ -159,6 +205,8 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw break } } + } else { + warn("layered_outline", err) } result["position"] = pos } @@ -180,26 +228,42 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw // 加载已有的章节规划(支持场景恢复跳过已完成场景) if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil { result["chapter_plan"] = plan + } else { + warn("chapter_plan", err) } // 写作参考资料分阶段加载 result["references"] = t.writerReferences(a.Chapter) } else { + runMeta, err := t.store.LoadRunMeta() + warn("run_meta", err) + if runMeta != nil && runMeta.PlanningTier != "" { + result["planning_tier"] = runMeta.PlanningTier + } // Architect 模式:全量角色 + 模板 if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { result["characters"] = chars + } else { + warn("characters", err) } // Architect 模式下也加载分层大纲(弧级规划需要看全貌) if layered, err := t.store.LoadLayeredOutline(); err == nil && len(layered) > 0 { result["layered_outline"] = layered + } else { + warn("layered_outline", err) } // 加载已有的弧摘要(弧级规划时需要参考前续弧的内容) if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 { result["volume_summaries"] = volSummaries + } else { + warn("volume_summaries", err) } result["references"] = t.architectReferences() } + if len(warnings) > 0 { + result["_warnings"] = warnings + } result["_loading_summary"] = buildLoadingSummary(result, a.Chapter) return json.Marshal(result) } @@ -213,6 +277,9 @@ func buildLoadingSummary(result map[string]any, chapter int) string { } else { parts = append(parts, "architect") } + if tier, ok := result["planning_tier"].(domain.PlanningTier); ok && tier != "" { + parts = append(parts, fmt.Sprintf("tier=%s", tier)) + } // 卷弧位置 if pos, ok := result["position"].(map[string]any); ok { @@ -272,6 +339,9 @@ func buildLoadingSummary(result map[string]any, chapter int) string { if refs, ok := result["references"].(map[string]string); ok && len(refs) > 0 { items = append(items, fmt.Sprintf("参考:%d项", len(refs))) } + if warnings, ok := result["_warnings"].([]string); ok && len(warnings) > 0 { + items = append(items, fmt.Sprintf("告警:%d", len(warnings))) + } if len(items) > 0 { parts = append(parts, strings.Join(items, " ")) @@ -309,15 +379,20 @@ func sliceLen(v any) int { // loadFilteredCharacters 按 Tier 和场景出场过滤角色。 // core/important 始终返回;secondary/decorative 只在当前章节大纲提及时返回。 -func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int) { +func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int, warn func(string, error)) { chars, err := t.store.LoadCharacters() - if err != nil || len(chars) == 0 { + if err != nil { + warn("characters", err) + return + } + if len(chars) == 0 { return } // 获取当前章节大纲的场景描述,用于匹配次要角色 entry, err := t.store.GetChapterOutline(chapter) if err != nil { + warn("current_chapter_outline", err) result["characters"] = chars return } @@ -351,12 +426,15 @@ func matchCharacter(text string, c domain.Character) bool { } // loadLayeredSummaries 分层摘要加载:卷摘要 + 当前卷弧摘要 + 弧内章摘要。 -func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summaryWindow int) { +func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summaryWindow int, warn func(string, error)) { vol, arc, err := t.store.LocateChapter(chapter) if err != nil { + warn("layered_outline_position", err) // 回退到扁平模式 if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries + } else { + warn("recent_summaries", err) } return } @@ -364,6 +442,8 @@ func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summa // 1. 已完成卷的卷摘要 if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 { result["volume_summaries"] = volSummaries + } else { + warn("volume_summaries", err) } // 2. 当前卷内已完成弧的弧摘要(不含当前弧) @@ -377,25 +457,30 @@ func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summa if len(prior) > 0 { result["arc_summaries"] = prior } + } else { + warn("arc_summaries", err) } // 3. 当前弧内最近 N 章的章摘要 if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries + } else { + warn("recent_summaries", err) } } // loadLayeredCharacters Layered 模式下的角色加载:优先用最近快照,回退到原始设定 + Tier 过滤。 -func (t *ContextTool) loadLayeredCharacters(result map[string]any, chapter int) { +func (t *ContextTool) loadLayeredCharacters(result map[string]any, chapter int, warn func(string, error)) { snapshots, err := t.store.LoadLatestSnapshots() if err == nil && len(snapshots) > 0 { result["character_snapshots"] = snapshots // 同时保留原始设定中的 core/important 角色(快照可能不含新登场角色) - t.loadFilteredCharacters(result, chapter) + t.loadFilteredCharacters(result, chapter, warn) return } + warn("character_snapshots", err) // 无快照时回退到原始设定 - t.loadFilteredCharacters(result, chapter) + t.loadFilteredCharacters(result, chapter, warn) } // writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。 @@ -431,6 +516,9 @@ func (t *ContextTool) architectReferences() map[string]string { } add("outline_template", t.refs.OutlineTemplate) add("character_template", t.refs.CharacterTemplate) + add("longform_planning", t.refs.LongformPlanning) + add("differentiation", t.refs.Differentiation) + add("style_reference", t.refs.StyleReference) return refs } diff --git a/tools/novel_context_test.go b/tools/novel_context_test.go new file mode 100644 index 0000000..6c6880d --- /dev/null +++ b/tools/novel_context_test.go @@ -0,0 +1,67 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/voocel/ainovel-cli/state" +) + +func TestContextToolReportsWarningsForCorruptedState(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + if err := store.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + if err := os.WriteFile(filepath.Join(dir, "outline.json"), []byte("{invalid"), 0o644); err != nil { + t.Fatalf("write outline.json: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "meta", "progress.json"), []byte("{invalid"), 0o644); err != nil { + t.Fatalf("write progress.json: %v", err) + } + + tool := NewContextTool(store, References{}, "default") + args, err := json.Marshal(map[string]any{"chapter": 2}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + var payload struct { + Warnings []string `json:"_warnings"` + Summary string `json:"_loading_summary"` + } + if err := json.Unmarshal(result, &payload); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if len(payload.Warnings) == 0 { + t.Fatal("expected context warnings for corrupted files") + } + if !containsWarning(payload.Warnings, "outline") { + t.Fatalf("expected outline warning, got %v", payload.Warnings) + } + if !containsWarning(payload.Warnings, "progress") { + t.Fatalf("expected progress warning, got %v", payload.Warnings) + } + if !strings.Contains(payload.Summary, "告警:") { + t.Fatalf("expected loading summary to contain warning count, got %q", payload.Summary) + } +} + +func containsWarning(warnings []string, key string) bool { + for _, warning := range warnings { + if strings.Contains(warning, key) { + return true + } + } + return false +} diff --git a/tools/save_foundation.go b/tools/save_foundation.go index 59edc61..1b00f9f 100644 --- a/tools/save_foundation.go +++ b/tools/save_foundation.go @@ -21,7 +21,7 @@ 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 数组" + return "保存小说基础设定。type=premise 时 content 为 Markdown;type=outline 时 content 为 JSON 数组;type=characters 时 content 为 JSON 数组;type=world_rules 时 content 为 JSON 数组。scale 可选,用于记录 short/mid/long 规划级别" } func (t *SaveFoundationTool) Label() string { return "保存设定" } @@ -29,6 +29,7 @@ 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("scale", schema.Enum("规划级别", "short", "mid", "long")), ) } @@ -36,10 +37,21 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j var a struct { Type string `json:"type"` Content string `json:"content"` + Scale string `json:"scale"` } if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("invalid args: %w", err) } + if a.Scale != "" { + switch domain.PlanningTier(a.Scale) { + case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong: + default: + return nil, fmt.Errorf("invalid scale %q, expected short/mid/long", a.Scale) + } + if err := t.store.SetPlanningTier(domain.PlanningTier(a.Scale)); err != nil { + return nil, fmt.Errorf("save planning tier: %w", err) + } + } switch a.Type { case "premise": @@ -47,7 +59,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j return nil, fmt.Errorf("save premise: %w", err) } _ = t.store.UpdatePhase(domain.PhasePremise) - return json.Marshal(map[string]any{"saved": true, "type": "premise"}) + return json.Marshal(map[string]any{"saved": true, "type": "premise", "scale": a.Scale}) case "outline": var entries []domain.OutlineEntry @@ -60,7 +72,12 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j _ = t.store.UpdatePhase(domain.PhaseOutline) // 根据大纲长度自动设定总章节数 _ = t.store.SetTotalChapters(len(entries)) - return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)}) + if domain.PlanningTier(a.Scale) != domain.PlanningTierLong { + _ = t.store.SetLayered(false) + _ = t.store.UpdateVolumeArc(0, 0) + _ = t.store.ClearLayeredOutline() + } + return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries), "scale": a.Scale}) case "layered_outline": var volumes []domain.VolumeOutline @@ -85,6 +102,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j return json.Marshal(map[string]any{ "saved": true, "type": "layered_outline", "volumes": len(volumes), "chapters": total, + "scale": a.Scale, }) case "characters": @@ -95,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j if err := t.store.SaveCharacters(chars); err != nil { return nil, fmt.Errorf("save characters: %w", err) } - return json.Marshal(map[string]any{"saved": true, "type": "characters", "count": len(chars)}) + return json.Marshal(map[string]any{"saved": true, "type": "characters", "count": len(chars), "scale": a.Scale}) case "world_rules": var rules []domain.WorldRule @@ -105,7 +123,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j if err := t.store.SaveWorldRules(rules); err != nil { return nil, fmt.Errorf("save world_rules: %w", err) } - return json.Marshal(map[string]any{"saved": true, "type": "world_rules", "count": len(rules)}) + return json.Marshal(map[string]any{"saved": true, "type": "world_rules", "count": len(rules), "scale": a.Scale}) default: return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type) diff --git a/tools/save_foundation_test.go b/tools/save_foundation_test.go new file mode 100644 index 0000000..b542320 --- /dev/null +++ b/tools/save_foundation_test.go @@ -0,0 +1,113 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" + + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +func TestSaveFoundationPersistsPlanningTier(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": "premise", + "content": "# Premise\n\n测试", + "scale": "long", + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + if _, err := tool.Execute(context.Background(), args); err != nil { + t.Fatalf("Execute: %v", err) + } + + meta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if meta == nil { + t.Fatal("expected run meta to exist") + } + if meta.PlanningTier != domain.PlanningTierLong { + t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierLong, meta.PlanningTier) + } +} + +func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + if err := store.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := store.InitProgress("test", 0); err != nil { + t.Fatalf("InitProgress: %v", err) + } + + tool := NewSaveFoundationTool(store) + + layeredArgs, err := json.Marshal(map[string]any{ + "type": "layered_outline", + "content": `[{"index":1,"title":"第一卷","theme":"主题","arcs":[{"index":1,"title":"第一弧","goal":"目标","chapters":[{"chapter":1,"title":"第一章","core_event":"开局","hook":"继续"}]}]}]`, + "scale": "long", + }) + if err != nil { + t.Fatalf("Marshal layered args: %v", err) + } + if _, err := tool.Execute(context.Background(), layeredArgs); err != nil { + t.Fatalf("Execute layered outline: %v", err) + } + + outlineArgs, err := json.Marshal(map[string]any{ + "type": "outline", + "content": `[{"chapter":1,"title":"第一章","core_event":"改为中篇","hook":"继续"}]`, + "scale": "mid", + }) + if err != nil { + t.Fatalf("Marshal outline args: %v", err) + } + if _, err := tool.Execute(context.Background(), outlineArgs); err != nil { + t.Fatalf("Execute outline: %v", err) + } + + progress, err := store.LoadProgress() + if err != nil { + t.Fatalf("LoadProgress: %v", err) + } + if progress == nil { + t.Fatal("expected progress to exist") + } + if progress.Layered { + t.Fatal("expected layered mode to be disabled") + } + if progress.CurrentVolume != 0 || progress.CurrentArc != 0 { + t.Fatalf("expected volume/arc reset, got volume=%d arc=%d", progress.CurrentVolume, progress.CurrentArc) + } + + volumes, err := store.LoadLayeredOutline() + if err != nil { + t.Fatalf("LoadLayeredOutline: %v", err) + } + if len(volumes) != 0 { + t.Fatalf("expected layered outline cleared, got %d volumes", len(volumes)) + } + + meta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if meta == nil { + t.Fatal("expected run meta to exist") + } + if meta.PlanningTier != domain.PlanningTierMid { + t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier) + } +}