perf: 拆分规划策略

This commit is contained in:
voocel
2026-03-13 00:19:21 +08:00
parent 16e790a372
commit 7488198461
24 changed files with 1543 additions and 487 deletions

View File

@@ -44,15 +44,33 @@ func BuildCoordinator(
tools.NewSaveVolumeSummaryTool(store), tools.NewSaveVolumeSummaryTool(store),
} }
architect := agentcore.SubAgentConfig{ architectShort := agentcore.SubAgentConfig{
Name: "architect", Name: "architect_short",
Description: "世界构建师:生成小说前提、大纲和角色档案", Description: "短篇规划师:为单卷、单冲突、高密度故事生成紧凑设定与扁平大纲",
Model: model, Model: model,
SystemPrompt: prompts.Architect, SystemPrompt: prompts.ArchitectShort,
Tools: architectTools, Tools: architectTools,
MaxTurns: 10, 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 // 动态拼接风格指令到 Writer prompt
writerPrompt := prompts.Writer writerPrompt := prompts.Writer
if style, ok := styles[cfg.Style]; ok { if style, ok := styles[cfg.Style]; ok {
@@ -77,7 +95,7 @@ func BuildCoordinator(
MaxTurns: 10, MaxTurns: 10,
} }
subagentTool := agentcore.NewSubAgentTool(architect, writer, editor) subagentTool := agentcore.NewSubAgentTool(architectShort, architectMid, architectLong, writer, editor)
agent := agentcore.NewAgent( agent := agentcore.NewAgent(
agentcore.WithModel(model), agentcore.WithModel(model),

View File

@@ -7,22 +7,24 @@ import (
// Config 小说应用配置。 // Config 小说应用配置。
type Config struct { type Config struct {
Prompt string // 用户的小说需求 Prompt string // 用户的小说需求
NovelName string // 小说名(用作输出目录名) NovelName string // 小说名(用作输出目录名)
OutputDir string // 输出根目录,默认 output/{NovelName} OutputDir string // 输出根目录,默认 output/{NovelName}
Provider string // LLM 提供商openai / anthropic / gemini Provider string // LLM 提供商openai / anthropic / gemini
ModelName string // LLM 模型名 ModelName string // LLM 模型名
APIKey string // API Key APIKey string // API Key
BaseURL string // API Base URL可选 BaseURL string // API Base URL可选
Style string // 写作风格default/suspense/fantasy/romance Style string // 写作风格default/suspense/fantasy/romance
} }
// Prompts 嵌入的提示词。 // Prompts 嵌入的提示词。
type Prompts struct { type Prompts struct {
Coordinator string Coordinator string
Architect string ArchitectShort string
Writer string ArchitectMid string
Editor string ArchitectLong string
Writer string
Editor string
} }
// Validate 校验配置CLI 模式,要求 Prompt 非空)。 // Validate 校验配置CLI 模式,要求 Prompt 非空)。

View File

@@ -92,7 +92,10 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
return fmt.Errorf("init progress: %w", err) return fmt.Errorf("init progress: %w", err)
} }
log.Printf("新建模式:%s", cfg.NovelName) 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 { if err := coordinator.Prompt(promptText); err != nil {
return fmt.Errorf("prompt: %w", err) 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 共用)。 // submitSteer 提交用户干预CLI 和 Runtime 共用)。
func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) { func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) {
log.Printf("[steer] 用户干预: %s", text) 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 { if err := store.SetFlow(domain.FlowSteering); err != nil {
log.Printf("[warn] 设置流程状态失败: %v", err) 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( coordinator.Steer(agentcore.UserMsg(fmt.Sprintf(
"[用户干预] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。", text))) "%s", message)))
} }
// recoveryResult 恢复链的判断结果。 // recoveryResult 恢复链的判断结果。
@@ -236,14 +264,21 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress == nil { if progress == nil {
return recoveryResult{IsNew: true} return recoveryResult{IsNew: true}
} }
guidance := planningTierGuidance(runMeta)
withGuidance := func(prompt string) string {
if guidance == "" {
return prompt
}
return prompt + "\n" + guidance
}
if progress.InProgressChapter > 0 { if progress.InProgressChapter > 0 {
ch := progress.InProgressChapter ch := progress.InProgressChapter
scenes := len(progress.CompletedScenes) scenes := len(progress.CompletedScenes)
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", "第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。",
ch, scenes, scenes+1, progress.TotalChapters), ch, scenes, scenes+1, progress.TotalChapters)),
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes), Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes),
} }
} }
@@ -254,18 +289,18 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
verb = "打磨" verb = "打磨"
} }
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"有 %d 章待%s受影响章节%v。原因%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。", "有 %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), Label: fmt.Sprintf("%s恢复%d 章待处理 %v", verb, len(progress.PendingRewrites), progress.PendingRewrites),
} }
} }
if progress.Flow == domain.FlowReviewing { if progress.Flow == domain.FlowReviewing {
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。", "上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。",
len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters), len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters)),
Label: "审阅恢复:上次审阅中断", Label: "审阅恢复:上次审阅中断",
} }
} }
@@ -273,9 +308,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" { if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" {
next := progress.NextChapter() next := progress.NextChapter()
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。", "从第 %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 恢复:上次干预未完成,重新注入", Label: "Steer 恢复:上次干预未完成,重新注入",
} }
} }
@@ -283,9 +318,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.IsResumable() { if progress.IsResumable() {
next := progress.NextChapter() next := progress.NextChapter()
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。", "从第 %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 字)", Label: fmt.Sprintf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)",
next, len(progress.CompletedChapters), progress.TotalWordCount), next, len(progress.CompletedChapters), progress.TotalWordCount),
} }

View File

@@ -2,6 +2,7 @@ package app
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"github.com/voocel/agentcore" "github.com/voocel/agentcore"
@@ -122,3 +123,31 @@ func TestCreateModelUsesOpenRouterProvider(t *testing.T) {
t.Fatalf("expected provider openrouter, got %q", provider) 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)
}
}

View File

@@ -198,7 +198,10 @@ func (rt *Runtime) Start(prompt string) error {
return fmt.Errorf("init progress: %w", err) 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 { if err := rt.coordinator.Prompt(promptText); err != nil {
return fmt.Errorf("prompt: %w", err) return fmt.Errorf("prompt: %w", err)
} }

View File

@@ -22,6 +22,15 @@ const (
FlowSteering FlowState = "steering" FlowSteering FlowState = "steering"
) )
// PlanningTier 表示作品规划的长度级别。
type PlanningTier string
const (
PlanningTierShort PlanningTier = "short"
PlanningTierMid PlanningTier = "mid"
PlanningTierLong PlanningTier = "long"
)
// Progress 进度追踪,持久化到 meta/progress.json。 // Progress 进度追踪,持久化到 meta/progress.json。
type Progress struct { type Progress struct {
NovelName string `json:"novel_name"` NovelName string `json:"novel_name"`
@@ -34,10 +43,10 @@ type Progress struct {
InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复) InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复)
CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号 CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号
Flow FlowState `json:"flow,omitempty"` // 当前流程 Flow FlowState `json:"flow,omitempty"` // 当前流程
PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列 PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列
RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因
StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand
HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type
// 长篇分层追踪(仅长篇模式使用,短篇/中篇为零值) // 长篇分层追踪(仅长篇模式使用,短篇/中篇为零值)
CurrentVolume int `json:"current_volume,omitempty"` CurrentVolume int `json:"current_volume,omitempty"`
CurrentArc int `json:"current_arc,omitempty"` CurrentArc int `json:"current_arc,omitempty"`
@@ -89,6 +98,7 @@ type RunMeta struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
Style string `json:"style"` Style string `json:"style"`
Model string `json:"model"` Model string `json:"model"`
PlanningTier PlanningTier `json:"planning_tier,omitempty"`
SteerHistory []SteerEntry `json:"steer_history,omitempty"` SteerHistory []SteerEntry `json:"steer_history,omitempty"`
PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入 PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入
} }

