perf: 拆分规划策略
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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 非空)。
|
||||||
|
|||||||
59
app/run.go
59
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)
|
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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
12
main.go
@@ -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
114
prompts/architect-long.md
Normal 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
109
prompts/architect-mid.md
Normal 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
|
||||||
|
- scenes(3-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
107
prompts/architect-short.md
Normal 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
|
||||||
|
- scenes(3-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. 保持短篇结构的紧凑性,不要越改越膨胀
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 短篇最重要的是集中与收束
|
||||||
|
- 不要预埋大量未来再说的线
|
||||||
|
- 不要把短篇写成“长篇开头”
|
||||||
@@ -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") 保存更新后的完整扁平大纲
|
|
||||||
@@ -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. 继续写下一卷的章节
|
||||||
|
|||||||
86
references/differentiation.md
Normal file
86
references/differentiation.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 通用差异化设计参考
|
||||||
|
|
||||||
|
这份参考用于避免同题材作品自动滑向“最高频模板”。
|
||||||
|
|
||||||
|
## 当用户只给出一个大题材词时,不能直接套最常见范式
|
||||||
|
|
||||||
|
例如用户说:
|
||||||
|
|
||||||
|
- 都市
|
||||||
|
- 奇幻
|
||||||
|
- 修仙
|
||||||
|
- 悬疑
|
||||||
|
- 言情
|
||||||
|
- 科幻
|
||||||
|
|
||||||
|
这不等于“照该题材最常见的开局写”。你必须先补足差异化维度。
|
||||||
|
|
||||||
|
## 差异化的五个维度
|
||||||
|
|
||||||
|
### 1. 主角维度
|
||||||
|
|
||||||
|
- 出身是否过于常见
|
||||||
|
- 初始优势/劣势是否过于常见
|
||||||
|
- 主角最强驱动力是什么
|
||||||
|
- 主角最大的盲区是什么
|
||||||
|
|
||||||
|
### 2. 冲突维度
|
||||||
|
|
||||||
|
- 主冲突是否只是该题材默认矛盾
|
||||||
|
- 有没有第二层冲突改变读者预期
|
||||||
|
- 冲突是否会在中期转型
|
||||||
|
|
||||||
|
### 3. 世界维度
|
||||||
|
|
||||||
|
- 世界规则是否真的改变角色行为
|
||||||
|
- 社会结构、资源结构、权力结构是否能持续制造问题
|
||||||
|
- 世界是否存在非主角视角也合理运转的逻辑
|
||||||
|
|
||||||
|
### 4. 关系维度
|
||||||
|
|
||||||
|
- 主要关系是否只有“队友/恋人/敌人”三个静态功能
|
||||||
|
- 是否存在长期互相塑造、互相伤害、互相利用、互相成全的关系
|
||||||
|
- 关系线是否会反向推动主线
|
||||||
|
|
||||||
|
### 5. 节奏维度
|
||||||
|
|
||||||
|
- 爽点是否单一重复
|
||||||
|
- 是否规划了不同阶段的阅读驱动力
|
||||||
|
- 前期吸引力和中后期吸引力是否一致,还是有自然升级
|
||||||
|
|
||||||
|
## 常见同质化信号
|
||||||
|
|
||||||
|
出现越多,越说明作品在滑向通用模板:
|
||||||
|
|
||||||
|
- 最常见的主角出身设定
|
||||||
|
- 最常见的“被看不起”起手
|
||||||
|
- 最常见的导师/宗门/学院/豪门/案件开场
|
||||||
|
- 最常见的反派动机
|
||||||
|
- 最常见的阶段升级节奏
|
||||||
|
- 最常见的“发现秘密”型钩子反复出现
|
||||||
|
|
||||||
|
## 规划时必须主动给自己设限
|
||||||
|
|
||||||
|
在同题材下,至少给出 2-3 条反模板约束。例如:
|
||||||
|
|
||||||
|
- 不使用最常见的开局身份
|
||||||
|
- 不使用最常见的金手指/能力来源
|
||||||
|
- 不使用最常见的中期升级路径
|
||||||
|
- 不让主要关系线停留在单一功能
|
||||||
|
- 不让终局只是“打败更大的敌人”
|
||||||
|
|
||||||
|
## 差异化不是猎奇,而是重新分配重心
|
||||||
|
|
||||||
|
有效的差异化通常来自:
|
||||||
|
|
||||||
|
- 更换主角真正关心的东西
|
||||||
|
- 更换长期冲突的来源
|
||||||
|
- 更换世界规则的压力点
|
||||||
|
- 更换关系线在故事中的功能
|
||||||
|
- 更换中期之后的推进方式
|
||||||
|
|
||||||
|
## 输出前自问
|
||||||
|
|
||||||
|
- 如果把角色名和设定名抹掉,这个故事还像同题材里另外十本书吗?
|
||||||
|
- 如果只看前 10 章,读者能说出这本书“独特在哪”吗?
|
||||||
|
- 如果写到 50 章后,作品的推进方式会不会和前 10 章完全重复?
|
||||||
105
references/longform-planning.md
Normal file
105
references/longform-planning.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 通用长篇规划参考
|
||||||
|
|
||||||
|
这份参考用于“适合长篇连载”的题材,不限定具体品类。
|
||||||
|
|
||||||
|
## 长篇不是把短篇拉长
|
||||||
|
|
||||||
|
长篇的核心不是章节更多,而是具备长期展开能力。判断一部作品能否写长,关键看它是否具备以下“故事引擎”:
|
||||||
|
|
||||||
|
- **目标引擎**:主角会不断追求新的阶段目标
|
||||||
|
- **世界引擎**:世界规则、势力格局、资源结构可以持续制造新问题
|
||||||
|
- **关系引擎**:主要人物关系会持续演化,而不是定型后停滞
|
||||||
|
- **身份引擎**:主角的位置、身份、阵营、责任会变化
|
||||||
|
- **代价引擎**:每次成长都带来新的约束、损失或风险
|
||||||
|
|
||||||
|
如果这几个引擎都很弱,再多章节也只会变成重复灌水。
|
||||||
|
|
||||||
|
## 长篇推荐规划顺序
|
||||||
|
|
||||||
|
### 1. 作品卖点
|
||||||
|
|
||||||
|
先明确:
|
||||||
|
|
||||||
|
- 这本书最吸引读者的承诺是什么
|
||||||
|
- 它和同题材常见写法最不同的点是什么
|
||||||
|
- 读者为什么愿意跟随主角走到中后期
|
||||||
|
|
||||||
|
### 2. 长期冲突
|
||||||
|
|
||||||
|
不要只有一个“终极反派”。长篇更适合多阶段冲突:
|
||||||
|
|
||||||
|
- 近程冲突:当前生存、当前任务、当前阶段目标
|
||||||
|
- 中程冲突:势力博弈、关系重组、身份变化
|
||||||
|
- 远程冲突:世界真相、时代命题、终局选择
|
||||||
|
|
||||||
|
### 3. 卷级设计
|
||||||
|
|
||||||
|
每一卷至少要有一个明确功能,常见功能包括:
|
||||||
|
|
||||||
|
- 立足
|
||||||
|
- 扩张
|
||||||
|
- 试错
|
||||||
|
- 反噬
|
||||||
|
- 失去
|
||||||
|
- 转向
|
||||||
|
- 收束
|
||||||
|
- 终局
|
||||||
|
|
||||||
|
每卷不只升级强度,还要升级问题类型。
|
||||||
|
|
||||||
|
### 4. 弧级设计
|
||||||
|
|
||||||
|
每一弧都应该像“一个可独立成立的小故事”:
|
||||||
|
|
||||||
|
- 有明确目标
|
||||||
|
- 有明确阻力
|
||||||
|
- 有阶段转折
|
||||||
|
- 有结果与代价
|
||||||
|
|
||||||
|
### 5. 章节设计
|
||||||
|
|
||||||
|
章节不是平均分配事件,而是为弧服务:
|
||||||
|
|
||||||
|
- 关键推进章
|
||||||
|
- 关系变化章
|
||||||
|
- 代价兑现章
|
||||||
|
- 误判与反噬章
|
||||||
|
- 转折章
|
||||||
|
- 收束与引出下弧章
|
||||||
|
|
||||||
|
## 避免长篇同质化
|
||||||
|
|
||||||
|
### 错误做法
|
||||||
|
|
||||||
|
- 每一卷都只是“换地图 + 换敌人”
|
||||||
|
- 每次升级都只是“主角更强了”
|
||||||
|
- 中期仍然重复前期的爽点结构
|
||||||
|
- 配角只在需要时出现,没有独立动机
|
||||||
|
- 世界规则只在设定里写,剧情中不产生压力
|
||||||
|
|
||||||
|
### 正确做法
|
||||||
|
|
||||||
|
- 升级“冲突类型”,不只升级“敌人强度”
|
||||||
|
- 升级“选择代价”,不只升级“资源规模”
|
||||||
|
- 升级“关系复杂度”,不只升级“出场人数”
|
||||||
|
- 升级“命题”,不只升级“舞台大小”
|
||||||
|
|
||||||
|
## 中期转向必须提前规划
|
||||||
|
|
||||||
|
很多作品前 20 章能写,50 章后就开始重复,根因是没有中期转向。
|
||||||
|
|
||||||
|
在规划时必须提前想清楚:
|
||||||
|
|
||||||
|
- 第一次结构转向发生在什么时候
|
||||||
|
- 为什么前期方法在中期失效
|
||||||
|
- 主角到中期后必须学会什么新的思维方式
|
||||||
|
- 中后期的核心吸引力与前期有什么不同
|
||||||
|
|
||||||
|
## 长篇通用检查清单
|
||||||
|
|
||||||
|
- 这本书是否具备至少 3 个阶段性主矛盾?
|
||||||
|
- 前 3 卷是否各自承担不同功能?
|
||||||
|
- 主角的“得到”和“失去”是否同步增长?
|
||||||
|
- 主要配角是否会改变主线,而不是只被主角改变?
|
||||||
|
- 世界规则是否真的限制了剧情决策?
|
||||||
|
- 中期转向后,作品是否仍然成立?
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 长篇卷级检查清单
|
||||||
|
|
||||||
|
每一卷都要回答:
|
||||||
|
|
||||||
|
- 这一卷新增了什么世界信息?
|
||||||
|
- 这一卷升级了什么核心矛盾?
|
||||||
|
- 这一卷让主角得到什么,也失去什么?
|
||||||
|
- 这一卷如何改变主要人物关系?
|
||||||
|
- 这一卷结束后,故事为什么必须进入下一卷?
|
||||||
|
|
||||||
|
## 长篇弧级检查清单
|
||||||
|
|
||||||
|
每一弧都要回答:
|
||||||
|
|
||||||
|
- 这条弧的明确目标是什么?
|
||||||
|
- 阻力来自谁、什么规则、什么代价?
|
||||||
|
- 转折点是什么?
|
||||||
|
- 这条弧结束后,哪些状态发生了不可逆变化?
|
||||||
|
|
||||||
|
## 章节级检查清单
|
||||||
|
|
||||||
|
- 每章必须服务于所在弧的目标
|
||||||
|
- 每章必须包含一个不可删除的事件推进
|
||||||
|
- 钩子要多样化,不要全靠“发现秘密”一种模式
|
||||||
|
- 前期章节不能只是在“介绍世界”,必须同步推进人物和冲突
|
||||||
|
|||||||
@@ -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 的下一章
|
||||||
|
|||||||
@@ -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 校验当前章节是否允许提交。
|
||||||
|
|||||||
@@ -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/。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
tools/novel_context_test.go
Normal file
67
tools/novel_context_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 为 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 "保存设定" }
|
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)
|
||||||
|
|||||||
113
tools/save_foundation_test.go
Normal file
113
tools/save_foundation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user