12
main.go
View File

@@ -90,6 +90,8 @@ func loadReferences(style string) tools.References {
Consistency: mustRead(referencesFS, "references/consistency.md"), Consistency: mustRead(referencesFS, "references/consistency.md"),
ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"), ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"),
DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.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" { if style != "" && style != "default" {
path := "references/" + style + "/style-references.md" path := "references/" + style + "/style-references.md"
@@ -102,10 +104,12 @@ func loadReferences(style string) tools.References {
func loadPrompts() app.Prompts { func loadPrompts() app.Prompts {
return app.Prompts{ return app.Prompts{
Coordinator: mustRead(promptsFS, "prompts/coordinator.md"), Coordinator: mustRead(promptsFS, "prompts/coordinator.md"),
Architect: mustRead(promptsFS, "prompts/architect.md"), ArchitectShort: mustRead(promptsFS, "prompts/architect-short.md"),
Writer: mustRead(promptsFS, "prompts/writer.md"), ArchitectMid: mustRead(promptsFS, "prompts/architect-mid.md"),
Editor: mustRead(promptsFS, "prompts/editor.md"), ArchitectLong: mustRead(promptsFS, "prompts/architect-long.md"),
Writer: mustRead(promptsFS, "prompts/writer.md"),
Editor: mustRead(promptsFS, "prompts/editor.md"),
} }
} }

114
prompts/architect-long.md Normal file
View File

@@ -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=<Markdown文本>)
### 3. 生成 Layered Outline
长篇默认使用分层结构,生成 JSON 格式的 layered_outline
-Volume阶段主题、阶段升级、阶段代价
-Arc局部目标、局部阻力、阶段转折
-Chapter章节标题、核心事件、钩子、场景
调用 save_foundation(type="layered_outline", scale="long", content=<JSON数组字符串>)
要求:
- 前 3 卷必须各自承担不同功能,而不是重复“升级打怪换地图”
- 每卷都必须回答:新增了什么、失去了什么、关系如何变化、为何必须进入下一卷
- 每弧都必须有明确目标、阻力、转折和结果
- 每章都必须服务于当前弧目标
- 中期必须有结构转向,后期必须有终局级命题
- 钩子类型要多样化,避免全靠“发现秘密”
### 4. 生成 Characters
基于 premise 和 layered_outline 生成角色档案JSON 格式),每个角色包含:
- name
- aliases
- role
- description
- arc
- traits
要求:
- 主要角色必须与长期故事引擎有关
- 角色弧线要能跨卷演化
- 重要配角不能只是阶段性工具人
- 关系线必须具备长期张力,而不是只服务某一章剧情
调用 save_foundation(type="characters", scale="long", content=<JSON数组字符串>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category
- rule
- boundary
要求:
- 规则必须会持续影响剧情决策
- 特别注意资源、代价、限制、秩序、势力边界
- 规则要能支撑中后期升级,而不是只服务前几章
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组字符串>)
## 增量修改模式
当任务中提到“增量修改”时:
1. 先调用 novel_context 获取当前 premise、outline、layered_outline、characters、world_rules
2. 保持已完成章节的一致性
3. 保持卷弧结构稳定,避免修改后退化成短篇式节奏
4. 若需调整长期规划,优先调整未展开卷弧
## 注意事项
- 长篇的核心是可持续展开,而不是简单变长
- 不要过早透支所有高潮和谜底
- 不要把同一种爽点反复复制到每一卷
- 不要让中后期只是前期的放大版

109
prompts/architect-mid.md Normal file
View File

@@ -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=<Markdown文本>)
### 3. 生成 Outline
中篇默认使用扁平 outline只有当阶段差异很强、用户明确要求更强结构时才考虑用 layered_outline。
生成章节大纲JSON 格式),每章包含:
- chapter
- title
- core_event
- hook
- scenes3-5 个场景)
要求:
- 至少划分出 3 个阶段:建立、升级、收束
- 每个阶段的主问题要有区别
- 中段必须出现一次改变后续推进方式的转折
- 支线不能游离,必须服务主线或人物关系变化
调用 save_foundation(type="outline", scale="mid", content=<JSON数组字符串>)
### 4. 生成 Characters
基于 premise 和 outline 生成角色档案JSON 格式),每个角色包含:
- name
- aliases
- role
- description
- arc
- traits
要求:
- 主要角色要承担不同功能
- 角色弧线要跨越多个阶段,而不是一章完成
- 配角要能反向影响主线
调用 save_foundation(type="characters", scale="mid", content=<JSON数组字符串>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category
- rule
- boundary
要求:
- 规则必须制造选择或代价
- 不能只是背景百科
调用 save_foundation(type="world_rules", scale="mid", content=<JSON数组字符串>)
## 增量修改模式
当任务中提到“增量修改”时:
1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules
2. 保持已完成章节的一致性
3. 保持中篇节奏,不要因为补设定而破坏阶段推进
## 注意事项
- 中篇的关键是阶段推进和平衡
- 不要像短篇那样过度压缩
- 也不要像长篇那样预留过多远期空间

107
prompts/architect-short.md Normal file
View File

@@ -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=<Markdown文本>)
### 3. 生成 Outline
短篇一律使用扁平 outline不使用 layered_outline。
生成章节大纲JSON 格式),每章包含:
- chapter
- title
- core_event
- hook
- scenes3-5 个场景)
要求:
- 每章都必须推动主冲突
- 不允许“中期再慢慢展开”的拖延式设计
- 配角数量控制在必要范围
- 世界规则只保留会直接影响剧情的部分
- 结局必须回收核心承诺
调用 save_foundation(type="outline", scale="short", content=<JSON数组字符串>)
### 4. 生成 Characters
基于 premise 和 outline 生成角色档案JSON 格式),每个角色包含:
- name
- aliases
- role
- description
- arc
- traits
要求:
- 角色功能必须清晰,避免冗余
- 主要角色弧线要在单卷内完成
调用 save_foundation(type="characters", scale="short", content=<JSON数组字符串>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category
- rule
- boundary
要求:
- 只保留必要规则,避免为短篇过度设计世界
- 规则必须直接服务当前冲突
调用 save_foundation(type="world_rules", scale="short", content=<JSON数组字符串>)
## 增量修改模式
当任务中提到“增量修改”时:
1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules
2. 保持已完成章节的一致性
3. 保持短篇结构的紧凑性,不要越改越膨胀
## 注意事项
- 短篇最重要的是集中与收束
- 不要预埋大量未来再说的线
- 不要把短篇写成“长篇开头”

View File

@@ -1,148 +0,0 @@
你是小说世界构建师。你负责从用户需求出发,构建小说的基础设定。
## 你的工具
- **novel_context**: 获取参考模板和当前状态
- **save_foundation**: 保存基础设定
## 工作流程
### 1. 获取模板
先调用 novel_context不传 chapter 参数)获取大纲模板和角色模板。
### 2. 生成 Premise
基于用户需求撰写故事前提Markdown 格式),包含:
- 题材和基调
- 核心冲突
- 主角目标
- 结局方向
- 写作禁区(不应出现的内容)
调用 save_foundation(type="premise", content=<Markdown文本>)
### 3. 生成 Outline
基于 premise 生成章节大纲JSON 格式),每章包含:
- chapter: 章节号
- title: 章节标题
- core_event: 核心事件
- hook: 章末钩子
- scenes: 场景概述列表3-5 个场景)
调用 save_foundation(type="outline", content=<JSON数组字符串>)
示例:
```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=<JSON数组字符串>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category: 规则类别magic / technology / geography / society / other
- rule: 规则描述
- boundary: 不可违反的边界
调用 save_foundation(type="world_rules", content=<JSON数组字符串>)
示例:
```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=<JSON数组字符串>)
### 弧级规划模式
当任务中提到"细化下一弧的章节大纲"时:
1. 调用 novel_context 获取当前分层大纲和已完成弧摘要
2. 为指定弧生成详细的章节大纲(复用现有 OutlineEntry 格式)
3. 调用 save_foundation(type="outline") 保存更新后的完整扁平大纲

View File

@@ -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**: 检查当前创作状态 - **novel_context**: 检查当前创作状态
## 工作流程 ## 工作流程
### 第一阶段:基础设定 ### 第一阶段:选择合适的规划师并生成基础设定
调用 architect 完成基础设定 在第一次规划前,你必须先判断用户需求更适合哪一种长度级别
- **短篇**:单冲突、单案、单任务、单段关键关系、结局集中
- **中篇**:有阶段性升级、几条重要支线、需要中段转折,但不需要超长连载
- **长篇**:题材具备持续升级空间、可扩展世界、长期关系张力、多阶段目标、多卷推进
选择规则:
- 只要题材明显适合长期展开,优先使用 `architect_long`
- 只有当需求明显更像单卷故事时,才使用 `architect_short`
- 不确定时,优先 `architect_mid`,但对连载型商业题材宁可偏长,不要误压成短篇
调用对应规划师完成基础设定:
```json ```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 处理: 收到 `[系统] Editor 审阅结论` 消息后,按 verdict 处理:
- **accept**: 继续写下一章 - **accept**: 继续写下一章
- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨。每次调用: - **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨
```json - **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写
{"agent": "writer", "task": "打磨第 N 章。审阅意见:<summary>"}
```
- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写。每次调用:
```json
{"agent": "writer", "task": "重写第 N 章。重写原因:<summary>"}
```
重写完成后回到正常写作流程,继续写下一个未完成章节
**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。中断退出后重启会自动恢复到重写状态。 **重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。
### 系统消息 ### 系统消息
@@ -59,7 +78,7 @@ architect 完成后,用 novel_context 确认设定已保存。
- **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer - **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer
- **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅 - **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅
你必须遵守系统消息中的确定性指令(如"不要再调用 writer" 你必须遵守系统消息中的确定性指令。
### 第四阶段:完成 ### 第四阶段:完成
@@ -73,42 +92,32 @@ architect 完成后,用 novel_context 确认设定已保存。
收到 `[用户干预]` 消息后: 收到 `[用户干预]` 消息后:
1. **评估影响范围**:判断用户的修改要求影响哪些内容 1. 评估影响范围
2. **更新设定**(如需要):调用 architect 更新 premise、outline 或 characters 2. 如需更新设定,调用与当前作品长度级别一致的规划师进行增量修改
```json 3. 如需重写已完成章节,逐章调用 writer 重写
{"agent": "architect", "task": "用户要求修改:<干预内容>。请在现有设定基础上做增量修改,保持已完成章节的一致性。"} 4. 从下一个未完成章节继续
```
3. **重写章节**(如需要):如果已完成章节受到影响,逐章调用 writer 重写 如果当前作品已经采用 layered_outline不要在修改时退化成短篇式 outline 思路。
4. **继续写作**:从下一个未完成章节继续
## 恢复指示 ## 恢复指示
- 收到"从第 N 章继续写作"的指示:跳过第一阶段,直接从第 N 章开始逐章写作 - 收到从第 N 章继续写作的指示:跳过第一阶段,直接从第 N 章开始逐章写作
- 收到"第 N 章正在进行中,已完成 M 个场景"的指示:调用 writer 从场景 M+1 继续该章写作 - 收到第 N 章正在进行中,已完成 M 个场景的指示:调用 writer 从场景 M+1 继续该章写作
- 收到"有 N 章待重写"的指示:逐章调用 writer 重写/打磨受影响章节,**全部完成后**才能继续写新章节 - 收到有 N 章待重写的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
- 收到"上次审阅中断"的指示:重新调用 editor 进行全局审阅 - 收到上次审阅中断的指示:重新调用 editor 进行全局审阅
## 注意事项
- 不要自己写正文,正文由 writer 完成
- 不要自己创建设定,设定由 architect 完成
- 不要自己做审阅,审阅由 editor 完成
- 你的职责是调度和决策,不是创作
- 章节完成/全书终止的判断由宿主程序通过系统消息控制
- 重写章节时writer 的流程与新写相同,旧文件会自动覆盖
## 长篇模式(分层大纲) ## 长篇模式(分层大纲)
当系统消息包含"弧结束"或"卷结束"信号时,执行以下工作流: 当系统消息包含弧结束”或“卷结束信号时,执行以下工作流:
### 弧结束处理 ### 弧结束处理
收到 `[系统] 第 V 卷第 A 弧结束` 消息后,按消息中的步骤依次执行 收到 `[系统] 第 V 卷第 A 弧结束` 消息后:
1. 调用 editor 进行弧级评审(任务中说明 scope=arc 1. 调用 editor 进行弧级评审
2. 调用 editor 生成弧摘要和角色快照editor 会调用 save_arc_summary 工具) 2. 调用 editor 生成弧摘要和角色快照
3. 继续写下一弧的章节 3. 继续写下一弧的章节
### 卷结束处理 ### 卷结束处理
收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后: 收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后:
1. 先完成弧结束处理(弧级评审 + 弧摘要) 1. 先完成弧结束处理
2. 额外调用 editor 生成卷摘要editor 会调用 save_volume_summary 工具) 2. 额外调用 editor 生成卷摘要
3. 继续写下一卷的章节 3. 继续写下一卷的章节

View File

@@ -0,0 +1,86 @@
# 通用差异化设计参考
这份参考用于避免同题材作品自动滑向“最高频模板”。
## 当用户只给出一个大题材词时,不能直接套最常见范式
例如用户说:
- 都市
- 奇幻
- 修仙
- 悬疑
- 言情
- 科幻
这不等于“照该题材最常见的开局写”。你必须先补足差异化维度。
## 差异化的五个维度
### 1. 主角维度
- 出身是否过于常见
- 初始优势/劣势是否过于常见
- 主角最强驱动力是什么
- 主角最大的盲区是什么
### 2. 冲突维度
- 主冲突是否只是该题材默认矛盾
- 有没有第二层冲突改变读者预期
- 冲突是否会在中期转型
### 3. 世界维度
- 世界规则是否真的改变角色行为
- 社会结构、资源结构、权力结构是否能持续制造问题
- 世界是否存在非主角视角也合理运转的逻辑
### 4. 关系维度
- 主要关系是否只有“队友/恋人/敌人”三个静态功能
- 是否存在长期互相塑造、互相伤害、互相利用、互相成全的关系
- 关系线是否会反向推动主线
### 5. 节奏维度
- 爽点是否单一重复
- 是否规划了不同阶段的阅读驱动力
- 前期吸引力和中后期吸引力是否一致,还是有自然升级
## 常见同质化信号
出现越多,越说明作品在滑向通用模板:
- 最常见的主角出身设定
- 最常见的“被看不起”起手
- 最常见的导师/宗门/学院/豪门/案件开场
- 最常见的反派动机
- 最常见的阶段升级节奏
- 最常见的“发现秘密”型钩子反复出现
## 规划时必须主动给自己设限
在同题材下,至少给出 2-3 条反模板约束。例如:
- 不使用最常见的开局身份
- 不使用最常见的金手指/能力来源
- 不使用最常见的中期升级路径
- 不让主要关系线停留在单一功能
- 不让终局只是“打败更大的敌人”
## 差异化不是猎奇,而是重新分配重心
有效的差异化通常来自:
- 更换主角真正关心的东西
- 更换长期冲突的来源
- 更换世界规则的压力点
- 更换关系线在故事中的功能
- 更换中期之后的推进方式
## 输出前自问
- 如果把角色名和设定名抹掉,这个故事还像同题材里另外十本书吗?
- 如果只看前 10 章,读者能说出这本书“独特在哪”吗?
- 如果写到 50 章后,作品的推进方式会不会和前 10 章完全重复?

View File

@@ -0,0 +1,105 @@
# 通用长篇规划参考
这份参考用于“适合长篇连载”的题材,不限定具体品类。
## 长篇不是把短篇拉长
长篇的核心不是章节更多,而是具备长期展开能力。判断一部作品能否写长,关键看它是否具备以下“故事引擎”:
- **目标引擎**:主角会不断追求新的阶段目标
- **世界引擎**:世界规则、势力格局、资源结构可以持续制造新问题
- **关系引擎**:主要人物关系会持续演化,而不是定型后停滞
- **身份引擎**:主角的位置、身份、阵营、责任会变化
- **代价引擎**:每次成长都带来新的约束、损失或风险
如果这几个引擎都很弱,再多章节也只会变成重复灌水。
## 长篇推荐规划顺序
### 1. 作品卖点
先明确:
- 这本书最吸引读者的承诺是什么
- 它和同题材常见写法最不同的点是什么
- 读者为什么愿意跟随主角走到中后期
### 2. 长期冲突
不要只有一个“终极反派”。长篇更适合多阶段冲突:
- 近程冲突:当前生存、当前任务、当前阶段目标
- 中程冲突:势力博弈、关系重组、身份变化
- 远程冲突:世界真相、时代命题、终局选择
### 3. 卷级设计
每一卷至少要有一个明确功能,常见功能包括:
- 立足
- 扩张
- 试错
- 反噬
- 失去
- 转向
- 收束
- 终局
每卷不只升级强度,还要升级问题类型。
### 4. 弧级设计
每一弧都应该像“一个可独立成立的小故事”:
- 有明确目标
- 有明确阻力
- 有阶段转折
- 有结果与代价
### 5. 章节设计
章节不是平均分配事件,而是为弧服务:
- 关键推进章
- 关系变化章
- 代价兑现章
- 误判与反噬章
- 转折章
- 收束与引出下弧章
## 避免长篇同质化
### 错误做法
- 每一卷都只是“换地图 + 换敌人”
- 每次升级都只是“主角更强了”
- 中期仍然重复前期的爽点结构
- 配角只在需要时出现,没有独立动机
- 世界规则只在设定里写,剧情中不产生压力
### 正确做法
- 升级“冲突类型”,不只升级“敌人强度”
- 升级“选择代价”,不只升级“资源规模”
- 升级“关系复杂度”,不只升级“出场人数”
- 升级“命题”,不只升级“舞台大小”
## 中期转向必须提前规划
很多作品前 20 章能写50 章后就开始重复,根因是没有中期转向。
在规划时必须提前想清楚:
- 第一次结构转向发生在什么时候
- 为什么前期方法在中期失效
- 主角到中期后必须学会什么新的思维方式
- 中后期的核心吸引力与前期有什么不同
## 长篇通用检查清单
- 这本书是否具备至少 3 个阶段性主矛盾?
- 前 3 卷是否各自承担不同功能?
- 主角的“得到”和“失去”是否同步增长?
- 主要配角是否会改变主线,而不是只被主角改变?
- 世界规则是否真的限制了剧情决策?
- 中期转向后,作品是否仍然成立?

View File

@@ -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"]
}
]
}
]
}
]
```
## 长篇卷级检查清单
每一卷都要回答:
- 这一卷新增了什么世界信息?
- 这一卷升级了什么核心矛盾?
- 这一卷让主角得到什么,也失去什么?
- 这一卷如何改变主要人物关系?
- 这一卷结束后,故事为什么必须进入下一卷?
## 长篇弧级检查清单
每一弧都要回答:
- 这条弧的明确目标是什么?
- 阻力来自谁、什么规则、什么代价?
- 转折点是什么?
- 这条弧结束后,哪些状态发生了不可逆变化?
## 章节级检查清单
- 每章必须服务于所在弧的目标
- 每章必须包含一个不可删除的事件推进
- 钩子要多样化,不要全靠“发现秘密”一种模式
- 前期章节不能只是在“介绍世界”,必须同步推进人物和冲突

View File

@@ -77,6 +77,16 @@ func (s *Store) LoadLayeredOutline() ([]domain.VolumeOutline, error) {
return volumes, nil 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 从分层大纲中按全局章节号查找。 // GetChapterFromLayered 从分层大纲中按全局章节号查找。
func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) { func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) {
volumes, err := s.LoadLayeredOutline() volumes, err := s.LoadLayeredOutline()
@@ -151,11 +161,11 @@ func (s *Store) CheckArcBoundary(chapter int) (*ArcBoundary, error) {
for ci := range a.Chapters { for ci := range a.Chapters {
if ch == chapter { if ch == chapter {
cur = &chapterPos{ cur = &chapterPos{
volume: v.Index, volume: v.Index,
arc: a.Index, arc: a.Index,
indexInArc: ci, indexInArc: ci,
arcLen: len(a.Chapters), arcLen: len(a.Chapters),
isLastArc: ai == len(v.Arcs)-1, isLastArc: ai == len(v.Arcs)-1,
} }
} else if cur != nil && nextVol == 0 { } else if cur != nil && nextVol == 0 {
// 紧跟 cur 的下一章 // 紧跟 cur 的下一章

View File

@@ -10,8 +10,14 @@ import (
// LoadProgress 读取 meta/progress.json。不存在时返回 nil。 // LoadProgress 读取 meta/progress.json。不存在时返回 nil。
func (s *Store) LoadProgress() (*domain.Progress, error) { 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 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) { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
@@ -22,7 +28,13 @@ func (s *Store) LoadProgress() (*domain.Progress, error) {
// SaveProgress 保存进度到 meta/progress.json。 // SaveProgress 保存进度到 meta/progress.json。
func (s *Store) SaveProgress(p *domain.Progress) error { 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 创建初始进度。 // InitProgress 创建初始进度。
@@ -36,84 +48,90 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error {
// SetTotalChapters 根据大纲长度设定总章节数。 // SetTotalChapters 根据大纲长度设定总章节数。
func (s *Store) SetTotalChapters(n int) error { func (s *Store) SetTotalChapters(n int) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
p = &domain.Progress{} if p == nil {
} p = &domain.Progress{}
p.TotalChapters = n }
return s.SaveProgress(p) p.TotalChapters = n
return s.saveProgressUnlocked(p)
})
} }
// UpdatePhase 更新创作阶段。 // UpdatePhase 更新创作阶段。
func (s *Store) UpdatePhase(phase domain.Phase) error { func (s *Store) UpdatePhase(phase domain.Phase) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
p = &domain.Progress{} if p == nil {
} p = &domain.Progress{}
p.Phase = phase }
return s.SaveProgress(p) p.Phase = phase
return s.saveProgressUnlocked(p)
})
} }
// MarkChapterComplete 标记章节完成,原子性更新进度。 // MarkChapterComplete 标记章节完成,原子性更新进度。
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。 // 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
// hookType 和 dominantStrand 用于节奏追踪,可为空。 // hookType 和 dominantStrand 用于节奏追踪,可为空。
func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error { func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return fmt.Errorf("progress not initialized, call InitProgress first") if p == nil {
} return fmt.Errorf("progress not initialized, call InitProgress first")
if p.ChapterWordCounts == nil { }
p.ChapterWordCounts = make(map[int]int) if p.ChapterWordCounts == nil {
} p.ChapterWordCounts = make(map[int]int)
// 重写场景:减去旧字数 }
if oldWC, ok := p.ChapterWordCounts[chapter]; ok { // 重写场景:减去旧字数
p.TotalWordCount -= oldWC if oldWC, ok := p.ChapterWordCounts[chapter]; ok {
} p.TotalWordCount -= oldWC
p.ChapterWordCounts[chapter] = wordCount }
p.TotalWordCount += wordCount p.ChapterWordCounts[chapter] = wordCount
if !slices.Contains(p.CompletedChapters, chapter) { p.TotalWordCount += wordCount
p.CompletedChapters = append(p.CompletedChapters, chapter) if !slices.Contains(p.CompletedChapters, chapter) {
} p.CompletedChapters = append(p.CompletedChapters, chapter)
// 仅在正常推进时更新 CurrentChapter重写旧章节不回退指针 }
if chapter+1 > p.CurrentChapter { // 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针
p.CurrentChapter = chapter + 1 if chapter+1 > p.CurrentChapter {
} p.CurrentChapter = chapter + 1
p.InProgressChapter = 0 }
p.CompletedScenes = nil p.InProgressChapter = 0
p.Phase = domain.PhaseWriting p.CompletedScenes = nil
p.Phase = domain.PhaseWriting
// 节奏追踪:按章节顺序填充 history确保索引对齐 // 节奏追踪:按章节顺序填充 history确保索引对齐
if dominantStrand != "" { if dominantStrand != "" {
for len(p.StrandHistory) < chapter-1 { for len(p.StrandHistory) < chapter-1 {
p.StrandHistory = append(p.StrandHistory, "") 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 { if hookType != "" {
p.StrandHistory = append(p.StrandHistory, dominantStrand) for len(p.HookHistory) < chapter-1 {
} else { p.HookHistory = append(p.HookHistory, "")
p.StrandHistory[chapter-1] = dominantStrand }
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 标记全书创作完成。 // MarkComplete 标记全书创作完成。
@@ -142,36 +160,40 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint。 // MarkSceneComplete 标记场景完成,用于场景级 checkpoint。
// 切换到不同章节时自动清空旧的 CompletedScenes。 // 切换到不同章节时自动清空旧的 CompletedScenes。
func (s *Store) MarkSceneComplete(chapter, scene int) error { func (s *Store) MarkSceneComplete(chapter, scene int) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return fmt.Errorf("progress not initialized, call InitProgress first") if p == nil {
} return fmt.Errorf("progress not initialized, call InitProgress first")
// 章节切换:清空旧场景列表 }
if p.InProgressChapter != chapter { // 章节切换:清空旧场景列表
p.CompletedScenes = nil if p.InProgressChapter != chapter {
} p.CompletedScenes = nil
p.InProgressChapter = chapter }
if !slices.Contains(p.CompletedScenes, scene) { p.InProgressChapter = chapter
p.CompletedScenes = append(p.CompletedScenes, scene) if !slices.Contains(p.CompletedScenes, scene) {
} p.CompletedScenes = append(p.CompletedScenes, scene)
return s.SaveProgress(p) }
return s.saveProgressUnlocked(p)
})
} }
// ClearInProgress 清除场景级进度状态(章节提交后调用)。 // ClearInProgress 清除场景级进度状态(章节提交后调用)。
func (s *Store) ClearInProgress() error { func (s *Store) ClearInProgress() error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return nil if p == nil {
} return nil
p.InProgressChapter = 0 }
p.CompletedScenes = nil p.InProgressChapter = 0
return s.SaveProgress(p) p.CompletedScenes = nil
return s.saveProgressUnlocked(p)
})
} }
// ClearLastCommit 清除 commit 信号文件,防止重复消费。 // ClearLastCommit 清除 commit 信号文件,防止重复消费。
@@ -181,95 +203,107 @@ func (s *Store) ClearLastCommit() error {
// UpdateVolumeArc 更新当前卷弧位置。 // UpdateVolumeArc 更新当前卷弧位置。
func (s *Store) UpdateVolumeArc(volume, arc int) error { func (s *Store) UpdateVolumeArc(volume, arc int) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return nil if p == nil {
} return nil
p.CurrentVolume = volume }
p.CurrentArc = arc p.CurrentVolume = volume
return s.SaveProgress(p) p.CurrentArc = arc
return s.saveProgressUnlocked(p)
})
} }
// SetLayered 设置分层模式标志。 // SetLayered 设置分层模式标志。
func (s *Store) SetLayered(layered bool) error { func (s *Store) SetLayered(layered bool) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return nil if p == nil {
} return nil
p.Layered = layered }
return s.SaveProgress(p) p.Layered = layered
return s.saveProgressUnlocked(p)
})
} }
// SetFlow 更新当前流程状态。 // SetFlow 更新当前流程状态。
func (s *Store) SetFlow(flow domain.FlowState) error { func (s *Store) SetFlow(flow domain.FlowState) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return nil if p == nil {
} return nil
p.Flow = flow }
return s.SaveProgress(p) p.Flow = flow
return s.saveProgressUnlocked(p)
})
} }
// SetPendingRewrites 设置待重写章节队列和原因。 // SetPendingRewrites 设置待重写章节队列和原因。
func (s *Store) SetPendingRewrites(chapters []int, reason string) error { func (s *Store) SetPendingRewrites(chapters []int, reason string) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return nil if p == nil {
} return nil
p.PendingRewrites = chapters }
p.RewriteReason = reason p.PendingRewrites = chapters
return s.SaveProgress(p) p.RewriteReason = reason
return s.saveProgressUnlocked(p)
})
} }
// CompleteRewrite 从待重写队列中移除已完成的章节。 // CompleteRewrite 从待重写队列中移除已完成的章节。
// 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。 // 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。
func (s *Store) CompleteRewrite(chapter int) error { func (s *Store) CompleteRewrite(chapter int) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err 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)
} }
} if p == nil {
p.PendingRewrites = remaining return nil
if len(remaining) == 0 { }
p.Flow = domain.FlowWriting var remaining []int
p.RewriteReason = "" for _, ch := range p.PendingRewrites {
} if ch != chapter {
return s.SaveProgress(p) remaining = append(remaining, ch)
}
}
p.PendingRewrites = remaining
if len(remaining) == 0 {
p.Flow = domain.FlowWriting
p.RewriteReason = ""
}
return s.saveProgressUnlocked(p)
})
} }
// ClearPendingRewrites 强制清空重写队列。 // ClearPendingRewrites 强制清空重写队列。
func (s *Store) ClearPendingRewrites() error { func (s *Store) ClearPendingRewrites() error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
if err != nil { p, err := s.loadProgressUnlocked()
return err if err != nil {
} return err
if p == nil { }
return nil if p == nil {
} return nil
p.PendingRewrites = nil }
p.RewriteReason = "" p.PendingRewrites = nil
p.Flow = domain.FlowWriting p.RewriteReason = ""
return s.SaveProgress(p) p.Flow = domain.FlowWriting
return s.saveProgressUnlocked(p)
})
} }
// ValidateChapterCommit 校验当前章节是否允许提交。 // ValidateChapterCommit 校验当前章节是否允许提交。

View File

@@ -10,13 +10,21 @@ import (
// SaveRunMeta 保存运行元信息到 meta/run.json。 // SaveRunMeta 保存运行元信息到 meta/run.json。
func (s *Store) SaveRunMeta(meta domain.RunMeta) error { 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 读取运行元信息。 // LoadRunMeta 读取运行元信息。
func (s *Store) LoadRunMeta() (*domain.RunMeta, error) { 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 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) { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
@@ -25,59 +33,90 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
return &meta, nil return &meta, nil
} }
func (s *Store) saveRunMetaUnlocked(meta domain.RunMeta) error {
return s.writeJSONUnlocked("meta/run.json", meta)
}
// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。 // InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。
func (s *Store) InitRunMeta(style, provider, model string) error { func (s *Store) InitRunMeta(style, provider, model string) error {
existing, _ := s.LoadRunMeta() return s.withWriteLock(func() error {
meta := domain.RunMeta{ existing, err := s.loadRunMetaUnlocked()
StartedAt: time.Now().Format(time.RFC3339), if err != nil {
Provider: provider, return err
Style: style, }
Model: model, meta := domain.RunMeta{
} StartedAt: time.Now().Format(time.RFC3339),
if existing != nil { Provider: provider,
meta.SteerHistory = existing.SteerHistory Style: style,
meta.PendingSteer = existing.PendingSteer Model: model,
} }
return s.SaveRunMeta(meta) if existing != nil {
meta.SteerHistory = existing.SteerHistory
meta.PendingSteer = existing.PendingSteer
meta.PlanningTier = existing.PlanningTier
}
return s.saveRunMetaUnlocked(meta)
})
} }
// AppendSteerEntry 追加用户干预记录到 meta/run.json。 // AppendSteerEntry 追加用户干预记录到 meta/run.json。
func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error { func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error {
meta, err := s.LoadRunMeta() return s.withWriteLock(func() error {
if err != nil { meta, err := s.loadRunMetaUnlocked()
return err if err != nil {
} return err
if meta == nil { }
meta = &domain.RunMeta{} if meta == nil {
} meta = &domain.RunMeta{}
meta.SteerHistory = append(meta.SteerHistory, entry) }
return s.SaveRunMeta(*meta) meta.SteerHistory = append(meta.SteerHistory, entry)
return s.saveRunMetaUnlocked(*meta)
})
} }
// SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。 // SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。
func (s *Store) SetPendingSteer(input string) error { func (s *Store) SetPendingSteer(input string) error {
meta, err := s.LoadRunMeta() return s.withWriteLock(func() error {
if err != nil { meta, err := s.loadRunMetaUnlocked()
return err if err != nil {
} return err
if meta == nil { }
meta = &domain.RunMeta{} if meta == nil {
} meta = &domain.RunMeta{}
meta.PendingSteer = input }
return s.SaveRunMeta(*meta) meta.PendingSteer = input
return s.saveRunMetaUnlocked(*meta)
})
} }
// ClearPendingSteer 清除已处理的 Steer 指令。 // ClearPendingSteer 清除已处理的 Steer 指令。
func (s *Store) ClearPendingSteer() error { func (s *Store) ClearPendingSteer() error {
meta, err := s.LoadRunMeta() return s.withWriteLock(func() error {
if err != nil { meta, err := s.loadRunMetaUnlocked()
return err if err != nil {
} return err
if meta == nil || meta.PendingSteer == "" { }
return nil if meta == nil || meta.PendingSteer == "" {
} return nil
meta.PendingSteer = "" }
return s.SaveRunMeta(*meta) 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/。 // SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。

View File

@@ -1,7 +1,9 @@
package state package state
import ( import (
"fmt"
"os" "os"
"sync"
"testing" "testing"
"github.com/voocel/ainovel-cli/domain" "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) { func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
store := NewStore(dir) 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) { func TestClearPendingSteer_Noop(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
store := NewStore(dir) store := NewStore(dir)

View File

@@ -5,11 +5,13 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
) )
// Store 封装小说输出目录,提供所有状态读写操作。 // Store 封装小说输出目录,提供所有状态读写操作。
type Store struct { type Store struct {
dir string dir string
mu sync.RWMutex
} }
// NewStore 创建状态管理器dir 为小说输出根目录。 // NewStore 创建状态管理器dir 为小说输出根目录。
@@ -36,19 +38,61 @@ func (s *Store) path(rel string) string {
} }
func (s *Store) readFile(rel string) ([]byte, error) { 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 { 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) p := s.path(rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err 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 { 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 { if err != nil {
return err return err
} }
@@ -56,21 +100,41 @@ func (s *Store) readJSON(rel string, v any) error {
} }
func (s *Store) writeJSON(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, "", " ") data, err := json.MarshalIndent(v, "", " ")
if err != nil { if err != nil {
return err return err
} }
return s.writeFile(rel, data) return s.writeFileUnlocked(rel, data)
} }
func (s *Store) writeMarkdown(rel string, content string) error { 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 { 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)) err := os.Remove(s.path(rel))
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil return nil
} }
return err return err
} }
func (s *Store) withWriteLock(fn func() error) error {
s.mu.Lock()
defer s.mu.Unlock()
return fn()
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/voocel/agentcore/schema" "github.com/voocel/agentcore/schema"
@@ -25,7 +26,9 @@ type References struct {
ContentExpansion string ContentExpansion string
DialogueWriting string DialogueWriting string
// V2 // V2
StyleReference string // 风格补充参考(可为空) StyleReference string // 风格补充参考(可为空)
LongformPlanning string // 通用长篇规划参考
Differentiation string // 通用差异化设计参考
} }
// ContextTool 组装当前章节所需上下文。 // ContextTool 组装当前章节所需上下文。
@@ -60,22 +63,47 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
} }
result := make(map[string]any) 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 != "" { if premise, err := t.store.LoadPremise(); err == nil && premise != "" {
result["premise"] = premise result["premise"] = premise
} else {
warn("premise", err)
} }
if outline, err := t.store.LoadOutline(); err == nil && outline != nil { if outline, err := t.store.LoadOutline(); err == nil && outline != nil {
result["outline"] = outline result["outline"] = outline
} else {
warn("outline", err)
} }
if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 { if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 {
result["world_rules"] = rules result["world_rules"] = rules
} else {
warn("world_rules", err)
} }
if a.Chapter > 0 { if a.Chapter > 0 {
// 根据总章节数计算上下文策略 // 根据总章节数计算上下文策略
profile := domain.NewContextProfile(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 { if progress != nil && progress.TotalChapters > 0 {
profile = domain.NewContextProfile(progress.TotalChapters) profile = domain.NewContextProfile(progress.TotalChapters)
} }
@@ -86,26 +114,32 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
// 角色加载Layered 模式优先用快照,回退到原始设定 // 角色加载Layered 模式优先用快照,回退到原始设定
if profile.Layered { if profile.Layered {
t.loadLayeredCharacters(result, a.Chapter) t.loadLayeredCharacters(result, a.Chapter, warn)
} else { } else {
t.loadFilteredCharacters(result, a.Chapter) t.loadFilteredCharacters(result, a.Chapter, warn)
} }
// Writer/Editor 模式:加载章节相关上下文 // Writer/Editor 模式:加载章节相关上下文
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil {
result["current_chapter_outline"] = entry result["current_chapter_outline"] = entry
} else {
warn("current_chapter_outline", err)
} }
// 摘要加载:分层 vs 扁平 // 摘要加载:分层 vs 扁平
if profile.Layered { if profile.Layered {
t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow) t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow, warn)
} else if profile.FullContext { } else if profile.FullContext {
if summaries, err := t.store.LoadAllSummaries(a.Chapter); err == nil && len(summaries) > 0 { if summaries, err := t.store.LoadAllSummaries(a.Chapter); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
} }
} else { } else {
if summaries, err := t.store.LoadRecentSummaries(a.Chapter, profile.SummaryWindow); err == nil && len(summaries) > 0 { if summaries, err := t.store.LoadRecentSummaries(a.Chapter, profile.SummaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries 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 profile.FullContext {
if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 {
result["timeline"] = timeline result["timeline"] = timeline
} else {
warn("timeline", err)
} }
} else { } else {
if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 { if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 {
result["timeline"] = timeline result["timeline"] = timeline
} else {
warn("timeline", err)
} }
} }
// foreshadow短篇全量否则只取未回收条目 // foreshadow短篇全量否则只取未回收条目
if profile.FullContext { if profile.FullContext {
if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 { if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow result["foreshadow_ledger"] = foreshadow
} else {
warn("foreshadow_ledger", err)
} }
} else { } else {
if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 { if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow result["foreshadow_ledger"] = foreshadow
} else {
warn("foreshadow_ledger", err)
} }
} }
// relationships保持全量pair-key 去重,数据量天然可控) // relationships保持全量pair-key 去重,数据量天然可控)
if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 { if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 {
result["relationship_state"] = relationships result["relationship_state"] = relationships
} else {
warn("relationship_state", err)
} }
// 状态变化:最近 5 章的角色/实体状态变化 // 状态变化:最近 5 章的角色/实体状态变化
if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 5); err == nil && len(changes) > 0 { if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 5); err == nil && len(changes) > 0 {
result["recent_state_changes"] = changes result["recent_state_changes"] = changes
} else {
warn("recent_state_changes", err)
} }
// Layered 模式:注入当前卷弧位置 + 弧目标/卷主题 // Layered 模式:注入当前卷弧位置 + 弧目标/卷主题
@@ -159,6 +205,8 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
break break
} }
} }
} else {
warn("layered_outline", err)
} }
result["position"] = pos 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 { if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
result["chapter_plan"] = plan result["chapter_plan"] = plan
} else {
warn("chapter_plan", err)
} }
// 写作参考资料分阶段加载 // 写作参考资料分阶段加载
result["references"] = t.writerReferences(a.Chapter) result["references"] = t.writerReferences(a.Chapter)
} else { } else {
runMeta, err := t.store.LoadRunMeta()
warn("run_meta", err)
if runMeta != nil && runMeta.PlanningTier != "" {
result["planning_tier"] = runMeta.PlanningTier
}
// Architect 模式:全量角色 + 模板 // Architect 模式:全量角色 + 模板
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
result["characters"] = chars result["characters"] = chars
} else {
warn("characters", err)
} }
// Architect 模式下也加载分层大纲(弧级规划需要看全貌) // Architect 模式下也加载分层大纲(弧级规划需要看全貌)
if layered, err := t.store.LoadLayeredOutline(); err == nil && len(layered) > 0 { if layered, err := t.store.LoadLayeredOutline(); err == nil && len(layered) > 0 {
result["layered_outline"] = layered result["layered_outline"] = layered
} else {
warn("layered_outline", err)
} }
// 加载已有的弧摘要(弧级规划时需要参考前续弧的内容) // 加载已有的弧摘要(弧级规划时需要参考前续弧的内容)
if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 { if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 {
result["volume_summaries"] = volSummaries result["volume_summaries"] = volSummaries
} else {
warn("volume_summaries", err)
} }
result["references"] = t.architectReferences() result["references"] = t.architectReferences()
} }
if len(warnings) > 0 {
result["_warnings"] = warnings
}
result["_loading_summary"] = buildLoadingSummary(result, a.Chapter) result["_loading_summary"] = buildLoadingSummary(result, a.Chapter)
return json.Marshal(result) return json.Marshal(result)
} }
@@ -213,6 +277,9 @@ func buildLoadingSummary(result map[string]any, chapter int) string {
} else { } else {
parts = append(parts, "architect") 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 { 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 { if refs, ok := result["references"].(map[string]string); ok && len(refs) > 0 {
items = append(items, fmt.Sprintf("参考:%d项", len(refs))) 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 { if len(items) > 0 {
parts = append(parts, strings.Join(items, " ")) parts = append(parts, strings.Join(items, " "))
@@ -309,15 +379,20 @@ func sliceLen(v any) int {
// loadFilteredCharacters 按 Tier 和场景出场过滤角色。 // loadFilteredCharacters 按 Tier 和场景出场过滤角色。
// core/important 始终返回secondary/decorative 只在当前章节大纲提及时返回。 // 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() chars, err := t.store.LoadCharacters()
if err != nil || len(chars) == 0 { if err != nil {
warn("characters", err)
return
}
if len(chars) == 0 {
return return
} }
// 获取当前章节大纲的场景描述,用于匹配次要角色 // 获取当前章节大纲的场景描述,用于匹配次要角色
entry, err := t.store.GetChapterOutline(chapter) entry, err := t.store.GetChapterOutline(chapter)
if err != nil { if err != nil {
warn("current_chapter_outline", err)
result["characters"] = chars result["characters"] = chars
return return
} }
@@ -351,12 +426,15 @@ func matchCharacter(text string, c domain.Character) bool {
} }
// loadLayeredSummaries 分层摘要加载:卷摘要 + 当前卷弧摘要 + 弧内章摘要。 // 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) vol, arc, err := t.store.LocateChapter(chapter)
if err != nil { if err != nil {
warn("layered_outline_position", err)
// 回退到扁平模式 // 回退到扁平模式
if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 { if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
} }
return return
} }
@@ -364,6 +442,8 @@ func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summa
// 1. 已完成卷的卷摘要 // 1. 已完成卷的卷摘要
if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 { if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 {
result["volume_summaries"] = volSummaries result["volume_summaries"] = volSummaries
} else {
warn("volume_summaries", err)
} }
// 2. 当前卷内已完成弧的弧摘要(不含当前弧) // 2. 当前卷内已完成弧的弧摘要(不含当前弧)
@@ -377,25 +457,30 @@ func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summa
if len(prior) > 0 { if len(prior) > 0 {
result["arc_summaries"] = prior result["arc_summaries"] = prior
} }
} else {
warn("arc_summaries", err)
} }
// 3. 当前弧内最近 N 章的章摘要 // 3. 当前弧内最近 N 章的章摘要
if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 { if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
} }
} }
// loadLayeredCharacters Layered 模式下的角色加载:优先用最近快照,回退到原始设定 + Tier 过滤。 // 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() snapshots, err := t.store.LoadLatestSnapshots()
if err == nil && len(snapshots) > 0 { if err == nil && len(snapshots) > 0 {
result["character_snapshots"] = snapshots result["character_snapshots"] = snapshots
// 同时保留原始设定中的 core/important 角色(快照可能不含新登场角色) // 同时保留原始设定中的 core/important 角色(快照可能不含新登场角色)
t.loadFilteredCharacters(result, chapter) t.loadFilteredCharacters(result, chapter, warn)
return return
} }
warn("character_snapshots", err)
// 无快照时回退到原始设定 // 无快照时回退到原始设定
t.loadFilteredCharacters(result, chapter) t.loadFilteredCharacters(result, chapter, warn)
} }
// writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。 // writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。
@@ -431,6 +516,9 @@ func (t *ContextTool) architectReferences() map[string]string {
} }
add("outline_template", t.refs.OutlineTemplate) add("outline_template", t.refs.OutlineTemplate)
add("character_template", t.refs.CharacterTemplate) 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 return refs
} }

View File

@@ -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
}

View File

@@ -21,7 +21,7 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
func (t *SaveFoundationTool) Name() string { return "save_foundation" } func (t *SaveFoundationTool) Name() string { return "save_foundation" }
func (t *SaveFoundationTool) Description() string { func (t *SaveFoundationTool) Description() string {
return "保存小说基础设定。type=premise 时 content 为 Markdowntype=outline 时 content 为 JSON 数组type=characters 时 content 为 JSON 数组type=world_rules 时 content 为 JSON 数组" return "保存小说基础设定。type=premise 时 content 为 Markdowntype=outline 时 content 为 JSON 数组type=characters 时 content 为 JSON 数组type=world_rules 时 content 为 JSON 数组。scale 可选,用于记录 short/mid/long 规划级别"
} }
func (t *SaveFoundationTool) Label() string { return "保存设定" } func (t *SaveFoundationTool) Label() string { return "保存设定" }
@@ -29,6 +29,7 @@ func (t *SaveFoundationTool) Schema() map[string]any {
return schema.Object( return schema.Object(
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(), schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
schema.Property("content", schema.String("内容。premise 为 Markdown 文本outline/layered_outline/characters/world_rules 为 JSON 字符串")).Required(), schema.Property("content", 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 { var a struct {
Type string `json:"type"` Type string `json:"type"`
Content string `json:"content"` Content string `json:"content"`
Scale string `json:"scale"`
} }
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err) return nil, fmt.Errorf("invalid args: %w", err)
} }
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 { switch a.Type {
case "premise": case "premise":
@@ -47,7 +59,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
return nil, fmt.Errorf("save premise: %w", err) return nil, fmt.Errorf("save premise: %w", err)
} }
_ = t.store.UpdatePhase(domain.PhasePremise) _ = t.store.UpdatePhase(domain.PhasePremise)
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": case "outline":
var entries []domain.OutlineEntry 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.UpdatePhase(domain.PhaseOutline)
// 根据大纲长度自动设定总章节数 // 根据大纲长度自动设定总章节数
_ = t.store.SetTotalChapters(len(entries)) _ = 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": case "layered_outline":
var volumes []domain.VolumeOutline var volumes []domain.VolumeOutline
@@ -85,6 +102,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
return json.Marshal(map[string]any{ return json.Marshal(map[string]any{
"saved": true, "type": "layered_outline", "saved": true, "type": "layered_outline",
"volumes": len(volumes), "chapters": total, "volumes": len(volumes), "chapters": total,
"scale": a.Scale,
}) })
case "characters": case "characters":
@@ -95,7 +113,7 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j
if err := t.store.SaveCharacters(chars); err != nil { if err := t.store.SaveCharacters(chars); err != nil {
return nil, fmt.Errorf("save characters: %w", err) 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": case "world_rules":
var rules []domain.WorldRule 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 { if err := t.store.SaveWorldRules(rules); err != nil {
return nil, fmt.Errorf("save world_rules: %w", err) 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: default:
return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type) return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type)

View File

@@ -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)
}
}