commit 27bd85ef905de5e45a4619be817023447ec5ccca Author: voocel Date: Sat Mar 7 21:25:55 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffccb5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ + +# GoReleaser artifacts +dist/ +release-notes.md + +docs/ +refer/ diff --git a/app/agents.go b/app/agents.go new file mode 100644 index 0000000..f5383da --- /dev/null +++ b/app/agents.go @@ -0,0 +1,84 @@ +package app + +import ( + "github.com/voocel/agentcore" + "github.com/voocel/ainovel-cli/state" + "github.com/voocel/ainovel-cli/tools" +) + +// BuildCoordinator 组装 Coordinator Agent 及其 SubAgent。 +func BuildCoordinator( + cfg Config, + store *state.Store, + model agentcore.ChatModel, + refs tools.References, + prompts Prompts, + styles map[string]string, +) *agentcore.Agent { + // 共享工具 + contextTool := tools.NewContextTool(store, refs, cfg.Style) + + // Architect SubAgent 工具 + architectTools := []agentcore.Tool{ + contextTool, + tools.NewSaveFoundationTool(store), + } + + // Writer SubAgent 工具(V1: +polish_chapter +check_consistency) + writerTools := []agentcore.Tool{ + contextTool, + tools.NewPlanChapterTool(store), + tools.NewWriteSceneTool(store), + tools.NewPolishChapterTool(store), + tools.NewCheckConsistencyTool(store), + tools.NewCommitChapterTool(store), + } + + // Editor SubAgent 工具(V1) + editorTools := []agentcore.Tool{ + contextTool, + tools.NewSaveReviewTool(store), + } + + architect := agentcore.SubAgentConfig{ + Name: "architect", + Description: "世界构建师:生成小说前提、大纲和角色档案", + Model: model, + SystemPrompt: prompts.Architect, + Tools: architectTools, + MaxTurns: 10, + } + + // 动态拼接风格指令到 Writer prompt + writerPrompt := prompts.Writer + if style, ok := styles[cfg.Style]; ok { + writerPrompt += "\n\n" + style + } + + writer := agentcore.SubAgentConfig{ + Name: "writer", + Description: "场景写作者:逐场景完成一章的创作,包含打磨和一致性检查", + Model: model, + SystemPrompt: writerPrompt, + Tools: writerTools, + MaxTurns: 25, + } + + editor := agentcore.SubAgentConfig{ + Name: "editor", + Description: "全局审阅者:发现跨章结构问题,输出审阅结果", + Model: model, + SystemPrompt: prompts.Editor, + Tools: editorTools, + MaxTurns: 10, + } + + subagentTool := agentcore.NewSubAgentTool(architect, writer, editor) + + return agentcore.NewAgent( + agentcore.WithModel(model), + agentcore.WithSystemPrompt(prompts.Coordinator), + agentcore.WithTools(subagentTool, contextTool), + agentcore.WithMaxTurns(60), + ) +} diff --git a/app/config.go b/app/config.go new file mode 100644 index 0000000..c6ed351 --- /dev/null +++ b/app/config.go @@ -0,0 +1,56 @@ +package app + +import ( + "fmt" + "path/filepath" +) + +// Config 小说应用配置。 +type Config struct { + Prompt string // 用户的小说需求 + NovelName string // 小说名(用作输出目录名) + OutputDir string // 输出根目录,默认 output/{NovelName} + ModelName string // LLM 模型名 + APIKey string // API Key + BaseURL string // API Base URL(可选) + MaxChapters int // 最大章节数 + Style string // 写作风格(default/suspense/fantasy/romance) +} + +// Prompts 嵌入的提示词。 +type Prompts struct { + Coordinator string + Architect string + Writer string + Editor string +} + +// Validate 校验配置。 +func (c *Config) Validate() error { + if c.Prompt == "" { + return fmt.Errorf("prompt is required") + } + if c.APIKey == "" { + return fmt.Errorf("api key is required (set OPENAI_API_KEY)") + } + return nil +} + +// FillDefaults 填充默认值。 +func (c *Config) FillDefaults() { + if c.NovelName == "" { + c.NovelName = "novel" + } + if c.OutputDir == "" { + c.OutputDir = filepath.Join("output", c.NovelName) + } + if c.ModelName == "" { + c.ModelName = "gpt-4o" + } + if c.Style == "" { + c.Style = "default" + } + if c.MaxChapters <= 0 { + c.MaxChapters = 3 + } +} diff --git a/app/run.go b/app/run.go new file mode 100644 index 0000000..3013657 --- /dev/null +++ b/app/run.go @@ -0,0 +1,351 @@ +package app + +import ( + "bufio" + "fmt" + "log" + "os" + "slices" + "strings" + "time" + + "github.com/voocel/agentcore" + "github.com/voocel/agentcore/llm" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" + "github.com/voocel/ainovel-cli/tools" +) + +// Run 启动小说创作流程。 +func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error { + cfg.FillDefaults() + if err := cfg.Validate(); err != nil { + return err + } + + // 1. 初始化状态 + store := state.NewStore(cfg.OutputDir) + if err := store.Init(); err != nil { + return fmt.Errorf("init store: %w", err) + } + + // 2. 创建模型 + var baseURL []string + if cfg.BaseURL != "" { + baseURL = append(baseURL, cfg.BaseURL) + } + model, err := llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + + // 3. 组装 Coordinator + coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles) + + // 4. 确定性控制面:事件监听 + FollowUp 注入 + coordinator.Subscribe(func(ev agentcore.Event) { + switch ev.Type { + case agentcore.EventToolExecStart: + log.Printf("[tool:start] %s", ev.Tool) + + case agentcore.EventToolExecEnd: + if ev.IsError { + log.Printf("[tool:error] %s", ev.Tool) + return + } + log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200)) + + // 宿主确定性控制:SubAgent 完成后读取信号文件 + if ev.Tool == "subagent" { + handleSubAgentDone(coordinator, store, cfg.MaxChapters) + handleEditorDone(coordinator, store) + } + + case agentcore.EventMessageEnd: + if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant { + log.Printf("[assistant] %s", truncateLog(ev.Message.TextContent(), 300)) + } + + case agentcore.EventError: + log.Printf("[error] %v", ev.Err) + } + }) + + // 5. 初始化运行元信息(保留已有 SteerHistory) + if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { + log.Printf("[warn] 初始化运行元信息失败: %v", err) + } + + // 6. Steer 协程:stdin 读取用户干预 + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + text := strings.TrimSpace(scanner.Text()) + if text == "" { + continue + } + log.Printf("[steer] 用户干预: %s", text) + if err := store.AppendSteerEntry(domain.SteerEntry{ + Input: text, + Timestamp: time.Now().Format(time.RFC3339), + }); err != nil { + log.Printf("[warn] 追加干预记录失败: %v", err) + } + if err := store.SetPendingSteer(text); err != nil { + log.Printf("[warn] 设置待处理干预失败: %v", err) + } + if err := store.SetFlow(domain.FlowSteering); err != nil { + log.Printf("[warn] 设置流程状态失败: %v", err) + } + coordinator.Steer(agentcore.UserMsg(fmt.Sprintf( + "[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text))) + } + }() + + // 7. 恢复或启动(按优先级链) + progress, _ := store.LoadProgress() + runMeta, _ := store.LoadRunMeta() + if progress != nil && progress.InProgressChapter > 0 { + // 场景级恢复:章节写到一半 + ch := progress.InProgressChapter + scenes := len(progress.CompletedScenes) + log.Printf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes) + if err := coordinator.Prompt(fmt.Sprintf( + "第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", + ch, scenes, scenes+1, progress.TotalChapters, + )); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else if progress != nil && len(progress.PendingRewrites) > 0 { + // 重写恢复:有待重写章节 + log.Printf("重写恢复:%d 章待处理 %v", len(progress.PendingRewrites), progress.PendingRewrites) + verb := "重写" + if progress.Flow == domain.FlowPolishing { + verb = "打磨" + } + if err := coordinator.Prompt(fmt.Sprintf( + "有 %d 章待%s(受影响章节:%v)。原因:%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。", + len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters, + )); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else if progress != nil && progress.Flow == domain.FlowReviewing { + // 审阅恢复:审阅中断 + log.Printf("审阅恢复:上次审阅中断") + if err := coordinator.Prompt(fmt.Sprintf( + "上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。", + len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, + )); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else if progress != nil && progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" { + next := progress.NextChapter() + log.Printf("Steer 恢复:上次干预未完成,重新注入") + if err := coordinator.Prompt(fmt.Sprintf( + "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", + next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer, + )); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else if progress != nil && progress.IsResumable() { + next := progress.NextChapter() + log.Printf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)", + next, len(progress.CompletedChapters), progress.TotalWordCount) + if err := coordinator.Prompt(fmt.Sprintf( + "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。", + next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, + )); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else { + // 新建:初始化进度 + if err := store.InitProgress(cfg.NovelName, cfg.MaxChapters); err != nil { + return fmt.Errorf("init progress: %w", err) + } + log.Printf("新建模式:%s(%d 章)", cfg.NovelName, cfg.MaxChapters) + if err := coordinator.Prompt(fmt.Sprintf( + "请创作一部 %d 章的小说。要求如下:\n\n%s", + cfg.MaxChapters, cfg.Prompt, + )); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } + + // 6. 等待完成 + coordinator.WaitForIdle() + finalizeSteerIfIdle(store) + + // 7. 输出结果 + finalProgress, _ := store.LoadProgress() + if finalProgress != nil { + log.Printf("创作完成:%d 章,共 %d 字,输出目录:%s", + len(finalProgress.CompletedChapters), finalProgress.TotalWordCount, store.Dir()) + } + return nil +} + +// handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。 +// SubAgent 内部工具事件不冒泡,所以通过 meta/last_commit.json 传递信号。 +func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int) { + result, err := store.LoadLastCommit() + if err != nil || result == nil { + return // 不是 Writer 的 commit,可能是 Architect 的 SubAgent 调用 + } + // 消费即清除,防止重复注入 FollowUp + if err := store.ClearLastCommit(); err != nil { + log.Printf("[host] 清除 commit 信号失败: %v", err) + } + + log.Printf("[host] 章节提交信号:第 %d 章,%d 字,%d 个场景", + result.Chapter, result.WordCount, result.SceneCount) + + // 确定性判断 0:正在重写/打磨流程中 + progress, _ := store.LoadProgress() + if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) { + if !slices.Contains(progress.PendingRewrites, result.Chapter) { + log.Printf("[host] 警告:重写期间提交了非队列章节 %d,拒绝并提醒", result.Chapter) + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] 当前处于重写流程,但提交了非队列章节(第 %d 章)。请先完成待重写章节 %v 后再继续新章节。", + result.Chapter, progress.PendingRewrites))) + return + } + if err := store.CompleteRewrite(result.Chapter); err != nil { + log.Printf("[host] 完成重写标记失败: %v", err) + } + clearHandledSteer(store) + updated, _ := store.LoadProgress() + if updated != nil && len(updated.PendingRewrites) == 0 { + log.Printf("[host] 所有重写/打磨已完成,恢复正常写作") + if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { + log.Printf("[host] 保存检查点失败: %v", err) + } + if err := store.SaveCheckpoint("rewrite-done"); err != nil { + log.Printf("[host] 保存检查点失败: %v", err) + } + } else if updated != nil { + log.Printf("[host] 还有 %d 章待处理:%v", len(updated.PendingRewrites), updated.PendingRewrites) + if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { + log.Printf("[host] 保存检查点失败: %v", err) + } + } + return // 重写期间不触发全书完成/审阅判断 + } + + // 确定性判断 1:全书完成 + if result.NextChapter > maxChapters { + log.Printf("[host] 所有 %d 章已完成,注入完成指令", maxChapters) + if err := store.MarkComplete(); err != nil { + log.Printf("[host] 标记完成失败: %v", err) + } + clearHandledSteer(store) + if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { + log.Printf("[host] 保存检查点失败: %v", err) + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。", + maxChapters))) + return + } + + // 确定性判断 2:需要全局审阅 + if result.ReviewRequired { + log.Printf("[host] review_required=true(%s),注入审阅指令", result.ReviewReason) + if err := store.SetFlow(domain.FlowReviewing); err != nil { + log.Printf("[host] 设置审阅流程失败: %v", err) + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] review_required=true,%s。请调用 editor 对已完成章节进行全局审阅,然后根据审阅结果决定继续写第 %d 章还是修正已有章节。", + result.ReviewReason, result.NextChapter))) + } + clearHandledSteer(store) + if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { + log.Printf("[host] 保存检查点失败: %v", err) + } +} + +// handleEditorDone 在 Editor SubAgent 完成后读取审阅信号。 +func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) { + review, err := store.LoadLastReviewSignal() + if err != nil { + log.Printf("[host] 加载审阅信号失败: %v", err) + return + } + if review == nil { + return + } + // 消费即清除,防止重复注入 FollowUp + if err := store.ClearLastReview(); err != nil { + log.Printf("[host] 清除审阅信号失败: %v", err) + } + + log.Printf("[host] 审阅信号:verdict=%s,%d 个问题", review.Verdict, len(review.Issues)) + + chaptersInfo := "" + if len(review.AffectedChapters) > 0 { + chaptersInfo = fmt.Sprintf("受影响章节:%v。", review.AffectedChapters) + } + + switch review.Verdict { + case "rewrite": + if err := store.SetPendingRewrites(review.AffectedChapters, review.Summary); err != nil { + log.Printf("[host] 设置重写队列失败: %v", err) + } + if err := store.SetFlow(domain.FlowRewriting); err != nil { + log.Printf("[host] 设置流程状态失败: %v", err) + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] Editor 审阅结论:rewrite。%s%s请逐章调用 writer 重写受影响章节,全部完成后继续正常写作。", + review.Summary, chaptersInfo))) + case "polish": + if err := store.SetPendingRewrites(review.AffectedChapters, review.Summary); err != nil { + log.Printf("[host] 设置打磨队列失败: %v", err) + } + if err := store.SetFlow(domain.FlowPolishing); err != nil { + log.Printf("[host] 设置流程状态失败: %v", err) + } + coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( + "[系统] Editor 审阅结论:polish。%s%s请逐章调用 writer 打磨受影响章节,全部完成后继续正常写作。", + review.Summary, chaptersInfo))) + default: + // accept — 审阅通过,清除审阅状态 + if err := store.SetFlow(domain.FlowWriting); err != nil { + log.Printf("[host] 清除审阅状态失败: %v", err) + } + } + clearHandledSteer(store) + if err := store.SaveCheckpoint(fmt.Sprintf("review-ch%02d-%s", review.Chapter, review.Verdict)); err != nil { + log.Printf("[host] 保存检查点失败: %v", err) + } +} + +func truncateLog(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes]) + "..." +} + +func clearHandledSteer(store *state.Store) { + if err := store.ClearPendingSteer(); err != nil { + log.Printf("[host] 清除待处理干预失败: %v", err) + } + progress, _ := store.LoadProgress() + if progress != nil && progress.Flow == domain.FlowSteering { + if err := store.SetFlow(domain.FlowWriting); err != nil { + log.Printf("[host] 重置流程状态失败: %v", err) + } + } +} + +func finalizeSteerIfIdle(store *state.Store) { + runMeta, _ := store.LoadRunMeta() + progress, _ := store.LoadProgress() + if runMeta == nil || runMeta.PendingSteer == "" || progress == nil { + return + } + if progress.Flow != domain.FlowSteering { + return + } + clearHandledSteer(store) +} diff --git a/app/run_test.go b/app/run_test.go new file mode 100644 index 0000000..9fc86eb --- /dev/null +++ b/app/run_test.go @@ -0,0 +1,72 @@ +package app + +import ( + "testing" + + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +func TestFinalizeSteerIfIdleClearsPendingState(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + if err := store.InitProgress("test", 3); err != nil { + t.Fatalf("InitProgress: %v", err) + } + if err := store.SetFlow(domain.FlowSteering); err != nil { + t.Fatalf("SetFlow: %v", err) + } + if err := store.SetPendingSteer("主角改成女性"); err != nil { + t.Fatalf("SetPendingSteer: %v", err) + } + + finalizeSteerIfIdle(store) + + progress, err := store.LoadProgress() + if err != nil { + t.Fatalf("LoadProgress: %v", err) + } + if progress.Flow != domain.FlowWriting { + t.Fatalf("expected flow writing, got %s", progress.Flow) + } + + runMeta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if runMeta.PendingSteer != "" { + t.Fatalf("expected pending steer cleared, got %q", runMeta.PendingSteer) + } +} + +func TestFinalizeSteerIfIdleKeepsActiveFlow(t *testing.T) { + dir := t.TempDir() + store := state.NewStore(dir) + if err := store.InitProgress("test", 3); err != nil { + t.Fatalf("InitProgress: %v", err) + } + if err := store.SetFlow(domain.FlowRewriting); err != nil { + t.Fatalf("SetFlow: %v", err) + } + if err := store.SetPendingSteer("加入反转"); err != nil { + t.Fatalf("SetPendingSteer: %v", err) + } + + finalizeSteerIfIdle(store) + + progress, err := store.LoadProgress() + if err != nil { + t.Fatalf("LoadProgress: %v", err) + } + if progress.Flow != domain.FlowRewriting { + t.Fatalf("expected flow rewriting, got %s", progress.Flow) + } + + runMeta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if runMeta.PendingSteer != "加入反转" { + t.Fatalf("expected pending steer preserved, got %q", runMeta.PendingSteer) + } +} diff --git a/domain/chapter.go b/domain/chapter.go new file mode 100644 index 0000000..09b9dc7 --- /dev/null +++ b/domain/chapter.go @@ -0,0 +1,32 @@ +package domain + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// MergeScenes 将多个场景草稿按顺序合并为完整章节正文。 +// 返回合并后的正文和总字数(按 rune 计)。 +func MergeScenes(scenes []SceneDraft) (string, int) { + var b strings.Builder + for i, s := range scenes { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(s.Content) + } + content := b.String() + return content, utf8.RuneCountInString(content) +} + +// ReviewInterval 全局审阅间隔(每 N 章触发一次)。 +const ReviewInterval = 5 + +// ShouldReview 根据已完成章节数判断是否需要全局审阅。 +func ShouldReview(completedCount int) (bool, string) { + if completedCount > 0 && completedCount%ReviewInterval == 0 { + return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount) + } + return false, "" +} diff --git a/domain/review.go b/domain/review.go new file mode 100644 index 0000000..b7bedeb --- /dev/null +++ b/domain/review.go @@ -0,0 +1,51 @@ +package domain + +// TimelineEvent 时间线事件。 +type TimelineEvent struct { + Chapter int `json:"chapter"` + Time string `json:"time"` + Event string `json:"event"` + Characters []string `json:"characters,omitempty"` +} + +// ForeshadowEntry 伏笔条目。 +type ForeshadowEntry struct { + ID string `json:"id"` + Description string `json:"description"` + PlantedAt int `json:"planted_at"` + Status string `json:"status"` // planted / advanced / resolved + ResolvedAt int `json:"resolved_at,omitempty"` +} + +// ForeshadowUpdate 伏笔增量操作。 +type ForeshadowUpdate struct { + ID string `json:"id"` + Action string `json:"action"` // plant / advance / resolve + Description string `json:"description,omitempty"` +} + +// RelationshipEntry 人物关系条目。 +type RelationshipEntry struct { + CharacterA string `json:"character_a"` + CharacterB string `json:"character_b"` + Relation string `json:"relation"` + Chapter int `json:"chapter"` +} + +// ConsistencyIssue 一致性问题。 +type ConsistencyIssue struct { + Type string `json:"type"` // timeline / foreshadow / relationship / character + Severity string `json:"severity"` // error / warning + Description string `json:"description"` + Suggestion string `json:"suggestion,omitempty"` +} + +// ReviewEntry Editor 的审阅条目。 +type ReviewEntry struct { + Chapter int `json:"chapter"` + Scope string `json:"scope"` // chapter / global + Issues []ConsistencyIssue `json:"issues"` + Verdict string `json:"verdict"` // accept / polish / rewrite + Summary string `json:"summary"` + AffectedChapters []int `json:"affected_chapters,omitempty"` // 需要重写/打磨的章节号 +} diff --git a/domain/runtime.go b/domain/runtime.go new file mode 100644 index 0000000..48c99ab --- /dev/null +++ b/domain/runtime.go @@ -0,0 +1,73 @@ +package domain + +// Phase 表示小说创作阶段。 +type Phase string + +const ( + PhaseInit Phase = "init" + PhasePremise Phase = "premise" + PhaseOutline Phase = "outline" + PhaseWriting Phase = "writing" + PhaseComplete Phase = "complete" +) + +// FlowState 当前活动流程类型,用于 checkpoint 恢复。 +type FlowState string + +const ( + FlowWriting FlowState = "writing" + FlowReviewing FlowState = "reviewing" + FlowRewriting FlowState = "rewriting" + FlowPolishing FlowState = "polishing" + FlowSteering FlowState = "steering" +) + +// Progress 进度追踪,持久化到 meta/progress.json。 +type Progress struct { + NovelName string `json:"novel_name"` + Phase Phase `json:"phase"` + CurrentChapter int `json:"current_chapter"` + TotalChapters int `json:"total_chapters"` + CompletedChapters []int `json:"completed_chapters"` + TotalWordCount int `json:"total_word_count"` + ChapterWordCounts map[int]int `json:"chapter_word_counts,omitempty"` // 每章字数,支持重写时修正总字数 + InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复) + CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号 + Flow FlowState `json:"flow,omitempty"` // 当前流程 + PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列 + RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 +} + +// IsResumable 判断是否可以从断点恢复。 +func (p *Progress) IsResumable() bool { + return p.Phase == PhaseWriting && p.CurrentChapter > 0 +} + +// NextChapter 返回下一个要写的章节号。 +func (p *Progress) NextChapter() int { + if len(p.CompletedChapters) == 0 { + return 1 + } + max := 0 + for _, ch := range p.CompletedChapters { + if ch > max { + max = ch + } + } + return max + 1 +} + +// RunMeta 运行元信息,持久化到 meta/run.json。 +type RunMeta struct { + StartedAt string `json:"started_at"` + Style string `json:"style"` + Model string `json:"model"` + SteerHistory []SteerEntry `json:"steer_history,omitempty"` + PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入 +} + +// SteerEntry 用户干预记录。 +type SteerEntry struct { + Input string `json:"input"` + Timestamp string `json:"timestamp"` +} diff --git a/domain/story.go b/domain/story.go new file mode 100644 index 0000000..e592c22 --- /dev/null +++ b/domain/story.go @@ -0,0 +1,32 @@ +package domain + +// Novel 小说元信息。 +type Novel struct { + Name string `json:"name"` + TotalChapters int `json:"total_chapters"` +} + +// OutlineEntry 大纲条目,对应一章。 +type OutlineEntry struct { + Chapter int `json:"chapter"` + Title string `json:"title"` + CoreEvent string `json:"core_event"` + Hook string `json:"hook"` + Scenes []string `json:"scenes"` +} + +// Character 角色档案。 +type Character struct { + Name string `json:"name"` + Role string `json:"role"` + Description string `json:"description"` + Arc string `json:"arc"` + Traits []string `json:"traits"` +} + +// WorldRule 世界观规则条目。 +type WorldRule struct { + Category string `json:"category"` // magic / technology / geography / society / other + Rule string `json:"rule"` // 规则描述 + Boundary string `json:"boundary"` // 不可违反的边界 +} diff --git a/domain/writing.go b/domain/writing.go new file mode 100644 index 0000000..5977808 --- /dev/null +++ b/domain/writing.go @@ -0,0 +1,48 @@ +package domain + +// ChapterPlan 章节规划,写入 drafts/{ch}.plan.json。 +type ChapterPlan struct { + Chapter int `json:"chapter"` + Title string `json:"title"` + Goal string `json:"goal"` + Conflict string `json:"conflict"` + Scenes []ScenePlan `json:"scenes"` + Hook string `json:"hook"` + EmotionArc string `json:"emotion_arc,omitempty"` +} + +// ScenePlan 场景规划。 +type ScenePlan struct { + Index int `json:"index"` + Summary string `json:"summary"` + POV string `json:"pov,omitempty"` + Location string `json:"location,omitempty"` +} + +// SceneDraft 场景草稿。 +type SceneDraft struct { + Chapter int `json:"chapter"` + Scene int `json:"scene"` + Content string `json:"content"` + WordCount int `json:"word_count"` +} + +// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。 +type ChapterSummary struct { + Chapter int `json:"chapter"` + Summary string `json:"summary"` + Characters []string `json:"characters"` + KeyEvents []string `json:"key_events"` +} + +// CommitResult 是 commit_chapter 工具的结构化返回值。 +// 宿主程序和 Coordinator 读取此信号做控制决策。 +type CommitResult struct { + Chapter int `json:"chapter"` + Committed bool `json:"committed"` + WordCount int `json:"word_count"` + SceneCount int `json:"scene_count"` + NextChapter int `json:"next_chapter"` + ReviewRequired bool `json:"review_required"` + ReviewReason string `json:"review_reason,omitempty"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f816f59 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/voocel/ainovel-cli + +go 1.25.5 + +require github.com/voocel/agentcore v1.5.1 + +require github.com/voocel/litellm v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e4d1f3b --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/voocel/agentcore v1.5.1 h1:gEVpBXZfXH4fkq4fLISo2dYfoQ+SaJ0NsetU/Y0hKrI= +github.com/voocel/agentcore v1.5.1/go.mod h1:fjksENApgfL1QXbcJY8RUUU5Gl03YOYExFAZ040X/zU= +github.com/voocel/litellm v1.6.0 h1:jc0Y7q+cp6QQcag3Mhmd6wMKkfzf7mXjXY0Uvj5VBQw= +github.com/voocel/litellm v1.6.0/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5e1ec2b --- /dev/null +++ b/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "embed" + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/app" + "github.com/voocel/ainovel-cli/tools" +) + +//go:embed prompts/*.md +var promptsFS embed.FS + +//go:embed references +var referencesFS embed.FS + +//go:embed styles/*.md +var stylesFS embed.FS + +func main() { + prompt := parsePrompt() + if prompt == "" { + fmt.Fprintf(os.Stderr, "用法: novel <小说需求描述>\n") + fmt.Fprintf(os.Stderr, "示例: novel \"写一部3章的都市悬疑短篇,讲述一个程序员在深夜收到神秘代码后卷入一场阴谋\"\n") + os.Exit(1) + } + + style := envOr("NOVEL_STYLE", "default") + refs := loadReferences(style) + prompts := loadPrompts() + styles := loadStyles() + + cfg := app.Config{ + Prompt: prompt, + NovelName: "novel", + APIKey: os.Getenv("OPENAI_API_KEY"), + BaseURL: os.Getenv("OPENAI_BASE_URL"), + ModelName: envOr("MODEL_NAME", ""), + Style: style, + } + if v := os.Getenv("MAX_CHAPTERS"); v != "" { + fmt.Sscanf(v, "%d", &cfg.MaxChapters) + } + + if err := app.Run(cfg, refs, prompts, styles); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func parsePrompt() string { + if len(os.Args) < 2 { + return "" + } + return strings.Join(os.Args[1:], " ") +} + +func loadReferences(style string) tools.References { + refs := tools.References{ + ChapterGuide: mustRead(referencesFS, "references/chapter-guide.md"), + HookTechniques: mustRead(referencesFS, "references/hook-techniques.md"), + QualityChecklist: mustRead(referencesFS, "references/quality-checklist.md"), + OutlineTemplate: mustRead(referencesFS, "references/outline-template.md"), + CharacterTemplate: mustRead(referencesFS, "references/character-template.md"), + ChapterTemplate: mustRead(referencesFS, "references/chapter-template.md"), + Consistency: mustRead(referencesFS, "references/consistency.md"), + ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"), + DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.md"), + } + // 加载风格补充参考(可选) + if style != "" && style != "default" { + path := "references/" + style + "/style-references.md" + if data, err := referencesFS.ReadFile(path); err == nil { + refs.StyleReference = string(data) + } + } + return refs +} + +func loadPrompts() app.Prompts { + return app.Prompts{ + Coordinator: mustRead(promptsFS, "prompts/coordinator.md"), + Architect: mustRead(promptsFS, "prompts/architect.md"), + Writer: mustRead(promptsFS, "prompts/writer.md"), + Editor: mustRead(promptsFS, "prompts/editor.md"), + } +} + +func mustRead(fs embed.FS, path string) string { + data, err := fs.ReadFile(path) + if err != nil { + panic(fmt.Sprintf("embed read %s: %v", path, err)) + } + return string(data) +} + +func loadStyles() map[string]string { + styles := make(map[string]string) + entries, err := stylesFS.ReadDir("styles") + if err != nil { + return styles + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := strings.TrimSuffix(e.Name(), ".md") + data, err := stylesFS.ReadFile("styles/" + e.Name()) + if err != nil { + continue + } + styles[name] = string(data) + } + return styles +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/prompts/architect.md b/prompts/architect.md new file mode 100644 index 0000000..58c4ce4 --- /dev/null +++ b/prompts/architect.md @@ -0,0 +1,106 @@ +你是小说世界构建师。你负责从用户需求出发,构建小说的基础设定。 + +## 你的工具 + +- **novel_context**: 获取参考模板和当前状态 +- **save_foundation**: 保存基础设定 + +## 工作流程 + +### 1. 获取模板 + +先调用 novel_context(不传 chapter 参数)获取大纲模板和角色模板。 + +### 2. 生成 Premise + +基于用户需求,撰写故事前提(Markdown 格式),包含: +- 题材和基调 +- 核心冲突 +- 主角目标 +- 结局方向 +- 写作禁区(不应出现的内容) + +调用 save_foundation(type="premise", content=) + +### 3. 生成 Outline + +基于 premise 生成章节大纲(JSON 格式),每章包含: +- chapter: 章节号 +- title: 章节标题 +- core_event: 核心事件 +- hook: 章末钩子 +- scenes: 场景概述列表(3-5 个场景) + +调用 save_foundation(type="outline", content=) + +示例: +```json +[ + { + "chapter": 1, + "title": "暗夜来客", + "core_event": "主角在暴雨夜收到神秘包裹", + "hook": "包裹里是一张二十年前失踪案的照片", + "scenes": ["雨夜独处", "快递到来", "打开包裹", "照片特写"] + } +] +``` + +### 4. 生成 Characters + +基于 premise 和 outline 生成角色档案(JSON 格式),每个角色包含: +- name: 姓名 +- role: 角色定位(主角/配角/反派) +- description: 外貌与性格描写 +- arc: 角色弧线(从A到B的变化) +- traits: 标签特征列表 + +调用 save_foundation(type="characters", content=) + +### 5. 生成 World Rules + +基于 premise 和世界观设定,生成世界规则(JSON 格式),每条规则包含: +- category: 规则类别(magic / technology / geography / society / other) +- rule: 规则描述 +- boundary: 不可违反的边界 + +调用 save_foundation(type="world_rules", content=) + +示例: +```json +[ + { + "category": "magic", + "rule": "法术需要消耗精神力,精神力与修炼等级成正比", + "boundary": "不存在无消耗的法术,精神力耗尽会导致昏迷" + }, + { + "category": "society", + "rule": "王国实行严格的等级制度,平民不得直视贵族", + "boundary": "没有例外,违反者会被当场处刑" + } +] +``` + +注意:不是所有小说都需要复杂的世界规则。现实题材可以只记录少量社会规则或物理限制。 + +## 增量修改模式 + +当任务中提到"增量修改"或"在现有设定基础上修改"时: + +1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules +2. 仅修改受影响的部分,保持未受影响部分不变 +3. 特别注意:已完成章节的设定不应产生矛盾 +4. 修改 outline 时,已完成章节的大纲条目保持不变(除非明确要求重写) +5. 修改 characters 时,保持角色已展示的特征不变,只调整后续发展 +6. 修改 world_rules 时,不得删除已在正文中体现的规则,只能新增或放宽边界 + +所有被修改的设定都必须用 save_foundation 保存完整版本(全量覆盖),包括 world_rules。 +未修改的设定无需重新保存。 + +## 注意事项 + +- 大纲的场景拆分要具体,不要笼统 +- 每章至少 3 个场景 +- 角色弧线要有变化,不要扁平 +- 钩子要制造悬念,吸引读者继续阅读 diff --git a/prompts/coordinator.md b/prompts/coordinator.md new file mode 100644 index 0000000..b0e0f28 --- /dev/null +++ b/prompts/coordinator.md @@ -0,0 +1,98 @@ +你是一个长篇小说创作的总协调者。你通过调度子 Agent 完成整本小说的创作。 + +## 你的工具 + +- **subagent**: 调度 architect、writer 和 editor 子 Agent +- **novel_context**: 检查当前创作状态 + +## 工作流程 + +### 第一阶段:基础设定 + +调用 architect 完成基础设定: + +```json +{"agent": "architect", "task": "根据以下需求生成小说基础设定(premise、outline、characters):\n\n<用户需求>"} +``` + +architect 完成后,用 novel_context 确认设定已保存。 + +### 第二阶段:逐章写作 + +从第 1 章开始,逐章调用 writer: + +```json +{"agent": "writer", "task": "写第 N 章"} +``` + +每次只调用一个 writer 写一章。writer 完成后继续下一章。 + +### 第三阶段:全局审阅(收到系统审阅指令时) + +收到 `[系统] review_required` 消息后,调用 editor 进行全局审阅: + +```json +{"agent": "editor", "task": "对已完成的章节进行全局审阅,最新章节为第 N 章"} +``` + +### 第三阶段 B:审阅后处理 + +收到 `[系统] Editor 审阅结论` 消息后,按 verdict 处理: + +- **accept**: 继续写下一章 +- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨。每次调用: + ```json + {"agent": "writer", "task": "打磨第 N 章。审阅意见:"} + ``` +- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写。每次调用: + ```json + {"agent": "writer", "task": "重写第 N 章。重写原因:"} + ``` + 重写完成后回到正常写作流程,继续写下一个未完成章节 + +**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。中断退出后重启会自动恢复到重写状态。 + +### 系统消息 + +宿主程序会在关键节点注入 `[系统]` 消息: + +- **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer +- **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅 + +你必须遵守系统消息中的确定性指令(如"不要再调用 writer")。 + +### 第四阶段:完成 + +收到系统完成指令后,输出全书总结: +- 总章数和总字数 +- 各章概要 +- 主要角色弧线 +- 伏笔回收情况 + +### 用户干预(Steer) + +收到 `[用户干预]` 消息后: + +1. **评估影响范围**:判断用户的修改要求影响哪些内容 +2. **更新设定**(如需要):调用 architect 更新 premise、outline 或 characters + ```json + {"agent": "architect", "task": "用户要求修改:<干预内容>。请在现有设定基础上做增量修改,保持已完成章节的一致性。"} + ``` +3. **重写章节**(如需要):如果已完成章节受到影响,逐章调用 writer 重写 +4. **继续写作**:从下一个未完成章节继续 + +## 恢复指示 + +- 收到"从第 N 章继续写作"的指示:跳过第一阶段,直接从第 N 章开始逐章写作 +- 收到"第 N 章正在进行中,已完成 M 个场景"的指示:调用 writer 从场景 M+1 继续该章写作 +- 收到"有 N 章待重写"的指示:逐章调用 writer 重写/打磨受影响章节,**全部完成后**才能继续写新章节 +- 收到"上次审阅中断"的指示:重新调用 editor 进行全局审阅 + +## 注意事项 + +- 不要自己写正文,正文由 writer 完成 +- 不要自己创建设定,设定由 architect 完成 +- 不要自己做审阅,审阅由 editor 完成 +- 你的职责是调度和决策,不是创作 +- 章节完成/全书终止的判断由宿主程序通过系统消息控制 +- 重写章节时,writer 的流程与新写相同,旧文件会自动覆盖 diff --git a/prompts/editor.md b/prompts/editor.md new file mode 100644 index 0000000..bf9e2fb --- /dev/null +++ b/prompts/editor.md @@ -0,0 +1,49 @@ +你是小说全局审阅者。你负责发现跨章和全局结构问题,不直接修改正文。 + +## 你的工具 + +- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系) +- **save_review**: 保存审阅结果 + +## 工作流程 + +### 1. 获取上下文 +调用 novel_context(chapter=最新章节号),获取全部状态数据。 + +### 2. 审阅重点 + +逐项检查: + +**时间线一致性** +- 事件发生顺序是否合理 +- 时间跨度是否自洽 + +**伏笔管理** +- 是否有长期未回收的伏笔(超过 5 章未推进视为遗忘风险) +- 新伏笔是否有回收计划 + +**人物关系** +- 关系变化是否自然 +- 角色行为是否符合其性格设定和弧线 + +**结构问题** +- 节奏是否失衡(某几章过快或过慢) +- 主线是否推进 +- 是否存在重复或矛盾 + +### 3. 输出审阅 +调用 save_review,给出: +- issues:发现的具体问题列表,每个问题包含类型、严重程度、描述和修改建议 +- verdict:审阅结论 + - `accept`:质量合格,可以继续写 + - `polish`:存在细节问题,建议对特定章节做打磨 + - `rewrite`:存在结构性问题,建议重写特定章节 +- summary:审阅总结(200字以内) +- affected_chapters:需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填) + +## 注意事项 + +- 不要自己修改正文 +- 不要输出空洞的表扬,只关注问题 +- severity=error 的问题必须修复,severity=warning 的可以后续处理 +- 如果没有发现问题,verdict 应为 accept diff --git a/prompts/writer.md b/prompts/writer.md new file mode 100644 index 0000000..8b143c0 --- /dev/null +++ b/prompts/writer.md @@ -0,0 +1,81 @@ +你是小说场景写作者。你负责逐场景地完成一章的创作。 + +## 你的工具 + +- **novel_context**: 获取当前章节的创作上下文 +- **plan_chapter**: 创建章节写作规划 +- **write_scene**: 写入单个场景 +- **polish_chapter**: 保存打磨后的完整章节正文 +- **check_consistency**: 检查章节与全局状态的一致性 +- **commit_chapter**: 提交完成的章节 + +## 写作流水线 + +严格按以下顺序执行,不可跳步: + +### 1. 获取上下文 +调用 novel_context(chapter=N) 获取: +- 故事前提、大纲、角色档案 +- 前几章摘要 +- 时间线、伏笔账本、人物关系(用于保持一致性) +- 写作参考资料 + +### 2. 规划章节 +调用 plan_chapter,基于大纲拆分为 3-5 个场景,明确每个场景的目标、视角和地点。 + +### 3. 逐场景写作 +对每个场景依次调用 write_scene。 + +**场景写作要求**: +- 每个场景 800-1500 字 +- 第一个场景的前 20% 必须出现冲突或悬念 +- 以具体的动作、对话或感官描写开场,不要用抽象描述 +- 对话要体现人物性格,避免说教式对白 +- 用细节和动作推动情节,不用概述和总结 +- 场景之间自然过渡 + +### 4. 打磨章节 +将所有场景合并,进行整体打磨,然后调用 polish_chapter 保存: +- **去 AI 味**:不用"不禁"、"竟然"、"仿佛"等滥用词,不用排比三连,控制形容词密度 +- **对话自然化**:体现人物性格差异,加入潜台词和动作穿插 +- **细节具象化**:用五感描写替代抽象概述 +- **节奏调整**:关键转折放慢,过渡段落紧凑 + +### 5. 一致性检查 +调用 check_consistency(chapter=N),检查是否有矛盾: +- 如果发现 error 级别问题,回到第 3 步修正相关场景,重新打磨 +- 如果只有 warning,记录后继续 + +### 6. 提交章节 +调用 commit_chapter,提供: +- summary: 本章内容摘要(200字以内) +- characters: 本章出场角色名列表 +- key_events: 本章关键事件列表 +- timeline_events: 本章发生的时间线事件 +- foreshadow_updates: 伏笔操作(plant 埋设 / advance 推进 / resolve 回收) +- relationship_changes: 人物关系变化 + +## 重写模式 + +当任务中包含"重写"或"打磨"指令时: +- 流水线与新写完全相同:context → plan → write_scene × N → polish → consistency → commit +- 旧的 plan、scene、polished 文件会被自然覆盖 +- commit_chapter 会自动修正字数统计 +- 重点关注审阅意见中指出的问题,确保修正到位 + +## 场景恢复模式 + +当任务中提到"从场景 M 继续"时: +- 调用 novel_context 获取上下文 +- 检查已有的 chapter plan 和已完成场景 +- 跳过已完成的场景,从指定场景编号开始写作 +- 后续流程不变:完成所有场景 → polish → consistency → commit + +## 注意事项 + +- 严格场景级写作,一次只写一个场景 +- 不要整章一起写然后拆分 +- 章末必须有悬念钩子 +- 保持与前几章的连贯性 +- 字数不够时用具体细节扩展,不用水话填充 +- 注意时间线连贯和伏笔管理 diff --git a/references/chapter-guide.md b/references/chapter-guide.md new file mode 100644 index 0000000..40a9417 --- /dev/null +++ b/references/chapter-guide.md @@ -0,0 +1,261 @@ +# 章节写作指南 + +每一章都应该是一个完整的叙事单元,同时推动整体故事向前发展。 + +## ⚠️ 关键原则:前 20% 决定生死 + +**读者在前 20% 的内容决定是否继续阅读。** + +如果开头不够吸引人,读者会放弃,无论后面写得多么精彩。 + +### 前 20% 必须达到的效果 + +1. **即时紧张感** - 读者立即感受到危机/冲突 +2. **重大事件** - 发生推动剧情的重要事情 +3. **情感冲击** - 读者产生强烈情绪(好奇、震惊、担忧) +4. **继续阅读的欲望** - 迫切想知道接下来发生什么 + +### 开头致命错误(绝对避免) + +| 错误类型 | 示例 | 为什么致命 | +|---------|------|-----------| +| 天气描写 | "那天天气晴朗,万里无云..." | 与故事无关,读者无耐心 | +| 日常流程 | "李明醒来,刷牙洗脸,吃早餐..." | 无聊,没有冲突 | +| 回顾上章 | "上一章我们说到..." | 读者已经知道,浪费时间 | +| 缓慢铺垫 | "先介绍一下这个城市的背景..." | 信息倾倒,没有行动 | +| 平淡对话 | "你好,你好吗?我很好。" | 无意义对话,没有张力 | +| 过度解释 | "这是因为,所以,然后..." | 讲述而非展示 | + +--- + +## 十种强力开头技巧 + +### 1. 行动中开场(In Media Res) + +直接从冲突/动作的高潮点开始。 + +**示例:** +> 子弹擦过他的耳边,击碎了身后的花瓶。李明没有回头,翻滚躲到沙发后面。 + +**为什么有效:** 立即建立紧张感,读者想知道为什么被打。 + +### 2. 反常情境 + +呈现一个不符合常理的场景,激发好奇。 + +**示例:** +> 死人坐在办公桌前,正在写一份报告。 + +**为什么有效:** 不可能的事物,读者想了解怎么回事。 + +### 3. 震撼对话 + +用一句惊人的对话开场。 + +**示例:** +> "我把孩子卖了。" 妻子平静地说,继续翻阅杂志。 + +**为什么有效:** 立即制造情感冲击和悬念。 + +### 4. 倒计时开场 + +从时间压力开始。 + +**示例:** +> 还有三分钟,炸弹就会爆炸。而拆弹包里少了一根红线。 + +**为什么有效:** 紧迫感,读者想知道能否及时。 + +### 5. 重大发现 + +从发现关键线索/真相开始。 + +**示例:** +> 法医报告摊在桌上。死因不是意外,是精心策划的谋杀。而嫌疑人只有一个——他自己。 + +**为什么有效:** 重大转折,重新理解之前的事件。 + +### 6. 危机时刻 + +从角色面临最大危机开始。 + +**示例:** +> 门被踹开的瞬间,她知道藏不住了。保险箱里的秘密即将曝光。 + +**为什么有效:** 高风险,读者关心角色命运。 + +### 7. 谜团浮现 + +从无法解释的现象开始。 + +**示例:** +> 醒来时,他发现右手背上出现了一个从未见过的纹身。而他不记得昨晚发生了什么。 + +**为什么有效:** 神秘感,读者想解开谜题。 + +### 8. 背叛开场 + +从背叛/信任崩塌开始。 + +**示例:** +> 枪口对准他的后背。"对不起," 他信任了十年的搭档说,"他们给的太多了。" + +**为什么有效:** 情感冲击,角色陷入绝境。 + +### 9. 重大选择 + +从角色的艰难决定开始。 + +**示例:** +> 救生艇只能载一个人。她的丈夫和女儿都在冰冷的海水里挣扎。她伸出手—— + +**为什么有效:** 道德困境,读者想知道她如何选择。 + +### 10. 结局预告 + +从未来的某个关键时刻开始,然后倒叙。 + +**示例:** +> 三天后,所有人都会后悔今天的决定。但此刻,会议室里的每个人都在微笑。 + +**为什么有效:** 预示灾难,读者想知道如何发生。 + +--- + +## 标准章节结构 + +### 1. 开头钩子(前 20%)⚠️ 最关键 + +**必须包含:** +- ✅ 即时冲突/危机 +- ✅ 重大事件/信息 +- ✅ 强烈情感冲击 +- ✅ 行动场景 + +**使用上述十种技巧之一,或组合使用。** + +### 2. 发展推进(中间 50-60%) + +本章的核心内容,必须推进剧情或深化人物。 + +**推进方式:** +- **新信息揭示**:让读者/主角获得重要信息 +- **关系变化**:人物关系发生转变 +- **问题升级**:现状恶化,新危机出现 +- **角色成长**:主角获得新技能、新认知 + +**避免:** +- 纯粹的场景描写(风景、房间布局等) +- 与剧情无关的人物互动 +- 重复已知的对话 + +### 3. 高潮时刻(后 15-20%) + +本章的情感或动作最高点。 + +**高潮类型:** +- **动作高潮**:战斗、追逐、对抗 +- **情感高潮**:重大发现、背叛、告白、牺牲 +- **心理高潮**:主角的内心转折点 + +### 4. 结尾钩子(最后 5-10%) + +留下悬念,让读者想看下一章。详见 [hook-techniques.md](hook-techniques.md) + +--- + +## 章节类型分类 + +### 情节推进章 + +**目的**:推动主线剧情发展 +**特征**:有明确的事件进展、重要信息揭示 +**示例**:主角发现线索、敌人发动攻击、盟友背叛 + +### 人物深化章 + +**目的**:深化读者对人物的理解 +**特征**:揭示人物背景、动机、内心冲突 +**示例**:回忆片段、私密对话、独处时刻 +**注意**:必须与主线相关,不能是纯粹的人物小传 + +### 氛围营造章 + +**目的**:建立特定情绪或紧张感 +**特征**:注重感官描写、节奏控制 +**示例**:暴风雨前的宁静、潜伏行动、等待审判 + +### 过渡衔接章 + +**目的**:连接两个重大事件 +**特征**:信息整理、位置转换、时间跳跃 +**注意**:保持简洁,避免拖沓 + +--- + +## 章节节奏控制 + +### 节奏变化 + +同一章内应包含节奏变化: + +``` +紧张 → 缓解 → 新紧张 → 更紧张 +``` + +示例: +``` +紧张:主角被追捕 +缓解:躲进安全屋,短暂喘息 +新紧张:发现安全屋已被入侵 +更紧张:必须立即逃离 +``` + +### 信息密度 + +- **高密度**:动作场面、大量对话、快速事件 +- **低密度**:内心独白、环境描写、情感沉淀 + +**原则**:高低密度交替,避免持续高密度(读者疲劳)或持续低密度(读者无聊) + +--- + +## 章节长度与内容密度对照 + +| 章节字数 | 核心事件数量 | 场景数量 | +|---------|-------------|---------| +| 800-1500 | 1 个主要事件 | 1-2 个场景 | +| 1500-3000 | 1-2 个主要事件 | 2-3 个场景 | +| 3000-5000 | 2-3 个主要事件 | 3-5 个场景 | + +**原则**:每章至少包含一个不可删除的核心事件。如果一个事件可以移除而不影响理解,则应删除。 + +--- + +## 章节写作检查清单 + +撰写每章后自查: + +### ⚠️ 开头检查(最关键) +- [ ] **前 20% 是否极其吸引人?**(如果不是,重写) +- [ ] 是否在第一段就建立冲突/紧张? +- [ ] 是否有重大事件或信息揭示? +- [ ] 是否有强烈的情感冲击? +- [ ] 读者是否会迫切想知道接下来发生什么? +- [ ] 是否避免了所有"致命错误"?(天气、日常、回顾等) + +### 内容检查 +- [ ] 本章是否推进了主线剧情或深化了人物? +- [ ] 是否有冲突或转折? +- [ ] 对话是否推动情节或揭示人物? +- [ ] 是否展示了而非讲述了关键信息? +- [ ] 结尾是否留下悬念钩子? +- [ ] 是否为下一章埋下伏笔? + +### 开头自测问题 +如果对开头有任何问题回答"否",必须重写: +1. 读者读了前三段后,会想继续读吗? +2. 开头是否有冲突或危机? +3. 开头是否有意外或转折? +4. 开头是否让读者产生强烈情绪? +5. 开头是否避免了平淡的日常/天气/背景说明? diff --git a/references/chapter-template.md b/references/chapter-template.md new file mode 100644 index 0000000..9e35e5e --- /dev/null +++ b/references/chapter-template.md @@ -0,0 +1,19 @@ +# 第[X]章:[章节标题] + +## 本章概要 +- **核心事件**:[一句话概括本章发生的事] +- **承接上章**:[回应上一章的悬念] +- **悬念钩子**:[本章结尾的钩子] + +--- + +## 正文 + +[章节正文内容 3000-5000 字,最低不低于 2500 字] + +--- + +## 章节备注 +- 本章悬念:[简述结尾钩子] +- 下章预告:[可选,1-2句话] +- 伏笔标记:[如果埋下伏笔,在此记录] diff --git a/references/character-building.md b/references/character-building.md new file mode 100644 index 0000000..37a4b1d --- /dev/null +++ b/references/character-building.md @@ -0,0 +1,220 @@ +# 人物塑造原则 + +好的人物是故事的灵魂。读者记住的是人,不是情节。 + +## 人物档案模板 + +每个主要角色都应建立完整档案: + +### 基本信息 + +```text +姓名:(有意义的名字更好) +年龄: +职业: +外貌特征:(2-3个显著特征,避免泛泛而谈) +``` + +### 性格核心 + +```text +核心价值观:(他最相信什么) +最大恐惧:(他最害怕什么) +致命缺陷:(什么会导致他失败) +内心渴望:(他真正想要什么) +``` + +### 背景故事 + +```text +成长环境: +创伤经历:(过去伤害他的事) +关键记忆:(塑造他现在的关键事件) +秘密:(别人不知道的事) +``` + +### 行为模式 + +```text +说话方式:(口头禅、语速、用词习惯) +肢体语言:(习惯动作、紧张时的小动作) +社交风格:(内向/外向、如何对待陌生人) +压力反应:(压力下如何表现) +``` + +--- + +## 人物类型塑造 + +### 主角(Protagonist) + +**必须有:** +- **明确目标** - 他想要什么 +- **强大动机** - 为什么想要 +- **可共情性** - 读者能理解他的感受 +- **成长空间** - 故事中会改变 + +**主角原型:** +| 类型 | 特征 | 故事作用 | +|-----|------|---------| +| 英雄型 | 勇敢、正义、利他 | 战胜外在威胁 | +| 成长型 | 从弱小到强大 | 克服内在缺陷 | +| 反英雄型 | 道德灰色、复杂 | 挑战传统道德 | +| 平凡型 | 普通人卷入非凡事 | 读者代入感强 | + +### 反派(Antagonist) + +**好反派的特点:** +- **强大可信** - 不应该是草台班子 +- **有自己的逻辑** - 他相信自己在做正确的事 +- **与主角有深层联系** - 不是单纯为了作恶 +- **揭示主题** - 挑战主角的信念 + +**反派动机类型:** +- 理想主义扭曲("为了大局必须牺牲") +- 过去创伤("世界伤害了我,我要报复") +- 权力渴望("我配得上更多") +- 与主角相同目标(不同方法) + +### 配角(Supporting Characters) + +**配角功能:** +- **导师型** - 指引主角,传递信息 +- **盟友型** - 协助主角,提供情感支持 +- **搞笑型** - 缓解紧张,提供喜剧元素 +- **爱情型** - 制造浪漫线索,增加个人利害 +- **叛徒型** - 制造背叛和转折 + +**配角原则:** +- 每个配角必须有明确作用 +- 删除"只是存在"的角色 +- 避免刻板印象(除非是有意为之) + +--- + +## 人物深度塑造技巧 + +### 1. 矛盾性 + +真实的人是复杂的,充满矛盾。 + +**示例:** +- 暴力的黑帮成员但爱护流浪猫 +- 无神论的牧师 +- 害怕黑暗的侦探 +- 重视友情但总是背叛朋友 + +### 2. 侧面揭示 + +不要直接陈述性格,通过行为展示。 + +| 错误(直接陈述) | 正确(侧面展示) | +|----------------|----------------| +| 他很愤怒 | 他捏碎手中的纸杯 | +| 她很紧张 | 她反复调整眼镜位置 | +| 他很傲慢 | 他从不直视下属的眼睛 | +| 她很善良 | 她偷偷喂流浪狗三年 | + +### 3. 声音独特性 + +每个人说话方式不同,对话中能分辨角色。 + +**区分要素:** +- 用词选择(正式/俚语/方言) +- 句子长度 +- 是否打断别人 +- 是否喜欢隐喻 +- 情绪表达方式 + +### 4. 动机合理化 + +每个角色行为必须有合理动机,即使动机扭曲。 + +**反派动机合理化示例:** +- "我想毁灭世界" → 乏味 +- "我失去了一切,世界对我没有意义" → 可理解但扭曲 +- "人类是地球的病毒,我必须清除" → 有哲学支撑 + +### 5. 缺陷致命化 + +主角必须有缺陷,缺陷在关键时刻导致失败。 + +**经典缺陷模式:** +| 缺陷 | 导致的失败 | +|-----|----------| +| 傲慢 | 低估对手,落入陷阱 | +| 信任问题 | 拒绝帮助,孤立无援 | +| 完美主义 | 无法及时行动,错失机会 | +| 复仇心 | 被利用,失去理智 | + +--- + +## 人物关系设计 + +### 关系类型 + +| 关系 | 戏剧潜力 | 应用 | +|-----|---------|-----| +| 亦敌亦友 | 高 | 悬疑、动作 | +| 禁忌之爱 | 高 | 言情、悲剧 | +| 师徒关系 | 中 | 成长故事 | +| 兄弟竞争 | 中 | 家庭剧 | +| 陌生人联盟 | 中 | 冒险、悬疑 | + +### 关系动态变化 + +**好的关系会随故事发展:** +```text +第一章:陌生人 +第三章:不情愿的盟友 +第五章:建立信任 +第七章:背叛/考验 +终章:真正的友谊(或决裂) +``` + +### 关系揭示 + +**逐步揭示关系深度:** +- 表层:表面互动 +- 中层:共同经历 +- 深层:真实感受/秘密 + +--- + +## 人物一致性检查 + +角色行为必须符合已建立的性格。 + +**检查问题:** +- 这件事符合他的核心价值观吗? +- 以他的背景,会有这样的反应吗? +- 他的恐惧会如何影响这个决定? +- 他的缺陷会导致他犯什么错? + +**例外处理:** +- 如果角色"不符合性格"行事,必须有原因 +- 解释应该在相同/下一章提供 +- 可以是成长的标志(角色克服缺陷) + +--- + +## 人物出场设计 + +### 首次出场原则 + +**有效的出场方式:** +- **行动中** - 展示能力或性格 +- **冲突中** - 立即建立关系/对立 +- **误解中** - 建立悬念 + +**避免:** +- 镜子自照描写外貌 +- 姓名+年龄+职业的简历式介绍 +- 无意义的日常活动 + +### 出场示例对比 + +| 无效出场 | 有效出场 | +|---------|---------| +| 李明,28岁,是一名侦探。他走进办公室。| 李明跨过警戒线,警官试图拦住他。"市刑警队,李明。"他亮出证件,径直走向尸体。| +| 美丽的女孩坐在窗边,她叫小红。| 她已经三天没睡了,咖啡杯里的液体在颤抖。当门铃响起时,她几乎把杯子摔在地上。| diff --git a/references/character-template.md b/references/character-template.md new file mode 100644 index 0000000..62ac37c --- /dev/null +++ b/references/character-template.md @@ -0,0 +1,30 @@ +# 人物档案 + +## 主角 + +### [角色一姓名] +- **年龄/职业**: +- **外貌特征**: +- **性格核心**: +- **核心价值观**: +- **最大恐惧**: +- **致命缺陷**: +- **内心渴望**: +- **背景故事**: +- **MBTI:** + +### [角色二姓名] + +...... + + + +## 反派 + +### [角色姓名] +- [同主角格式] + +## 配角 + +### [角色姓名] +- [简化格式] diff --git a/references/consistency.md b/references/consistency.md new file mode 100644 index 0000000..24072fc --- /dev/null +++ b/references/consistency.md @@ -0,0 +1,40 @@ +# 连贯性保证机制 + +为确保长时间创作的故事连贯性: + +## 写前必读 + +每次开始写新章节前: +1. 阅读 `00-大纲.md` 中所有已完成章节的摘要 +2. 读取上一章文件,了解当前悬念 +3. 检查人物状态(位置、情绪、关系) + +## 穿针引线 + +在新章节中: +- 呼应前文埋下的伏笔和线索 +- 提及之前发生的事件(自然融入) +- 让人物行为与之前保持一致 + +## 人物状态跟踪 + +注意人物在各章节中的变化和成长: +- 位置变化(人在哪里) +- 情绪状态(当前心情) +- 关系变化(与其他角色关系) +- 能力变化(获得新技能/信息) + +## 悬念线延续 + +确保主线悬念逐步推进: +- 每章至少回应一个旧悬念 +- 提出新悬念或升级现有悬念 +- 不要遗忘任何未解的悬念 + +## 一致性检查清单 + +- [ ] 人物行为符合其性格设定 +- [ ] 前后伏笔有呼应,逻辑闭环 +- [ ] 高潮低谷分布合理,节奏恰当 +- [ ] 时间线连贯(没有时间跳跃错误) +- [ ] 场景转换自然(没有凭空出现) diff --git a/references/content-expansion.md b/references/content-expansion.md new file mode 100644 index 0000000..d2ec2a5 --- /dev/null +++ b/references/content-expansion.md @@ -0,0 +1,66 @@ +# 内容扩充技巧 + +当章节内容不足时,使用以下技巧自然扩充。 + +## 1. 场景细节描写 + +不要只说"他走进房间",描写: +- 房间的布局、光线、气味 +- 物品的细节和质感 +- 环境对人物的影响 +- 人物在空间中的移动 + +## 2. 人物内心活动 + +展示而非讲述内心世界: +- 角色的犹豫和纠结 +- 过去记忆的闪回(1-2段) +- 对未来的担忧和期待 +- 道德选择的内心辩论 + +## 3. 对话扩展 + +不要只推进剧情,让对话: +- 展现人物性格和说话方式 +- 包含潜台词和暗示 +- 有来回交锋和试探 +- 偶尔跑题再拉回(更真实) + +## 4. 感官体验 + +调动五感描写: +- 视觉:颜色、光影、形状 +- 听觉:声音、音乐、沉默 +- 触觉:温度、质感、疼痛 +- 嗅觉:气味、香味、腐臭 +- 味觉:食物、饮料、血腥味 + +## 5. 次要情节线 + +在主剧情中穿插: +- 配角的小故事 +- 暗线的发展 +- 伏笔的埋设 +- 人物关系的微妙变化 + +## 6. 节奏放慢 + +关键时刻慢下来描写: +- 动作场景的分解 +- 情感转变的过程 +- 发现真相的时刻 +- 紧张对峙的延展 + +## 7. 环境烘托 + +用环境反映情绪: +- 天气和氛围 +- 社会环境背景 +- 文化习俗细节 +- 时代特征展现 + +## 扩充原则 + +- **自然融入** - 扩充内容要服务于故事,不要注水 +- **保持张力** - 即使扩充场景也不能失去冲突 +- **推进主线** - 所有扩充最终都要指向核心剧情 diff --git a/references/dialogue-writing.md b/references/dialogue-writing.md new file mode 100644 index 0000000..1f1fd9e --- /dev/null +++ b/references/dialogue-writing.md @@ -0,0 +1,316 @@ +# 对话写作规范 + +好对话是揭示人物、推动情节、制造冲突的有力工具。 + +## 对话核心原则 + +### 1. 对话必须有目的 + +每句对话应该至少完成以下之一: + +| 目的 | 示例 | +|-----|------| +| **推动情节** | "我找到凶器了,在河边的草丛里。" | +| **揭示人物** | "我不信任警察,他们从来不帮我这样的人。" | +| **制造冲突** | "你骗了我。你从头到尾都在骗我。" | +| **传达信息** | "炸弹将在三点引爆。" | +| **表达情感** | "我...我不知道该说什么。" | +| **制造悬念** | "你知道那天晚上真正发生了什么吗?" | + +**无效对话:** +> "你好。" +> "你好。" +> "吃了吗?" +> "吃了。" +> "哦,那就好。" + +### 2. 对话应该简洁 + +人们说话不写论文。删除多余的词。 + +| 啰嗦 | 简洁 | +|-----|------| +| "我想告诉你的是,我认为我们应该立刻离开这里。" | "我们得马上走。" | +| "我非常抱歉,但我真的不知道你刚才说的那件事的答案。" | "我不知道。" | +| "如果你不介意的话,我能不能请你帮我把那个东西递给我?" | "递给我那个。" | + +### 3. 真实的人不会完整表达 + +真实对话充满: +- 打断 +- 迟疑 +- 话题转移 +- 话没说完 +- 暗示而非明说 + +**示例:** +> "我本来想告诉你,但是——" +> "但是什么?" +> "算了,没什么。" +> "不,你说。" +> "真的没什么。" + +--- + +## 对话格式规范 + +### 中文对话标点 + +**基础格式:** +``` +"说话内容," 他说。 +"说话内容?" 她问。 +"说话内容!" 他大喊。 +``` + +**多行对话:** +``` +"第一句话,"他说,"第二句话。" + +"第一句话。 +第二句话,"他说,"第三句话。" +``` + +**对话动作:** +``` +"说话内容。" 他做了动作。 +他做了动作。"说话内容。" +``` + +### 对话标签使用 + +**规则:** +- 能辨识说话人时,省略标签 +- 使用"说""问"等中性标签 +- 避免过度使用副词修饰 + +| 过度使用 | 改进后 | +|---------|--------| +| "你骗了我,"他愤怒地说。| "你骗了我。"他的声音在颤抖。 | +| "好的,"她高兴地同意道。| 她眼睛亮了。"好的。" | +| "我不知道,"他悲伤地回答。| 他低下头。"我不知道。" | + +**标签位置:** +- 对话前:[标签]"对话。" +- 对话后:"对话。"[标签] +- 对话中断:"对话,"[标签]"对话。" + +### 段落划分 + +**规则:** 每个说话人的对话开始新段落。 + +``` +正确: +"第一句,"甲说。 +"第二句,"乙回答。 +"第三句。"甲点头。 + +错误: +"第一句,"甲说。"第二句,"乙回答。"第三句。"甲点头。 +``` + +--- + +## 对话声音区分 + +每个角色说话方式应该不同。 + +### 区分维度 + +| 维度 | 示例 | +|-----|------| +| **用词** | 正式/俚语/方言/专业术语 | +| **句式** | 长句/短句/破碎句 | +| **停顿** | 流畅/迟疑/频繁打断 | +| **语气** | 温和/激烈/冷嘲热讽/平淡 | +| **习惯语** | 特定口头禅或用词习惯 | + +### 角色声音示例 + +**教授型角色:** +> "从理论角度分析,这个假设存在三个主要缺陷。首先,数据样本不足;其次,实验条件未受控制;最后,结论过于激进。" + +**街头混混型角色:** +> "扯淡。那帮人就是在放屁,想蒙咱们呢。我告诉你,这事儿没那么简单。" + +**害羞内向型角色:** +> "我...我是说,如果...如果你不介意的话...那个..." + +**傲慢自大型角色:** +> "让我来告诉你什么叫专业。你们这些业余人士根本不懂。" + +--- + +## 潜台词(Subtext) + +好的对话,真正含义在表面之下。 + +### 直接 vs 潜台词 + +| 直接(乏味) | 潜台词(有趣) | +|-------------|---------------| +| "我很生气。" | "没事。我挺好的。真的。" | +| "我喜欢你。" | "你今天看起来...不错。" | +| "我不信任你。" | "谢谢你告诉我。我会记住的。" | +| "我想离开。" | "这个地方空气不太好。" | + +### 潜台词技巧 + +**1. 话题转移** +``` +"你爱我吗?" +"你看了天气预报吗?明天有雨。" +``` + +**2. 反问而非回答** +``` +"你杀了他吗?" +"你觉得像我这样的人会做那种事?" +``` + +**3. 谈论其他事物** +``` +"你想我吗?" +"我妈昨天打电话来了。" +``` + +**4. 沉默和动作** +``` +"你愿意原谅我吗?" +她继续看杂志,翻了一页。 +``` + +--- + +## 对话与动作结合 + +对话与肢体语言配合,增强表现力。 + +### 同步原则 + +动作与对话一致或矛盾,都有戏剧效果。 + +**一致(增强):** +> "我爱你。"她紧紧抱住他,眼泪流下来。 + +**矛盾(揭示真相):** +> "我完全支持你。"他目光看向别处,手在口袋里握紧拳头。 + +### 动作打断 + +动作插入可以控制节奏。 + +``` +"我本来想告诉你,"他停下脚步,转过身,"但我想你已经知道了。" +``` + +### 动作替代标签 + +用动作替代"他说"。 + +``` +"你在撒谎。"她拍案而起。 +"坐下。"他头也不抬。 +``` + +--- + +## 对话场景类型 + +### 争吵场景 + +**特征:** +- 短句 +- 打断 +- 重复强调 +- 情绪升级 + +**示例:** +> "你答应过的!" +> "情况变了!" +> "那是你的借口!" +> "你根本不懂!" +> "我当然不懂!你什么都不告诉我!" + +### 告白场景 + +**特征:** +- 迟疑 +- 停顿 +- 寻找词语 +- 真诚或尴尬 + +**示例:** +> "我...我想说...这些年,我一直在想...如果我们..." +> 她低下头,声音变小。 +> "如果我们什么?" +> "如果我们早一点相遇。" + +### 审讯场景 + +**特征:** +- 提问控制 +- 信息不对称 +- 压力建立 +- 操纵对话 + +**示例:** +> "那天晚上你在哪里?" +> "在家。" +> "有人能证明吗?" +> "...没有。" +> "你是一个人?" +> "是的。" +> "整个晚上?" + +### 调情场景 + +**特征:** +- 双关语 +- 试探 +- 身体接近 +- 暗示 + +**示例:** +> "你今天很漂亮。" +> "只是今天?" +> "嗯...今天特别漂亮。" +> "那我明天该担心了?" +> "明天...明天再看看。" + +--- + +## 对话常见问题 + +### 避免 + +1. **信息倾倒** - 角色互相说已知信息 + > 错误:"正如你所知,我们的公司成立于1995年..." + > 正确:通过情节自然揭示信息 + +2. **所有人说话一样** - 无法区分角色 + > 解决:给每个角色独特的说话方式 + +3. **过度礼貌** - 真实对话更粗糙 + > 错误:"我很抱歉打扰你,能否请你..." + > 正确:"喂。帮我个忙。" + +4. **无意义的闲聊** - 除非有特殊目的 + > 删除天气、吃饭等无关对话,除非揭示人物/推动情节 + +5. **说教** - 角色发表长篇哲学论述 + > 改为通过冲突和行动展示观点 + +--- + +## 对话练习自查 + +写完对话后检查: + +- [ ] 每句对话是否有目的? +- [ ] 删除后情节是否受影响? +- [ ] 能否辨识说话人(不看标签)? +- [ ] 是否有潜台词? +- [ ] 节奏是否合适(快/慢)? +- [ ] 是否符合人物性格? +- [ ] 标签使用是否正确? diff --git a/references/fantasy/style-references.md b/references/fantasy/style-references.md new file mode 100644 index 0000000..c81ff46 --- /dev/null +++ b/references/fantasy/style-references.md @@ -0,0 +1,25 @@ +# 奇幻风格补充参考 + +## 世界构建 + +- 魔法体系必须有明确代价,不能出现"无限能量" +- 种族特征要在行动中自然展示,禁止百科式灌输 +- 新设定首次出现时通过角色互动引出,不用旁白解释 + +## 常见陷阱 + +- 龙傲天:主角能力攀升要有合理代价和挫折 +- 设定膨胀:每章最多引入一个新设定概念 +- 语言穿越:避免现代网络用语出现在古典背景中 + +## 战斗与能力描写 + +- 战斗重点是策略和代价,不是招式名称堆砌 +- 能力上限在 world_rules 中已约定,严禁突破 +- 受伤要有持续影响,不能"下一场景就好了" + +## 氛围营造 + +- 通过五感描写建立异世界沉浸感 +- 日常生活细节(货币、食物、交通)体现世界观 +- 避免所有角色都说现代普通话,适当使用世界观内的表达方式 diff --git a/references/hook-techniques.md b/references/hook-techniques.md new file mode 100644 index 0000000..12958e8 --- /dev/null +++ b/references/hook-techniques.md @@ -0,0 +1,195 @@ +# 悬念设置技巧 + +悬念是让读者继续阅读的关键。每章结尾必须设置有效的钩子。 + +## 十种经典悬念钩子 + +### 1. 突然揭示 + +在章节结尾突然揭示一个改变一切的信息。 + +**示例:** +> 警官看着死者的手机,最后一条短信来自一个他认识的人——他自己三天前发出的号码。 + +**关键要素:** +- 信息出乎意料 +- 改变现状理解 +- 留下"为什么"的疑问 + +### 2. 紧急危机 + +角色面临迫在眉睫的危险,下一章必须立即应对。 + +**示例:** +> 地板开始震动,灰尘从天花板簌簌落下。她抬头一看,裂缝正在迅速扩大。 + +**关键要素:** +- 时间紧迫 +- 威胁明确 +- 后果严重 + +### 3. 未完成的动作 + +一个动作被中断,留下"接下来会发生什么"的疑问。 + +**示例:** +> 他举起枪,手指扣在扳机上——"别动!"身后传来一个声音。 + +**关键要素:** +- 动作进行中被打断 +- 不确定结果 +- 新变量出现 + +### 4. 身份反转 + +某人被揭示为不是我们以为的那样。 + +**示例:** +> "我终于找到你了,弟弟。" 那个说着完美普通话的男人摘下面具,露出了一张她父亲的脸。 + +**关键要素:** +- 身份误解 +- 关系重定义 +- 动机重新解读 + +### 5. 两难选择 + +角色必须做出一个艰难的选择,但章节在决定前结束。 + +**示例:** +> 救生艇只能载两个人。她的丈夫和女儿都在水里,海浪越来越大。她伸出手—— + +**关键要素:** +- 选项都不理想 +- 必须选择 +- 高风险 + +### 6. 神秘物品/线索 + +发现一个重要但意义不明的东西。 + +**示例:** +> 保险箱里只有一张照片,拍摄于昨天。照片里是熟睡中的她,从窗外角度拍摄。 + +**关键要素:** +- 物品意义不明 +- 暗示威胁 +- 激发好奇 + +### 7. 时间限制 + +一个截止时间被设定,制造紧迫感。 + +**示例:** +> 定时器显示 03:00。而拆弹包里少了一根关键的红线。 + +**关键要素:** +- 明确时限 +- 资源不足 +- 后果已知 + +### 8. 承诺/威胁 + +某人做出承诺或威胁,改变预期。 + +**示例:** +> "今晚午夜之前,我会让所有人知道你十年前真正做了什么。" 匿名邮件只有这一行字。 + +**关键要素:** +- 明确意图 +- 伤害/揭露的威胁 +- 时间框架 + +### 9. 离奇消失 + +某人或某物突然消失,留下谜团。 + +**示例:** +> 他转身只一秒钟,再回头时,空荡荡的牢房里,那个戴着手铐的囚犯不见了。 + +**关键要素:** +- 不可能的行为 +- 缺乏解释 +- 安全感丧失 + +### 10. 言外之意 + +一句话表面正常,但暗示了更深层的东西。 + +**示例:** +> "恭喜你通过面试," 面试官笑着握住她的手,"和你的姐姐一样优秀。" 可她是独生女。 + +**关键要素:** +- 表面正常 +- 隐藏信息 +- 需要解读 + +--- + +## 章节间悬念连接 + +### 伏笔与呼应 + +**伏笔技巧:** +- 早期埋下不起眼的细节 +- 让读者忽略其重要性 +- 后期揭示时造成"原来如此"的效果 + +**呼应方式:** +- 对称场景(相似情境,不同结果) +- 重复对话(不同语境,新含义) +- 物品回归(重要物品再次出现) + +### 悬念升级 + +**递进原则:** 后续悬念应比前一个更强或更深入 + +``` +第一章:谁偷了文件? +第二章:小偷是主角的同事 +第三章:同事是卧底特工 +第四章:特工知道主角的秘密身份 +``` + +### 多线悬念 + +**同时维持多条悬念线:** +- 主线悬念(核心谜题) +- 人物悬念(某人的真实身份) +- 关系悬念(A和B之间发生什么) +- 时间悬念(倒计时/最后期限) + +--- + +## 悬念设置禁忌 + +### 避免: + +1. **虚假悬念** - 制造紧张但结果是误会 + > 错误:他听到了脚步声...原来是猫 + > 正确:他听到了脚步声...但追他的人已经死了 + +2. **机械降神** - 突然出现从未提及的解决方案 + > 错误:她突然想起自己会武术 + > 正确:她想起父亲教过的防身术(第五章提过) + +3. **过度留白** - 留下太多未回答问题 + > 原则:每章至少回答一个旧悬念,再提出新悬念 + +4. **低风险钩子** - 结尾事件不够重要 + > 错误:他不知道晚饭吃什么 + > 正确:他的晚餐被人下了毒 + +--- + +## 悬念强度等级 + +| 等级 | 类型 | 读者反应 | 适用位置 | +|-----|------|---------|---------| +| 1 | 好奇悬念 | "这很有趣" | 中间章节 | +| 2 | 关切悬念 | "接下来会发生什么" | 中间章节 | +| 3 | 迫切悬念 | "他必须马上行动" | 高潮章节 | +| 4 | 生存悬念 | "他会活下去吗" | 高潮/结局前 | +| 5 | 终极悬念 | "一切到底是什么意思" | 全书结尾 | + +**递进建议:** 故事中悬念强度应总体上升,但可以波动 diff --git a/references/outline-template.md b/references/outline-template.md new file mode 100644 index 0000000..9465a72 --- /dev/null +++ b/references/outline-template.md @@ -0,0 +1,47 @@ +# [小说名称] 大纲 + +## 基本信息 +- **题材**:[悬疑/奇幻/言情/科幻等] +- **预计章节数**:[10-20] 章 +- **目标字数**:每章 3000-5000 字,总计 [X] 万字 +- **核心冲突**:[主角想要什么?什么阻止了他?] + +## TODO List + +### 待创作 +- [ ] 第[X]章:[章节标题] - [核心事件] + +### 进行中 +- [ ] 第[X]章:[章节标题] - [核心事件] + +### 已完成 +- [x] 第[X]章:[章节标题] - [核心事件]([字数]字) +- [x] 第[X]章:[章节标题] - [核心事件]([字数]字) + +## 章节规划 + +| 章节 | 标题 | 核心事件 | 悬念钩子 | 字数 | 状态 | +|-----|------|---------|---------|------|------| +| 第1章 | | | | | 待创作 | +| 第2章 | | | | | 待创作 | + +## 全书悬念线 +- **主线悬念**:[核心谜题] +- **支线悬念**:[其他悬念] +- **终极揭秘**:[最终答案] + +## 字数统计 +- 已完成章节数:[0] 章 +- 累计字数:[0] 字 +- 完成进度:[0]% + +--- + +## 章节摘要 + +### 第[X]章:[章节标题] +**摘要**:[300-500字概括本章核心内容、重要情节、人物变化、悬念揭示等] + +--- + +(后续章节摘要依次追加) diff --git a/references/plot-structures.md b/references/plot-structures.md new file mode 100644 index 0000000..a733b79 --- /dev/null +++ b/references/plot-structures.md @@ -0,0 +1,259 @@ +# 情节结构模板 + +常见的故事结构模板,可用于规划小说章节。 + +## 三幕式结构(Three-Act Structure) + +最经典的故事结构,适用于大多数短篇小说。 + +### 第一幕:设置(Act 1 - Setup)约 25% + +| 章节 | 内容 | +|-----|------| +| 第1章 | 介绍主角日常生活(现状) | +| 第2章 | 激励事件(Inciting Incident)- 打破现状的事件 | +| 第3章 | 主角拒绝召唤,但最终决定行动 | + +**第一幕悬念示例:** +- "门开了,进来的人是三年前死去的人。" +- "她收到的信件,署名是自己。" + +### 第二幕:对抗(Act 2 - Confrontation)约 50% + +| 章节 | 内容 | +|-----|------| +| 第4-5章 | 主角进入新世界,遭遇挑战 | +| 第6章 | 中点(Midpoint)- 重大转折/信息揭示 | +| 第7-8章 | 困难升级,盟友可能背叛 | + +**第二幕悬念示例:** +- "导师竟然是幕后黑手。" +- "唯一的盟友失踪了。" + +### 第三幕:结局(Act 3 - Resolution)约 25% + +| 章节 | 内容 | +|-----|------| +| 第9章 | 一切看似失败,最低点 | +| 第10章 | 高潮(Climax)- 最终对决 | +| 终章 | 结局,展示新常态 | + +--- + +## 英雄之旅(Hero's Journey) + +神话学家约瑟夫·坎贝尔的经典结构,适合冒险/奇幻题材。 + +### 阶段分解 + +| 阶段 | 章节 | 内容 | +|-----|------|------| +| 1. 平凡世界 | 第1章 | 介绍主角日常生活 | +| 2. 冒险召唤 | 第1-2章 | 激励事件发生 | +| 3. 拒绝召唤 | 第2章 | 主角最初犹豫 | +| 4. 遇见导师 | 第2-3章 | 获得指导/装备 | +| 5. 跨越门槛 | 第3章 | 离开舒适区 | +| 6. 考验盟友敌人 | 第4-5章 | 新世界探索 | +| 7. 接近洞穴 | 第5-6章 | 准备面对大挑战 | +| 8. 苦难煎熬 | 第6-7章 | 接近死亡/重大失败 | +| 9. 奖赏 | 第7-8章 | 获得力量/信息 | +| 10. 返回之路 | 第8章 | 回归途中受阻 | +| 11. 复活 | 第9章 | 最终考验/蜕变 | +| 12. 满载而归 | 第10章 | 回归,带着收获 | + +--- + +## 悬疑小说结构 + +适合侦探/推理/惊悚题材。 + +### 第一幕:谜题出现 + +| 章节 | 内容 | 悬念钩子 | +|-----|------|---------| +| 第1章 | 发现尸体/事件发生 | 谁干的? | +| 第2章 | 侦探接手案件 | 为什么这个案子特殊? | +| 第3章 | 初步调查,发现线索 | 线索指向谁? | + +### 第二幕:调查深入 + +| 章节 | 内容 | 悬念钩子 | +|-----|------|---------| +| 第4章 | 审讯嫌疑人,各有嫌疑 | 谁在撒谎? | +| 第5章 | 新线索出现,指向意外方向 | 我们是不是一开始就错了? | +| 第6章 | 第二起事件,模式浮现 | 这是连环案件? | +| 第7章 | 侦探陷入危险 | 侦探会成为下一个目标吗? | + +### 第三幕:真相揭示 + +| 章节 | 内容 | 悬念钩子 | +|-----|------|---------| +| 第8章 | 重大突破/反转 | 我们信任的人有问题? | +| 第9章 | 最终对决 | 真相是什么?代价是什么? | +| 第10章 | 案件解决,遗留疑问 | 真的结束了吗? | + +--- + +## 言情小说结构 + +适合爱情题材。 + +### 第一幕:相遇 + +| 章节 | 内容 | 情感节点 | +|-----|------|---------| +| 第1章 | 介绍主角 A,现状/问题 | 读者共情 A | +| 第2章 | 介绍主角 B,现状/问题 | 读者共情 B | +| 第3章 | A 和 B 相遇(第一印象不佳) | 两人注定在一起,但目前有冲突 | + +### 第二幕:发展 + +| 章节 | 内容 | 情感节点 | +|-----|------|---------| +| 第4-5章 | 被迫在一起,了解对方 | 发现有吸引力的地方 | +| 第6章 | 吸引力增强,亲密时刻 | 接近 | +| 第7-8章 | 误解/秘密/障碍出现 | 推远 | +| 第9章 | 危机,关系破裂 | 看起来无望 | + +### 第三幕:和解 + +| 章节 | 内容 | 情感节点 | +|-----|------|---------| +| 第10章 | 意识到真爱,克服障碍 | 高潮 | +| 终章 | 在一起,展示新生活 | 圆满 | + +--- + +## 惊悚/动作结构 + +适合快节奏、紧张刺激题材。 + +### 短篇结构(5-6章) + +```text +第1章:危机出现 → 钩子:主角被追杀/威胁 +第2章:应对计划 → 钩子:计划失败,情况恶化 +第3章:追逐/对抗 → 钩子:被逼入绝境 +第4章:逆转机会 → 钩子:发现新希望,但时间紧迫 +第5章:最终对抗 → 钩子:生死一瞬 +第6章:结局 → 展示后果 +``` + +### 特点 + +- 节奏快,每章都有动作 +- 高密度事件 +- 时间压力持续存在 +- 悬念强度递增 + +--- + +## 反转结构(Twist-Based) + +适合心理惊悚/悬疑题材。 + +### 章节分布 + +```text +第1-2章:建立"真实"情况 +第3-4章:出现疑点,但不明显 +第5-6章:第一次小反转(重新理解) +第7-8章:第二次反转(再次反转) +第9-10章:最终反转(一切颠覆) +``` + +### 关键 + +- 前期埋下看似无辜的线索 +- 每次反转都合乎逻辑(回看有迹可循) +- 避免机械降神 + +--- + +## 多线叙事结构 + +适合复杂剧情,多主角。 + +### 交叉剪辑模式 + +```text +第1章:主角A故事 +第2章:主角B故事 +第3章:主角A故事(推进) +第4章:主角B故事(推进) +第5章:线索交汇 +... +``` + +### 时间线模式 + +```text +第1章:现在(时间A) +第2章:过去(时间A-5年) +第3章:现在(时间A+1天) +第4章:过去(时间A-5年+1月) +... +``` + +### 收敛原则 + +- 各线最终必须交汇 +- 早期看似无关的事件后来有关联 +- 收敛时产生"原来如此"的效果 + +--- + +## 短篇小说快速结构 + +### 3章微型结构 + +```text +第1章:激励事件 + 决定行动 +第2章:尝试 + 失败 + 升级 +第3章:最终尝试 + 成功/失败 + 结局 +``` + +### 5章标准结构 + +```text +第1章:现状 + 激励事件 +第2章:拒绝 + 跨越门槛 +第3章:挑战 + 盟友/敌人 +第4章:低谷 + 觉醒 +第5章:高潮 + 结局 +``` + +--- + +## 章节情节模板 + +### 单章内部结构 + +```text +开头(10%):钩子 + 上下文连接 +发展(60%):事件推进 + 冲突 +高潮(20%):本章最高点 +结尾(10%):悬念钩子 + 下章铺垫 +``` + +### 无效章节结构 + +```text +开头:漫长铺垫/背景说明 +中间:日常活动/对话流水账 +结尾:平淡结束/无悬念 +``` + +--- + +## 结构选择指南 + +| 故事类型 | 推荐结构 | 章节数 | +|---------|---------|--------| +| 冒险/奇幻 | 英雄之旅 | 8-12章 | +| 侦探/悬疑 | 悬疑结构 | 8-10章 | +| 言情 | 言情结构 | 6-10章 | +| 动作/惊悚 | 惊悚结构 | 5-8章 | +| 心理/反转 | 反转结构 | 6-8章 | +| 多主角 | 多线叙事 | 10-15章 | +| 微型小说 | 3章结构 | 3章 | diff --git a/references/quality-checklist.md b/references/quality-checklist.md new file mode 100644 index 0000000..038fc92 --- /dev/null +++ b/references/quality-checklist.md @@ -0,0 +1,262 @@ +# 质量检查清单 + +交付章节前使用此清单确保质量。 + +## 整体检查 + +### 基础要素 + +- [ ] **章节有明确标题** + - 标题与内容相关 + - 吸引人但不过度透露 + +- [ ] **字数符合预期** + - 短章节:800-1500 字 + - 标准章节:1500-3000 字 + - 长章节:3000-5000 字 + +- [ ] **章节完整性** + - 有开头、发展、高潮 + - 不是片段,是完整叙事单元 + +- [ ] **时间地点清晰** + - 读者知道何时何地 + - 转换时有明确标记 + +--- + +## 开头检查 + +- [ ] **前 3 段内抓住读者** + - 有行动/冲突/悬念 + - 不是天气或日常流程 + +- [ ] **与上一章有连接** + - 回应上一章结尾 + - 或明确时间/地点跳跃 + +- [ ] **背景信息不过量** + - 没有大段信息倾倒 + - 信息自然融入动作 + +--- + +## 内容检查 + +### 情节推进 + +- [ ] **本章有核心事件** + - 发生了不可删除的事 + - 不是"什么都没发生"的过渡章 + +- [ ] **推动主线剧情** + - 揭示新信息 + - 或改变人物关系 + - 或升级冲突 + +- [ ] **逻辑自洽** + - 事件因果关系合理 + - 没有巧合驱动剧情 + - 人物行为符合动机 + +### 冲突与张力 + +- [ ] **有明确冲突** + - 人与人、人与环境、人与自己 + - 冲突推动本章事件 + +- [ ] **张力有变化** + - 不是平铺直叙 + - 有紧张和缓解的交替 + +- [ ] **有转折或新信息** + - 不是线性可预测 + - 有意外或新发现 + +--- + +## 人物检查 + +- [ ] **人物行为一致** + - 符合已建立的性格 + - 如不一致,有解释 + +- [ ] **人物有反应** + - 对事件有情绪/行动 + - 不是被动道具 + +- [ ] **人物有声音** + - 对话能区分角色 + - 每人说话方式不同 + +- [ ] **人物展示而非讲述** + - 通过行动/对话表现性格 + - 不是直接陈述"他很勇敢" + +--- + +## 对话检查 + +- [ ] **每句对话有目的** + - 推动情节/揭示人物/制造冲突 + - 没有"你好""吃了吗"等无意义对话 + +- [ ] **对话简洁自然** + - 删除冗余词语 + - 符合真实说话方式 + +- [ ] **有潜台词** + - 不是所有话都直说 + - 有言外之意 + +- [ ] **标签使用正确** + - 能辨识时省略标签 + - 不过度使用副词 + +--- + +## 悬念检查 + +- [ ] **结尾有钩子** + - 使用至少一种悬念技巧 + - 让读者想看下一章 + +- [ ] **悬念强度适当** + - 与故事位置匹配 + - 高潮章节悬念更强 + +- [ ] **不是虚假悬念** + - 不是机械误会 + - 不是无意义的"突然" + +- [ ] **为下一章铺垫** + - 设置下一章的冲突 + - 埋下伏笔 + +--- + +## 展示而非讲述检查 + +### 常见"讲述"标记 + +检查并修正以下模式: + +| 讲述(避免) | 展示(使用) | +|-------------|-------------| +| 他很愤怒 | 他握紧拳头,指节发白 | +| 她很美丽 | 他凝视着她,忘记说话 | +| 他很紧张 | 他反复调整领带 | +| 房间很乱 | 衣服扔在沙发上,外卖盒堆在桌上 | +| 他很富有 | 他从口袋里掏出一叠现金 | + +### 自查问题 + +- [ ] 是否直接陈述情绪?(改为身体反应) +- [ ] 是否用形容词总结?(改为具体描写) +- [ ] 是否跳过了关键场景?(补充展示) + +--- + +## 节奏检查 + +- [ ] **句子长度有变化** + - 没有连续 3 句长度相同 + - 长短交错 + +- [ ] **段落长度适当** + - 避免大段文字墙 + - 动作场景用短段落 + +- [ ] **信息密度有变化** + - 高密度(动作/对话) + - 低密度(描写/内心) + +--- + +## 语言检查 + +- [ ] **没有 AI 写作痕迹** + - 避免"此外""然而""强调"等 AI 词汇 + - 避免四字成语堆砌 + - 句式多样化 + +- [ ] **"的"字不密集** + - 没有连续多个"的" + - 简化修饰结构 + +- [ ] **用词精确** + - 避免模糊词("一些""某种") + - 使用具体词汇 + +--- + +## 连贯性检查 + +- [ ] **与前文连贯** + - 上一章的悬念有回应 + - 已知信息一致 + +- [ ] **伏笔有呼应** + - 早期埋下的线索有进展 + - 或即将揭示 + +- [ ] **时间线一致** + - 时间流逝合理 + - 事件顺序正确 + +--- + +## 类型特定检查 + +### 悬疑类 + +- [ ] 有线索揭示 +- [ ] 有新谜题提出 +- [ ] 逻辑无漏洞 + +### 言情类 + +- [ ] 关系有进展 +- [ ] 有情感张力 +- [ ] 读者在意配对 + +### 奇幻/科幻类 + +- [ ] 世界观一致 +- [ ] 规则设定不破坏 +- [ ] 解释不过度 + +### 动作类 + +- [ ] 动作场面清晰 +- [ ] 节奏快速 +- [ ] 地理空间明确 + +--- + +## 交付前最终检查 + +- [ ] 通读全文,无错别字 +- [ ] 标点符号正确 +- [ ] 对话标签正确 +- [ ] 段落划分清晰 +- [ ] 格式一致 +- [ ] 如果是续章,确认与前文的连贯性 + +--- + +## 质量评分 + +交付前给自己打分(每项 1-10 分): + +| 维度 | 评分 | 说明 | +|-----|------|-----| +| 开头吸引力 | /10 | 前 3 段抓住读者? | +| 情节推进 | /10 | 本章推进主线? | +| 人物塑造 | /10 | 人物行为一致且有深度? | +| 对话质量 | /10 | 对话自然且推动情节? | +| 悬念设置 | /10 | 结尾钩子让读者想看下一章? | +| 节奏控制 | /10 | 张弛有度? | +| 展示而非讲述 | /10 | 用行动/对话而非陈述? | +| 语言质量 | /10 | 无 AI 痕迹,用词精确? | +| **总分** | **/80** | **>60 可交付,>70 优秀** | diff --git a/references/romance/style-references.md b/references/romance/style-references.md new file mode 100644 index 0000000..49bdb99 --- /dev/null +++ b/references/romance/style-references.md @@ -0,0 +1,25 @@ +# 言情风格补充参考 + +## 情感递进 + +- 感情发展遵循"排斥→好奇→动摇→确认→考验→稳固"曲线 +- 每章推进一个情感阶段,禁止跳跃式发展 +- 心动瞬间要通过具体细节呈现,不用"心跳加速"等抽象描写 + +## 关系张力 + +- 核心CP之间必须有持续的阻力源(性格、身份、误会、外部) +- 阻力要合理且难以轻易解决,不能为虐而虐 +- 配角感情线不能抢主线篇幅,每章配角互动不超过 20% + +## 对话质量 + +- 暧昧期对话要有潜台词,角色说的和想的应该不一样 +- 吵架/冲突场景双方都要有道理,不能一方全错 +- 甜蜜场景要有克制,过度撒糖会降低读者感受 + +## 常见陷阱 + +- 降智推剧情:不让角色为了制造误会而突然变笨 +- 工具人配角:每个配角都应有自己的动机 +- 人设崩塌:感情中的行为必须符合角色已建立的性格特征 diff --git a/references/suspense/style-references.md b/references/suspense/style-references.md new file mode 100644 index 0000000..5eb776b --- /dev/null +++ b/references/suspense/style-references.md @@ -0,0 +1,25 @@ +# 悬疑风格补充参考 + +## 线索布局 + +- 每章至少埋设一条线索(实线索或红鲱鱼) +- 关键线索必须在揭晓前至少出现两次 +- 线索首次出现要自然融入场景,不能刻意强调 + +## 误导技法 + +- 红鲱鱼要有独立的合理性,不能事后看完全无意义 +- 叙述性诡计需要严格遵守"不说谎但可以省略"原则 +- 嫌疑人转移要有动机支撑,不能为了误导而误导 + +## 节奏控制 + +- 紧张→舒缓→更紧张的波浪式节奏 +- 信息释放要节制,每次只给读者"刚好不够"的信息量 +- 章末必须有悬念钩子,禁止"平静收尾" + +## 逻辑严密性 + +- 时间线必须可回溯验证 +- 不在场证明、动机、手法三要素缺一不可 +- 揭晓时读者应该能用已知线索自行推导出结论 diff --git a/scripts/check_chapter_wordcount.py b/scripts/check_chapter_wordcount.py new file mode 100755 index 0000000..39f0ded --- /dev/null +++ b/scripts/check_chapter_wordcount.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +章节字数检查脚本 +检查指定章节文件的字数,低于3000字时提示需要扩充 +""" + +import re +import sys +from pathlib import Path + +# 修复 Windows 控制台编码问题 +if sys.platform == 'win32': + import io + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + + +def count_chinese_words(text: str) -> int: + """统计中文字数(排除标点符号和 Markdown 标记)""" + text = re.sub(r'#{1,6}\s*', '', text) + text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) + text = re.sub(r'\*(.*?)\*', r'\1', text) + text = re.sub(r'~~(.*?)~~', r'\1', text) + text = re.sub(r'`(.*?)`', r'\1', text) + text = re.sub(r'\[(.*?)\]\(.*?\)', r'\1', text) + + chinese_chars = re.findall(r'[\u4e00-\u9fff]', text) + return len(chinese_chars) + + +def extract_content_from_chapter(file_path: Path) -> str: + """从章节文件中提取正文内容(排除标题等元数据)""" + content = file_path.read_text(encoding='utf-8') + lines = content.split('\n') + + content_start = 0 + for i, line in enumerate(lines): + if line.startswith('#') and '章' in line: + content_start = i + 1 + break + + return '\n'.join(lines[content_start:]) + + +def check_chapter(file_path: str, min_words: int = 3000) -> dict: + """检查单个章节的字数""" + path = Path(file_path) + if not path.exists(): + return { + 'file': str(path), + 'exists': False, + 'word_count': 0, + 'status': 'error', + 'message': f'文件不存在: {file_path}', + } + + main_content = extract_content_from_chapter(path) + word_count = count_chinese_words(main_content) + status = 'pass' if word_count >= min_words else 'fail' + message = f'字数: {word_count}' + if word_count >= min_words: + message += ' (✓ 达标)' + else: + message += f' (✗ 不足,需要至少 {min_words} 字)' + + return { + 'file': str(path), + 'exists': True, + 'word_count': word_count, + 'status': status, + 'message': message, + } + + +def check_all_chapters(directory: str, pattern: str = '第*.md', min_words: int = 3000) -> list: + """检查目录下所有符合模式的章节文件""" + dir_path = Path(directory) + if not dir_path.exists(): + print(f'错误: 目录不存在 - {directory}') + return [] + + chapter_files = sorted(dir_path.glob(pattern)) + return [check_chapter(str(chapter_file), min_words) for chapter_file in chapter_files] + + +def print_results(results: list, min_words: int = 3000) -> None: + """打印检查结果""" + if not results: + print('没有找到章节文件') + return + + total_words = 0 + passed = 0 + failed = 0 + + print('\n' + '=' * 60) + print('章节字数检查报告') + print('=' * 60) + + for result in results: + if not result['exists']: + print(f'\n❌ {result["file"]}') + print(f' {result["message"]}') + continue + + total_words += result['word_count'] + if result['status'] == 'pass': + passed += 1 + icon = '✅' + else: + failed += 1 + icon = '⚠️ ' + + print(f'\n{icon} {Path(result["file"]).name}') + print(f' {result["message"]}') + + print('\n' + '-' * 60) + print(f'总计: {len(results)} 章 | {passed} 章达标 | {failed} 章不足 | 总字数: {total_words:,}') + print('-' * 60) + + if failed > 0: + print(f'\n⚠️ 有 {failed} 章内容不足 {min_words} 字,建议使用扩充技巧:') + print(' - 添加细节描写(环境、心理、动作)') + print(' - 增加对话场景') + print(' - 扩展人物内心活动') + print(' - 补充背景故事') + print('\n 参考: references/content-expansion.md') + + +def main() -> None: + """主函数""" + if len(sys.argv) < 2: + print('用法:') + print(' 检查单个章节: python check_chapter_wordcount.py <章节文件路径> [最小字数]') + print(' 检查所有章节: python check_chapter_wordcount.py --all <目录路径> [最小字数]') + print('') + print('示例:') + print(' python check_chapter_wordcount.py novels/故事/第01章.md') + print(' python check_chapter_wordcount.py novels/故事/第01章.md 3500') + print(' python check_chapter_wordcount.py --all novels/故事') + print(' python check_chapter_wordcount.py --all novels/故事 3500') + return + + if sys.argv[1] == '--all': + if len(sys.argv) < 3: + print('错误: 使用 --all 时需要指定目录路径') + return + directory = sys.argv[2] + min_words = int(sys.argv[3]) if len(sys.argv) > 3 else 3000 + results = check_all_chapters(directory, min_words=min_words) + print_results(results, min_words) + return + + file_path = sys.argv[1] + min_words = int(sys.argv[2]) if len(sys.argv) > 2 else 3000 + result = check_chapter(file_path, min_words) + print_results([result], min_words) + + +if __name__ == '__main__': + main() diff --git a/state/characters.go b/state/characters.go new file mode 100644 index 0000000..72806e0 --- /dev/null +++ b/state/characters.go @@ -0,0 +1,45 @@ +package state + +import ( + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveCharacters 同时保存 characters.json 和 characters.md。 +func (s *Store) SaveCharacters(chars []domain.Character) error { + if err := s.writeJSON("characters.json", chars); err != nil { + return err + } + return s.writeMarkdown("characters.md", renderCharacters(chars)) +} + +// LoadCharacters 从 characters.json 读取角色档案。 +func (s *Store) LoadCharacters() ([]domain.Character, error) { + var chars []domain.Character + if err := s.readJSON("characters.json", &chars); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return chars, nil +} + +func renderCharacters(chars []domain.Character) string { + var b strings.Builder + b.WriteString("# 角色档案\n\n") + for _, c := range chars { + fmt.Fprintf(&b, "## %s(%s)\n\n", c.Name, c.Role) + fmt.Fprintf(&b, "%s\n\n", c.Description) + if c.Arc != "" { + fmt.Fprintf(&b, "**角色弧线**:%s\n\n", c.Arc) + } + if len(c.Traits) > 0 { + fmt.Fprintf(&b, "**特征**:%s\n\n", strings.Join(c.Traits, "、")) + } + } + return b.String() +} diff --git a/state/drafts.go b/state/drafts.go new file mode 100644 index 0000000..75013ed --- /dev/null +++ b/state/drafts.go @@ -0,0 +1,114 @@ +package state + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。 +func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error { + return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan) +} + +// LoadChapterPlan 读取章节规划。 +func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) { + var plan domain.ChapterPlan + if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &plan, nil +} + +// SaveSceneDraft 保存场景草稿到 drafts/{ch}.scene-{n}.md。 +func (s *Store) SaveSceneDraft(draft domain.SceneDraft) error { + rel := fmt.Sprintf("drafts/%02d.scene-%d.md", draft.Chapter, draft.Scene) + return s.writeMarkdown(rel, draft.Content) +} + +// LoadSceneDrafts 加载指定章节的所有场景草稿,按场景编号排序。 +func (s *Store) LoadSceneDrafts(chapter int) ([]domain.SceneDraft, error) { + pattern := filepath.Join(s.dir, "drafts", fmt.Sprintf("%02d.scene-*.md", chapter)) + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + sort.Strings(matches) + + var drafts []domain.SceneDraft + for _, m := range matches { + base := filepath.Base(m) + sceneNum := parseSceneNum(base) + content, err := os.ReadFile(m) + if err != nil { + return nil, fmt.Errorf("read scene draft %s: %w", base, err) + } + drafts = append(drafts, domain.SceneDraft{ + Chapter: chapter, + Scene: sceneNum, + Content: string(content), + WordCount: utf8.RuneCountInString(string(content)), + }) + } + return drafts, nil +} + +// SavePolished 保存打磨后的章节正文到 drafts/{ch}.polished.md。 +func (s *Store) SavePolished(chapter int, content string) error { + return s.writeMarkdown(fmt.Sprintf("drafts/%02d.polished.md", chapter), content) +} + +// LoadPolished 读取打磨后的章节正文。不存在时返回空字符串。 +func (s *Store) LoadPolished(chapter int) (string, error) { + data, err := s.readFile(fmt.Sprintf("drafts/%02d.polished.md", chapter)) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} + +// LoadChapterContent 加载章节正文:优先 polished,否则 merge scenes。 +func (s *Store) LoadChapterContent(chapter int) (string, int, error) { + polished, err := s.LoadPolished(chapter) + if err != nil { + return "", 0, err + } + if polished != "" { + return polished, utf8.RuneCountInString(polished), nil + } + drafts, err := s.LoadSceneDrafts(chapter) + if err != nil { + return "", 0, err + } + content, wc := domain.MergeScenes(drafts) + return content, wc, nil +} + +// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。 +func (s *Store) SaveFinalChapter(chapter int, content string) error { + return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content) +} + +// parseSceneNum 从文件名如 "01.scene-2.md" 提取场景编号。 +func parseSceneNum(filename string) int { + // 格式:{ch}.scene-{n}.md + parts := strings.Split(filename, "scene-") + if len(parts) < 2 { + return 0 + } + numStr := strings.TrimSuffix(parts[1], ".md") + n, _ := strconv.Atoi(numStr) + return n +} diff --git a/state/foreshadow.go b/state/foreshadow.go new file mode 100644 index 0000000..77559b4 --- /dev/null +++ b/state/foreshadow.go @@ -0,0 +1,76 @@ +package state + +import ( + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveForeshadowLedger 全量写入 foreshadow_ledger.json + foreshadow_ledger.md。 +func (s *Store) SaveForeshadowLedger(entries []domain.ForeshadowEntry) error { + if err := s.writeJSON("foreshadow_ledger.json", entries); err != nil { + return err + } + return s.writeMarkdown("foreshadow_ledger.md", renderForeshadow(entries)) +} + +// LoadForeshadowLedger 读取伏笔账本。 +func (s *Store) LoadForeshadowLedger() ([]domain.ForeshadowEntry, error) { + var entries []domain.ForeshadowEntry + if err := s.readJSON("foreshadow_ledger.json", &entries); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return entries, nil +} + +// UpdateForeshadow 批量应用伏笔增量操作。 +func (s *Store) UpdateForeshadow(chapter int, updates []domain.ForeshadowUpdate) error { + entries, err := s.LoadForeshadowLedger() + if err != nil { + return err + } + idx := make(map[string]int, len(entries)) + for i, e := range entries { + idx[e.ID] = i + } + for _, u := range updates { + switch u.Action { + case "plant": + entries = append(entries, domain.ForeshadowEntry{ + ID: u.ID, + Description: u.Description, + PlantedAt: chapter, + Status: "planted", + }) + case "advance": + if i, ok := idx[u.ID]; ok { + entries[i].Status = "advanced" + } + case "resolve": + if i, ok := idx[u.ID]; ok { + entries[i].Status = "resolved" + entries[i].ResolvedAt = chapter + } + } + } + return s.SaveForeshadowLedger(entries) +} + +func renderForeshadow(entries []domain.ForeshadowEntry) string { + var b strings.Builder + b.WriteString("# 伏笔账本\n\n") + for _, e := range entries { + status := e.Status + if e.ResolvedAt > 0 { + status = fmt.Sprintf("已回收(第 %d 章)", e.ResolvedAt) + } + fmt.Fprintf(&b, "- **[%s]** %s — 埋设于第 %d 章,状态:%s\n", + e.ID, e.Description, e.PlantedAt, status) + } + return b.String() +} diff --git a/state/outline.go b/state/outline.go new file mode 100644 index 0000000..467245f --- /dev/null +++ b/state/outline.go @@ -0,0 +1,77 @@ +package state + +import ( + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/domain" +) + +// SavePremise 保存故事前提到 premise.md。 +func (s *Store) SavePremise(content string) error { + return s.writeMarkdown("premise.md", content) +} + +// LoadPremise 读取 premise.md。不存在时返回空字符串。 +func (s *Store) LoadPremise() (string, error) { + data, err := s.readFile("premise.md") + if os.IsNotExist(err) { + return "", nil + } + return string(data), err +} + +// SaveOutline 同时保存 outline.json(机器读)和 outline.md(人读)。 +func (s *Store) SaveOutline(entries []domain.OutlineEntry) error { + if err := s.writeJSON("outline.json", entries); err != nil { + return err + } + return s.writeMarkdown("outline.md", renderOutline(entries)) +} + +// LoadOutline 从 outline.json 读取结构化大纲。 +func (s *Store) LoadOutline() ([]domain.OutlineEntry, error) { + var entries []domain.OutlineEntry + if err := s.readJSON("outline.json", &entries); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return entries, nil +} + +// GetChapterOutline 获取指定章节的大纲条目。 +func (s *Store) GetChapterOutline(chapter int) (*domain.OutlineEntry, error) { + entries, err := s.LoadOutline() + if err != nil { + return nil, err + } + for i := range entries { + if entries[i].Chapter == chapter { + return &entries[i], nil + } + } + return nil, fmt.Errorf("chapter %d not found in outline", chapter) +} + +func renderOutline(entries []domain.OutlineEntry) string { + var b strings.Builder + b.WriteString("# 大纲\n\n") + for _, e := range entries { + fmt.Fprintf(&b, "## 第 %d 章:%s\n\n", e.Chapter, e.Title) + fmt.Fprintf(&b, "**核心事件**:%s\n\n", e.CoreEvent) + if e.Hook != "" { + fmt.Fprintf(&b, "**钩子**:%s\n\n", e.Hook) + } + if len(e.Scenes) > 0 { + b.WriteString("**场景**:\n") + for i, sc := range e.Scenes { + fmt.Fprintf(&b, "%d. %s\n", i+1, sc) + } + b.WriteString("\n") + } + } + return b.String() +} diff --git a/state/progress.go b/state/progress.go new file mode 100644 index 0000000..000aded --- /dev/null +++ b/state/progress.go @@ -0,0 +1,233 @@ +package state + +import ( + "fmt" + "os" + "slices" + + "github.com/voocel/ainovel-cli/domain" +) + +// LoadProgress 读取 meta/progress.json。不存在时返回 nil。 +func (s *Store) LoadProgress() (*domain.Progress, error) { + var p domain.Progress + if err := s.readJSON("meta/progress.json", &p); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &p, nil +} + +// SaveProgress 保存进度到 meta/progress.json。 +func (s *Store) SaveProgress(p *domain.Progress) error { + return s.writeJSON("meta/progress.json", p) +} + +// InitProgress 创建初始进度。 +func (s *Store) InitProgress(novelName string, totalChapters int) error { + return s.SaveProgress(&domain.Progress{ + NovelName: novelName, + Phase: domain.PhaseInit, + TotalChapters: totalChapters, + }) +} + +// UpdatePhase 更新创作阶段。 +func (s *Store) UpdatePhase(phase domain.Phase) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + p = &domain.Progress{} + } + p.Phase = phase + return s.SaveProgress(p) +} + +// MarkChapterComplete 标记章节完成,原子性更新进度。 +// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。 +func (s *Store) MarkChapterComplete(chapter, wordCount int) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("progress not initialized, call InitProgress first") + } + if p.ChapterWordCounts == nil { + p.ChapterWordCounts = make(map[int]int) + } + // 重写场景:减去旧字数 + if oldWC, ok := p.ChapterWordCounts[chapter]; ok { + p.TotalWordCount -= oldWC + } + p.ChapterWordCounts[chapter] = wordCount + p.TotalWordCount += wordCount + if !slices.Contains(p.CompletedChapters, chapter) { + p.CompletedChapters = append(p.CompletedChapters, chapter) + } + // 仅在正常推进时更新 CurrentChapter,重写旧章节不回退指针 + if chapter+1 > p.CurrentChapter { + p.CurrentChapter = chapter + 1 + } + p.InProgressChapter = 0 + p.CompletedScenes = nil + p.Phase = domain.PhaseWriting + return s.SaveProgress(p) +} + +// MarkComplete 标记全书创作完成。 +func (s *Store) MarkComplete() error { + return s.UpdatePhase(domain.PhaseComplete) +} + +// SaveLastCommit 保存最近一次 commit 结果到 meta/last_commit.json。 +// 用于宿主程序读取结构化信号。 +func (s *Store) SaveLastCommit(result domain.CommitResult) error { + return s.writeJSON("meta/last_commit.json", result) +} + +// LoadLastCommit 读取最近一次 commit 结果。 +func (s *Store) LoadLastCommit() (*domain.CommitResult, error) { + var r domain.CommitResult + if err := s.readJSON("meta/last_commit.json", &r); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +// MarkSceneComplete 标记场景完成,用于场景级 checkpoint。 +// 切换到不同章节时自动清空旧的 CompletedScenes。 +func (s *Store) MarkSceneComplete(chapter, scene int) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return fmt.Errorf("progress not initialized, call InitProgress first") + } + // 章节切换:清空旧场景列表 + if p.InProgressChapter != chapter { + p.CompletedScenes = nil + } + p.InProgressChapter = chapter + if !slices.Contains(p.CompletedScenes, scene) { + p.CompletedScenes = append(p.CompletedScenes, scene) + } + return s.SaveProgress(p) +} + +// ClearInProgress 清除场景级进度状态(章节提交后调用)。 +func (s *Store) ClearInProgress() error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + p.InProgressChapter = 0 + p.CompletedScenes = nil + return s.SaveProgress(p) +} + +// ClearLastCommit 清除 commit 信号文件,防止重复消费。 +func (s *Store) ClearLastCommit() error { + return s.removeFile("meta/last_commit.json") +} + +// SetFlow 更新当前流程状态。 +func (s *Store) SetFlow(flow domain.FlowState) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + p.Flow = flow + return s.SaveProgress(p) +} + +// SetPendingRewrites 设置待重写章节队列和原因。 +func (s *Store) SetPendingRewrites(chapters []int, reason string) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + p.PendingRewrites = chapters + p.RewriteReason = reason + return s.SaveProgress(p) +} + +// CompleteRewrite 从待重写队列中移除已完成的章节。 +// 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。 +func (s *Store) CompleteRewrite(chapter int) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + var remaining []int + for _, ch := range p.PendingRewrites { + if ch != chapter { + remaining = append(remaining, ch) + } + } + p.PendingRewrites = remaining + if len(remaining) == 0 { + p.Flow = domain.FlowWriting + p.RewriteReason = "" + } + return s.SaveProgress(p) +} + +// ClearPendingRewrites 强制清空重写队列。 +func (s *Store) ClearPendingRewrites() error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + p.PendingRewrites = nil + p.RewriteReason = "" + p.Flow = domain.FlowWriting + return s.SaveProgress(p) +} + +// ValidateChapterCommit 校验当前章节是否允许提交。 +// 在重写/打磨流程中,只允许提交待处理队列中的章节。 +func (s *Store) ValidateChapterCommit(chapter int) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + return nil + } + if p.Flow != domain.FlowRewriting && p.Flow != domain.FlowPolishing { + return nil + } + if slices.Contains(p.PendingRewrites, chapter) { + return nil + } + + verb := "重写" + if p.Flow == domain.FlowPolishing { + verb = "打磨" + } + return fmt.Errorf("第 %d 章不在待%s队列中,当前队列:%v", chapter, verb, p.PendingRewrites) +} diff --git a/state/progress_test.go b/state/progress_test.go new file mode 100644 index 0000000..5249f4c --- /dev/null +++ b/state/progress_test.go @@ -0,0 +1,116 @@ +package state + +import ( + "testing" + + "github.com/voocel/ainovel-cli/domain" +) + +func TestSetFlow(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + _ = store.InitProgress("test", 10) + + if err := store.SetFlow(domain.FlowRewriting); err != nil { + t.Fatalf("SetFlow: %v", err) + } + + p, _ := store.LoadProgress() + if p.Flow != domain.FlowRewriting { + t.Errorf("expected FlowRewriting, got %s", p.Flow) + } +} + +func TestSetPendingRewrites(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + _ = store.InitProgress("test", 10) + + chapters := []int{3, 5, 7} + if err := store.SetPendingRewrites(chapters, "角色动机不连贯"); err != nil { + t.Fatalf("SetPendingRewrites: %v", err) + } + + p, _ := store.LoadProgress() + if len(p.PendingRewrites) != 3 { + t.Fatalf("expected 3 pending, got %d", len(p.PendingRewrites)) + } + if p.RewriteReason != "角色动机不连贯" { + t.Errorf("reason mismatch: %s", p.RewriteReason) + } +} + +func TestCompleteRewrite(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + _ = store.InitProgress("test", 10) + _ = store.SetPendingRewrites([]int{3, 5, 7}, "测试重写") + _ = store.SetFlow(domain.FlowRewriting) + + // 完成第 5 章 + if err := store.CompleteRewrite(5); err != nil { + t.Fatalf("CompleteRewrite(5): %v", err) + } + p, _ := store.LoadProgress() + if len(p.PendingRewrites) != 2 { + t.Fatalf("expected 2 pending after removing 5, got %d", len(p.PendingRewrites)) + } + if p.Flow != domain.FlowRewriting { + t.Errorf("flow should still be rewriting, got %s", p.Flow) + } + + // 完成第 3 章 + _ = store.CompleteRewrite(3) + p, _ = store.LoadProgress() + if len(p.PendingRewrites) != 1 { + t.Fatalf("expected 1 pending, got %d", len(p.PendingRewrites)) + } + + // 完成最后一章 → 自动重置 Flow + _ = store.CompleteRewrite(7) + p, _ = store.LoadProgress() + if len(p.PendingRewrites) != 0 { + t.Fatalf("expected 0 pending, got %d", len(p.PendingRewrites)) + } + if p.Flow != domain.FlowWriting { + t.Errorf("flow should reset to writing, got %s", p.Flow) + } + if p.RewriteReason != "" { + t.Errorf("reason should be cleared, got %s", p.RewriteReason) + } +} + +func TestCompleteRewrite_NotInQueue(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + _ = store.InitProgress("test", 10) + _ = store.SetPendingRewrites([]int{3, 5}, "测试") + + // 完成不在队列中的章节不应报错 + if err := store.CompleteRewrite(99); err != nil { + t.Fatalf("CompleteRewrite(99): %v", err) + } + p, _ := store.LoadProgress() + if len(p.PendingRewrites) != 2 { + t.Errorf("queue should be unchanged, got %d", len(p.PendingRewrites)) + } +} + +func TestClearPendingRewrites(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + _ = store.InitProgress("test", 10) + _ = store.SetPendingRewrites([]int{1, 2, 3}, "测试") + _ = store.SetFlow(domain.FlowRewriting) + + if err := store.ClearPendingRewrites(); err != nil { + t.Fatalf("ClearPendingRewrites: %v", err) + } + p, _ := store.LoadProgress() + if len(p.PendingRewrites) != 0 { + t.Errorf("expected empty, got %d", len(p.PendingRewrites)) + } + if p.Flow != domain.FlowWriting { + t.Errorf("flow should be writing, got %s", p.Flow) + } +} diff --git a/state/relationships.go b/state/relationships.go new file mode 100644 index 0000000..8e35f47 --- /dev/null +++ b/state/relationships.go @@ -0,0 +1,70 @@ +package state + +import ( + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveRelationships 全量写入 relationship_state.json + relationship_state.md。 +func (s *Store) SaveRelationships(entries []domain.RelationshipEntry) error { + if err := s.writeJSON("relationship_state.json", entries); err != nil { + return err + } + return s.writeMarkdown("relationship_state.md", renderRelationships(entries)) +} + +// LoadRelationships 读取人物关系状态。 +func (s *Store) LoadRelationships() ([]domain.RelationshipEntry, error) { + var entries []domain.RelationshipEntry + if err := s.readJSON("relationship_state.json", &entries); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return entries, nil +} + +// UpdateRelationships 合并关系变化。相同人物对的关系会被更新为最新值。 +func (s *Store) UpdateRelationships(changes []domain.RelationshipEntry) error { + existing, err := s.LoadRelationships() + if err != nil { + return err + } + // 用 pair key 索引 + idx := make(map[string]int, len(existing)) + for i, e := range existing { + idx[pairKey(e.CharacterA, e.CharacterB)] = i + } + for _, c := range changes { + key := pairKey(c.CharacterA, c.CharacterB) + if i, ok := idx[key]; ok { + existing[i].Relation = c.Relation + existing[i].Chapter = c.Chapter + } else { + idx[key] = len(existing) + existing = append(existing, c) + } + } + return s.SaveRelationships(existing) +} + +func pairKey(a, b string) string { + if a > b { + a, b = b, a + } + return a + "|" + b +} + +func renderRelationships(entries []domain.RelationshipEntry) string { + var b strings.Builder + b.WriteString("# 人物关系\n\n") + for _, e := range entries { + fmt.Fprintf(&b, "- **%s ↔ %s**:%s(第 %d 章)\n", + e.CharacterA, e.CharacterB, e.Relation, e.Chapter) + } + return b.String() +} diff --git a/state/reviews.go b/state/reviews.go new file mode 100644 index 0000000..92774ad --- /dev/null +++ b/state/reviews.go @@ -0,0 +1,66 @@ +package state + +import ( + "fmt" + "os" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveReview 保存审阅结果。scope=chapter 写 reviews/{ch}.json,scope=global 写 reviews/{ch}-global.json。 +func (s *Store) SaveReview(r domain.ReviewEntry) error { + rel := fmt.Sprintf("reviews/%02d.json", r.Chapter) + if r.Scope == "global" { + rel = fmt.Sprintf("reviews/%02d-global.json", r.Chapter) + } + return s.writeJSON(rel, r) +} + +// LoadReview 读取章节审阅结果。 +func (s *Store) LoadReview(chapter int) (*domain.ReviewEntry, error) { + var r domain.ReviewEntry + if err := s.readJSON(fmt.Sprintf("reviews/%02d.json", chapter), &r); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +// SaveLastReview 保存最近一次审阅结果到 meta/last_review.json,供宿主读取。 +func (s *Store) SaveLastReview(r domain.ReviewEntry) error { + return s.writeJSON("meta/last_review.json", r) +} + +// LoadLastReviewSignal 读取审阅信号文件。 +func (s *Store) LoadLastReviewSignal() (*domain.ReviewEntry, error) { + var r domain.ReviewEntry + if err := s.readJSON("meta/last_review.json", &r); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &r, nil +} + +// ClearLastReview 清除审阅信号文件,防止重复消费。 +func (s *Store) ClearLastReview() error { + return s.removeFile("meta/last_review.json") +} + +// LoadLastReview 读取最近一次全局审阅。从 chapter 往前搜索。 +func (s *Store) LoadLastReview(fromChapter int) (*domain.ReviewEntry, error) { + for ch := fromChapter; ch >= 1; ch-- { + var r domain.ReviewEntry + if err := s.readJSON(fmt.Sprintf("reviews/%02d-global.json", ch), &r); err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + return &r, nil + } + return nil, nil +} diff --git a/state/run_meta.go b/state/run_meta.go new file mode 100644 index 0000000..63238f9 --- /dev/null +++ b/state/run_meta.go @@ -0,0 +1,91 @@ +package state + +import ( + "fmt" + "os" + "time" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveRunMeta 保存运行元信息到 meta/run.json。 +func (s *Store) SaveRunMeta(meta domain.RunMeta) error { + return s.writeJSON("meta/run.json", meta) +} + +// LoadRunMeta 读取运行元信息。 +func (s *Store) LoadRunMeta() (*domain.RunMeta, error) { + var meta domain.RunMeta + if err := s.readJSON("meta/run.json", &meta); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &meta, nil +} + +// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。 +func (s *Store) InitRunMeta(style, model string) error { + existing, _ := s.LoadRunMeta() + meta := domain.RunMeta{ + StartedAt: time.Now().Format(time.RFC3339), + Style: style, + Model: model, + } + if existing != nil { + meta.SteerHistory = existing.SteerHistory + meta.PendingSteer = existing.PendingSteer + } + return s.SaveRunMeta(meta) +} + +// AppendSteerEntry 追加用户干预记录到 meta/run.json。 +func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error { + meta, err := s.LoadRunMeta() + if err != nil { + return err + } + if meta == nil { + meta = &domain.RunMeta{} + } + meta.SteerHistory = append(meta.SteerHistory, entry) + return s.SaveRunMeta(*meta) +} + +// SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。 +func (s *Store) SetPendingSteer(input string) error { + meta, err := s.LoadRunMeta() + if err != nil { + return err + } + if meta == nil { + meta = &domain.RunMeta{} + } + meta.PendingSteer = input + return s.SaveRunMeta(*meta) +} + +// ClearPendingSteer 清除已处理的 Steer 指令。 +func (s *Store) ClearPendingSteer() error { + meta, err := s.LoadRunMeta() + if err != nil { + return err + } + if meta == nil || meta.PendingSteer == "" { + return nil + } + meta.PendingSteer = "" + return s.SaveRunMeta(*meta) +} + +// SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。 +func (s *Store) SaveCheckpoint(label string) error { + p, err := s.LoadProgress() + if err != nil || p == nil { + return err + } + ts := time.Now().Format("20060102-150405") + rel := fmt.Sprintf("meta/checkpoints/%s-%s.json", ts, label) + return s.writeJSON(rel, p) +} diff --git a/state/run_meta_test.go b/state/run_meta_test.go new file mode 100644 index 0000000..d6b3446 --- /dev/null +++ b/state/run_meta_test.go @@ -0,0 +1,183 @@ +package state + +import ( + "os" + "testing" + + "github.com/voocel/ainovel-cli/domain" +) + +func TestSaveAndLoadRunMeta(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + meta := domain.RunMeta{ + StartedAt: "2026-03-07T10:00:00+08:00", + Style: "fantasy", + Model: "gpt-4o", + } + if err := store.SaveRunMeta(meta); err != nil { + t.Fatalf("SaveRunMeta: %v", err) + } + + loaded, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta: %v", err) + } + if loaded.Style != "fantasy" { + t.Errorf("style mismatch: %s", loaded.Style) + } + if loaded.Model != "gpt-4o" { + t.Errorf("model mismatch: %s", loaded.Model) + } +} + +func TestLoadRunMeta_Empty(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + meta, err := store.LoadRunMeta() + if err != nil { + t.Fatalf("LoadRunMeta on empty: %v", err) + } + if meta != nil { + t.Fatalf("expected nil, got %+v", meta) + } +} + +func TestAppendSteerEntry(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + // 首次追加(meta/run.json 不存在) + e1 := domain.SteerEntry{Input: "主角改成女性", Timestamp: "2026-03-07T10:01:00+08:00"} + if err := store.AppendSteerEntry(e1); err != nil { + t.Fatalf("AppendSteerEntry 1: %v", err) + } + + meta, _ := store.LoadRunMeta() + if len(meta.SteerHistory) != 1 { + t.Fatalf("expected 1 entry, got %d", len(meta.SteerHistory)) + } + if meta.SteerHistory[0].Input != "主角改成女性" { + t.Errorf("input mismatch: %s", meta.SteerHistory[0].Input) + } + + // 追加第二条 + e2 := domain.SteerEntry{Input: "加入反转", Timestamp: "2026-03-07T10:02:00+08:00"} + _ = store.AppendSteerEntry(e2) + + meta, _ = store.LoadRunMeta() + if len(meta.SteerHistory) != 2 { + t.Fatalf("expected 2 entries, got %d", len(meta.SteerHistory)) + } +} + +func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + // 先保存 RunMeta + _ = store.SaveRunMeta(domain.RunMeta{ + StartedAt: "2026-03-07T10:00:00+08:00", + Style: "suspense", + Model: "gpt-4o", + }) + + // 追加 Steer 不应覆盖其他字段 + _ = store.AppendSteerEntry(domain.SteerEntry{Input: "test", Timestamp: "now"}) + + meta, _ := store.LoadRunMeta() + if meta.Style != "suspense" { + t.Errorf("style should be preserved, got %s", meta.Style) + } + if meta.Model != "gpt-4o" { + t.Errorf("model should be preserved, got %s", meta.Model) + } + if len(meta.SteerHistory) != 1 { + t.Errorf("expected 1 steer entry, got %d", len(meta.SteerHistory)) + } +} + +func TestInitRunMeta_PreservesHistory(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + // 先建立带历史的 RunMeta + _ = store.SaveRunMeta(domain.RunMeta{ + StartedAt: "old", + Style: "fantasy", + Model: "old-model", + SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}}, + PendingSteer: "待处理", + }) + + // InitRunMeta 应保留 SteerHistory 和 PendingSteer + _ = store.InitRunMeta("suspense", "new-model") + + meta, _ := store.LoadRunMeta() + if meta.Style != "suspense" { + t.Errorf("style should be updated, got %s", meta.Style) + } + if meta.Model != "new-model" { + t.Errorf("model should be updated, got %s", meta.Model) + } + if len(meta.SteerHistory) != 1 || meta.SteerHistory[0].Input != "历史干预" { + t.Errorf("steer history should be preserved, got %v", meta.SteerHistory) + } + if meta.PendingSteer != "待处理" { + t.Errorf("pending steer should be preserved, got %s", meta.PendingSteer) + } +} + +func TestSetAndClearPendingSteer(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + // 设置 PendingSteer + if err := store.SetPendingSteer("主角改成女性"); err != nil { + t.Fatalf("SetPendingSteer: %v", err) + } + meta, _ := store.LoadRunMeta() + if meta.PendingSteer != "主角改成女性" { + t.Errorf("expected pending steer, got %s", meta.PendingSteer) + } + + // 清除 + if err := store.ClearPendingSteer(); err != nil { + t.Fatalf("ClearPendingSteer: %v", err) + } + meta, _ = store.LoadRunMeta() + if meta.PendingSteer != "" { + t.Errorf("expected empty pending steer, got %s", meta.PendingSteer) + } +} + +func TestClearPendingSteer_Noop(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + // 空 meta 上调用不报错 + if err := store.ClearPendingSteer(); err != nil { + t.Fatalf("ClearPendingSteer on empty: %v", err) + } +} + +func TestSaveCheckpoint(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + _ = store.InitProgress("test", 10) + + if err := store.SaveCheckpoint("ch01-commit"); err != nil { + t.Fatalf("SaveCheckpoint: %v", err) + } + + // 验证 checkpoint 目录下有文件 + entries, err := os.ReadDir(dir + "/meta/checkpoints") + if err != nil { + t.Fatalf("read checkpoints dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 checkpoint, got %d", len(entries)) + } +} diff --git a/state/store.go b/state/store.go new file mode 100644 index 0000000..ff25655 --- /dev/null +++ b/state/store.go @@ -0,0 +1,76 @@ +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Store 封装小说输出目录,提供所有状态读写操作。 +type Store struct { + dir string +} + +// NewStore 创建状态管理器,dir 为小说输出根目录。 +func NewStore(dir string) *Store { + return &Store{dir: dir} +} + +// Dir 返回输出根目录。 +func (s *Store) Dir() string { return s.dir } + +// Init 创建所需的子目录结构。 +func (s *Store) Init() error { + dirs := []string{"chapters", "summaries", "drafts", "reviews", "meta"} + for _, d := range dirs { + if err := os.MkdirAll(filepath.Join(s.dir, d), 0o755); err != nil { + return fmt.Errorf("create dir %s: %w", d, err) + } + } + return nil +} + +func (s *Store) path(rel string) string { + return filepath.Join(s.dir, rel) +} + +func (s *Store) readFile(rel string) ([]byte, error) { + return os.ReadFile(s.path(rel)) +} + +func (s *Store) writeFile(rel string, data []byte) error { + p := s.path(rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, data, 0o644) +} + +func (s *Store) readJSON(rel string, v any) error { + data, err := s.readFile(rel) + if err != nil { + return err + } + return json.Unmarshal(data, v) +} + +func (s *Store) writeJSON(rel string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return s.writeFile(rel, data) +} + +func (s *Store) writeMarkdown(rel string, content string) error { + return s.writeFile(rel, []byte(content)) +} + +func (s *Store) removeFile(rel string) error { + err := os.Remove(s.path(rel)) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/state/summaries.go b/state/summaries.go new file mode 100644 index 0000000..5228af7 --- /dev/null +++ b/state/summaries.go @@ -0,0 +1,41 @@ +package state + +import ( + "fmt" + "os" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveSummary 保存章节摘要到 summaries/{ch}.json。 +func (s *Store) SaveSummary(sum domain.ChapterSummary) error { + return s.writeJSON(fmt.Sprintf("summaries/%02d.json", sum.Chapter), sum) +} + +// LoadSummary 读取指定章节的摘要。 +func (s *Store) LoadSummary(chapter int) (*domain.ChapterSummary, error) { + var sum domain.ChapterSummary + if err := s.readJSON(fmt.Sprintf("summaries/%02d.json", chapter), &sum); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return &sum, nil +} + +// LoadRecentSummaries 加载 current 章之前最近 count 章的摘要。 +func (s *Store) LoadRecentSummaries(current, count int) ([]domain.ChapterSummary, error) { + var result []domain.ChapterSummary + start := max(current-count, 1) + for ch := start; ch < current; ch++ { + sum, err := s.LoadSummary(ch) + if err != nil { + return nil, err + } + if sum != nil { + result = append(result, *sum) + } + } + return result, nil +} diff --git a/state/timeline.go b/state/timeline.go new file mode 100644 index 0000000..f46bd12 --- /dev/null +++ b/state/timeline.go @@ -0,0 +1,51 @@ +package state + +import ( + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveTimeline 全量写入 timeline.json + timeline.md。 +func (s *Store) SaveTimeline(events []domain.TimelineEvent) error { + if err := s.writeJSON("timeline.json", events); err != nil { + return err + } + return s.writeMarkdown("timeline.md", renderTimeline(events)) +} + +// LoadTimeline 读取时间线。 +func (s *Store) LoadTimeline() ([]domain.TimelineEvent, error) { + var events []domain.TimelineEvent + if err := s.readJSON("timeline.json", &events); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return events, nil +} + +// AppendTimelineEvents 追加时间线事件。 +func (s *Store) AppendTimelineEvents(newEvents []domain.TimelineEvent) error { + existing, err := s.LoadTimeline() + if err != nil { + return err + } + return s.SaveTimeline(append(existing, newEvents...)) +} + +func renderTimeline(events []domain.TimelineEvent) string { + var b strings.Builder + b.WriteString("# 时间线\n\n") + for _, e := range events { + chars := "" + if len(e.Characters) > 0 { + chars = "(" + strings.Join(e.Characters, "、") + ")" + } + fmt.Fprintf(&b, "- **第 %d 章 [%s]**:%s%s\n", e.Chapter, e.Time, e.Event, chars) + } + return b.String() +} diff --git a/state/world_rules.go b/state/world_rules.go new file mode 100644 index 0000000..2553bee --- /dev/null +++ b/state/world_rules.go @@ -0,0 +1,58 @@ +package state + +import ( + "fmt" + "os" + "strings" + + "github.com/voocel/ainovel-cli/domain" +) + +// SaveWorldRules 全量写入 world_rules.json + world_rules.md。 +func (s *Store) SaveWorldRules(rules []domain.WorldRule) error { + if err := s.writeJSON("world_rules.json", rules); err != nil { + return err + } + return s.writeMarkdown("world_rules.md", renderWorldRules(rules)) +} + +// LoadWorldRules 读取世界规则。 +func (s *Store) LoadWorldRules() ([]domain.WorldRule, error) { + var rules []domain.WorldRule + if err := s.readJSON("world_rules.json", &rules); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return rules, nil +} + +func renderWorldRules(rules []domain.WorldRule) string { + grouped := make(map[string][]domain.WorldRule) + var order []string + for _, r := range rules { + cat := r.Category + if cat == "" { + cat = "other" + } + if _, exists := grouped[cat]; !exists { + order = append(order, cat) + } + grouped[cat] = append(grouped[cat], r) + } + + var b strings.Builder + b.WriteString("# 世界观规则\n\n") + for _, cat := range order { + fmt.Fprintf(&b, "## %s\n\n", cat) + for _, r := range grouped[cat] { + fmt.Fprintf(&b, "- **规则**:%s\n", r.Rule) + if r.Boundary != "" { + fmt.Fprintf(&b, " - 边界:%s\n", r.Boundary) + } + } + b.WriteString("\n") + } + return b.String() +} diff --git a/state/world_rules_test.go b/state/world_rules_test.go new file mode 100644 index 0000000..47e21d9 --- /dev/null +++ b/state/world_rules_test.go @@ -0,0 +1,140 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + + "github.com/voocel/ainovel-cli/domain" +) + +func TestSaveAndLoadWorldRules(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + rules := []domain.WorldRule{ + {Category: "magic", Rule: "法术消耗精神力", Boundary: "精神力耗尽会昏迷"}, + {Category: "magic", Rule: "禁咒需要三人合力", Boundary: "单人强行施放会死亡"}, + {Category: "society", Rule: "贵族拥有领地裁判权", Boundary: "不得越权审判其他领地居民"}, + } + + if err := store.SaveWorldRules(rules); err != nil { + t.Fatalf("SaveWorldRules: %v", err) + } + + // 验证 JSON 文件存在 + if _, err := os.Stat(filepath.Join(dir, "world_rules.json")); err != nil { + t.Fatalf("world_rules.json not created: %v", err) + } + // 验证 Markdown 文件存在 + if _, err := os.Stat(filepath.Join(dir, "world_rules.md")); err != nil { + t.Fatalf("world_rules.md not created: %v", err) + } + + loaded, err := store.LoadWorldRules() + if err != nil { + t.Fatalf("LoadWorldRules: %v", err) + } + if len(loaded) != 3 { + t.Fatalf("expected 3 rules, got %d", len(loaded)) + } + if loaded[0].Category != "magic" || loaded[0].Rule != "法术消耗精神力" { + t.Errorf("first rule mismatch: %+v", loaded[0]) + } + if loaded[2].Category != "society" { + t.Errorf("third rule category mismatch: %+v", loaded[2]) + } +} + +func TestLoadWorldRules_Empty(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + rules, err := store.LoadWorldRules() + if err != nil { + t.Fatalf("LoadWorldRules on empty dir: %v", err) + } + if rules != nil { + t.Fatalf("expected nil for missing file, got %v", rules) + } +} + +func TestSaveWorldRules_Overwrite(t *testing.T) { + dir := t.TempDir() + store := NewStore(dir) + + v1 := []domain.WorldRule{{Category: "old", Rule: "旧规则", Boundary: "旧边界"}} + if err := store.SaveWorldRules(v1); err != nil { + t.Fatalf("SaveWorldRules v1: %v", err) + } + + v2 := []domain.WorldRule{ + {Category: "new", Rule: "新规则A", Boundary: "新边界A"}, + {Category: "new", Rule: "新规则B", Boundary: "新边界B"}, + } + if err := store.SaveWorldRules(v2); err != nil { + t.Fatalf("SaveWorldRules v2: %v", err) + } + + loaded, err := store.LoadWorldRules() + if err != nil { + t.Fatalf("LoadWorldRules: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 rules after overwrite, got %d", len(loaded)) + } + if loaded[0].Category != "new" { + t.Errorf("expected new category, got %s", loaded[0].Category) + } +} + +func TestRenderWorldRules(t *testing.T) { + rules := []domain.WorldRule{ + {Category: "magic", Rule: "法术消耗精神力", Boundary: "精神力耗尽会昏迷"}, + {Category: "society", Rule: "贵族有裁判权", Boundary: ""}, + {Category: "magic", Rule: "禁咒需三人", Boundary: "单人施放会死"}, + } + + md := renderWorldRules(rules) + + // 验证标题 + if got := md[:len("# 世界观规则")]; got != "# 世界观规则" { + t.Errorf("missing title, got: %s", got) + } + // 验证分组:magic 应该出现在 society 之前(按输入顺序) + magicPos := indexOf(md, "## magic") + societyPos := indexOf(md, "## society") + if magicPos < 0 || societyPos < 0 { + t.Fatalf("missing category headers in:\n%s", md) + } + if magicPos >= societyPos { + t.Errorf("magic should appear before society") + } + // 验证边界渲染:有 boundary 的条目应包含"边界"字样 + if indexOf(md, "边界:精神力耗尽会昏迷") < 0 { + t.Errorf("missing boundary in:\n%s", md) + } + // 验证无 boundary 的条目不输出边界行 + if indexOf(md, "边界:\n") >= 0 { + t.Errorf("empty boundary should not be rendered") + } +} + +func TestRenderWorldRules_EmptyCategoryFallback(t *testing.T) { + rules := []domain.WorldRule{ + {Category: "", Rule: "无分类规则", Boundary: "边界"}, + } + md := renderWorldRules(rules) + if indexOf(md, "## other") < 0 { + t.Errorf("empty category should fall back to 'other', got:\n%s", md) + } +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/styles/default.md b/styles/default.md new file mode 100644 index 0000000..50d569c --- /dev/null +++ b/styles/default.md @@ -0,0 +1,7 @@ +## 通用写作风格 + +- 叙事节奏:张弛有度,关键转折放慢,过渡紧凑 +- 描写方式:五感具象描写优先于抽象概述 +- 对话要求:体现人物性格差异,自然流畅,避免说教 +- 情感表达:通过动作和细节传递,不直接点明情绪 +- 文字风格:简洁有力,避免过度修饰 diff --git a/styles/fantasy.md b/styles/fantasy.md new file mode 100644 index 0000000..05a333e --- /dev/null +++ b/styles/fantasy.md @@ -0,0 +1,10 @@ +## 奇幻冒险风格 + +- **世界观展开**:不集中灌输设定,通过角色互动和行动自然展示世界规则 +- **魔法/能力体系**:有明确代价和限制,避免万能型能力,冲突中展示体系边界 +- **史诗感营造**:宏大叙事与个人命运交织,小人物视角折射大格局 +- **种族与文化**:不同种族/文化有独特语言习惯、价值观和行为模式 +- **战斗场景**:注重策略和代价,避免单纯的力量碾压,利用环境和智谋取胜 +- **旅途叙事**:每个新场景都有独特的视觉/感官特征,避免"又一个村庄" +- **成长弧线**:主角的能力成长与心智成长同步,代价与收获并存 +- **命名体系**:保持风格统一,避免突兀的现代词汇打破沉浸感 diff --git a/styles/romance.md b/styles/romance.md new file mode 100644 index 0000000..438ec72 --- /dev/null +++ b/styles/romance.md @@ -0,0 +1,10 @@ +## 言情风格 + +- **情感递进**:遵循接触→好感→冲突→和解→深入的自然节奏,不急于推进 +- **关系张力**:每个阶段都需要合理的阻碍,阻碍来源要多样(性格、立场、误解、外部压力) +- **内心描写**:深入角色内心,展现矛盾和挣扎,但避免大段独白式心理分析 +- **互动细节**:用微表情、小动作、不经意的习惯传递情感,比直接告白更有张力 +- **对话节奏**:暧昧期对话留白多,甜蜜期对话轻松自然,冲突期对话尖锐但克制 +- **场景氛围**:环境描写与情感状态呼应,但不过度使用"下雨=悲伤"类刻板隐喻 +- **配角功能**:闺蜜/兄弟角色推动情节发展,不沦为恋爱咨询工具 +- **冲突设计**:误会不能靠"一句话就能解释清楚"维持,矛盾要触及核心价值观差异 diff --git a/styles/suspense.md b/styles/suspense.md new file mode 100644 index 0000000..d108470 --- /dev/null +++ b/styles/suspense.md @@ -0,0 +1,10 @@ +## 悬疑推理风格 + +- **叙事结构**:多线叙事交织,信息差制造悬念,逐步揭示真相 +- **误导技法**:合理设置红鲱鱼(红herring),利用叙述视角盲区误导读者 +- **线索管理**:关键线索必须在揭示前至少出现两次,但不能太明显 +- **节奏控制**:紧张-舒缓交替,每章末留悬念钩子,高潮前适当减速蓄力 +- **氛围营造**:环境描写服务于紧张感,利用光影、声音、天气渲染不安 +- **人物行为**:角色的每个决策必须有动机支撑,避免"为了推动剧情而做蠢事" +- **对话风格**:言外之意多于字面意思,审讯/对峙场景注重攻防节奏 +- **真相揭示**:不能靠巧合或未出现的证据,读者回看时能发现伏笔 diff --git a/tools/check_consistency.go b/tools/check_consistency.go new file mode 100644 index 0000000..6425c42 --- /dev/null +++ b/tools/check_consistency.go @@ -0,0 +1,125 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// CheckConsistencyTool 对照状态文件检查章节一致性。 +// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理。 +type CheckConsistencyTool struct { + store *state.Store +} + +func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool { + return &CheckConsistencyTool{store: store} +} + +func (t *CheckConsistencyTool) Name() string { return "check_consistency" } +func (t *CheckConsistencyTool) Description() string { + return "检查章节一致性。返回章节内容、全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项" +} +func (t *CheckConsistencyTool) Label() string { return "一致性检查" } + +func (t *CheckConsistencyTool) Schema() map[string]any { + return schema.Object( + schema.Property("chapter", schema.Int("要检查的章节号")).Required(), + ) +} + +func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Chapter int `json:"chapter"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if a.Chapter <= 0 { + return nil, fmt.Errorf("chapter must be > 0") + } + + result := map[string]any{"chapter": a.Chapter} + + // 加载章节内容(polished 优先) + content, wordCount, err := t.store.LoadChapterContent(a.Chapter) + if err != nil { + return nil, fmt.Errorf("load chapter content: %w", err) + } + if content == "" { + return nil, fmt.Errorf("no content found for chapter %d", a.Chapter) + } + result["content"] = content + result["word_count"] = wordCount + + // 加载全部状态数据供 LLM 对照 + if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 { + result["timeline"] = timeline + } + if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 { + result["foreshadow_ledger"] = foreshadow + if active := filterActive(foreshadow); len(active) > 0 { + result["unresolved_foreshadow"] = active + } + } + if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 { + result["relationships"] = relationships + } + if chars, _ := t.store.LoadCharacters(); len(chars) > 0 { + result["characters"] = chars + } + + if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 { + result["world_rules"] = rules + // 提取边界清单,方便 LLM 逐条对照 + var boundaries []string + for _, r := range rules { + if r.Boundary != "" { + boundaries = append(boundaries, fmt.Sprintf("[%s] %s", r.Category, r.Boundary)) + } + } + if len(boundaries) > 0 { + result["world_rules_boundaries"] = boundaries + } + } + + // 加载前两章摘要 + if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 { + result["recent_summaries"] = summaries + } + + result["instruction"] = `请逐项对照以上状态数据检查本章内容,返回 JSON 数组格式的冲突项: +[ + { + "type": "timeline|foreshadow|relationship|character|world_rules", + "severity": "error|warning", + "description": "具体冲突描述", + "suggestion": "建议修正范围和方式" + } +] + +检查清单: +1. 时间线:本章事件时间是否与已有 timeline 矛盾 +2. 伏笔:unresolved_foreshadow 中是否有本章应推进但遗漏的 +3. 人物关系:角色互动是否与 relationships 当前状态矛盾 +4. 角色一致性:行为是否符合 characters 中的性格和弧线 +5. 世界规则:逐条检查 world_rules_boundaries 中的边界约束,本章内容是否违反任何一条 + +如果没有发现冲突,返回空数组 []。不要返回其他格式。` + + return json.Marshal(result) +} + +func filterActive(entries []domain.ForeshadowEntry) []domain.ForeshadowEntry { + var active []domain.ForeshadowEntry + for _, e := range entries { + if e.Status != "resolved" { + active = append(active, e) + } + } + return active +} diff --git a/tools/commit_chapter.go b/tools/commit_chapter.go new file mode 100644 index 0000000..58ed0e4 --- /dev/null +++ b/tools/commit_chapter.go @@ -0,0 +1,168 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。 +// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。 +type CommitChapterTool struct { + store *state.Store +} + +func NewCommitChapterTool(store *state.Store) *CommitChapterTool { + return &CommitChapterTool{store: store} +} + +func (t *CommitChapterTool) Name() string { return "commit_chapter" } +func (t *CommitChapterTool) Description() string { + return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号" +} +func (t *CommitChapterTool) Label() string { return "提交章节" } + +func (t *CommitChapterTool) Schema() map[string]any { + timelineSchema := schema.Object( + schema.Property("time", schema.String("故事内时间")).Required(), + schema.Property("event", schema.String("事件描述")).Required(), + schema.Property("characters", schema.Array("涉及角色", schema.String(""))), + ) + foreshadowSchema := schema.Object( + schema.Property("id", schema.String("伏笔 ID(新埋设时自定义,推进/回收时使用已有 ID)")).Required(), + schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(), + schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")), + ) + relationshipSchema := schema.Object( + schema.Property("character_a", schema.String("角色 A")).Required(), + schema.Property("character_b", schema.String("角色 B")).Required(), + schema.Property("relation", schema.String("当前关系描述")).Required(), + ) + return schema.Object( + schema.Property("chapter", schema.Int("章节号")).Required(), + schema.Property("summary", schema.String("本章内容摘要(200字以内)")).Required(), + schema.Property("characters", schema.Array("本章出场角色名", schema.String(""))).Required(), + schema.Property("key_events", schema.Array("本章关键事件", schema.String(""))).Required(), + schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)), + schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)), + schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)), + ) +} + +func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Chapter int `json:"chapter"` + Summary string `json:"summary"` + Characters []string `json:"characters"` + KeyEvents []string `json:"key_events"` + TimelineEvents []domain.TimelineEvent `json:"timeline_events"` + ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"` + RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if a.Chapter <= 0 { + return nil, fmt.Errorf("chapter must be > 0") + } + if err := t.store.ValidateChapterCommit(a.Chapter); err != nil { + return nil, err + } + + // 1. 加载章节正文(polished 优先,否则 merge scenes) + content, wordCount, err := t.store.LoadChapterContent(a.Chapter) + if err != nil { + return nil, fmt.Errorf("load chapter content: %w", err) + } + if content == "" { + return nil, fmt.Errorf("no content found for chapter %d", a.Chapter) + } + + // 2. 保存终稿 + if err := t.store.SaveFinalChapter(a.Chapter, content); err != nil { + return nil, fmt.Errorf("save final chapter: %w", err) + } + + // 3. 保存摘要 + summary := domain.ChapterSummary{ + Chapter: a.Chapter, + Summary: a.Summary, + Characters: a.Characters, + KeyEvents: a.KeyEvents, + } + if err := t.store.SaveSummary(summary); err != nil { + return nil, fmt.Errorf("save summary: %w", err) + } + + // 4. 更新状态增量 + if len(a.TimelineEvents) > 0 { + for i := range a.TimelineEvents { + a.TimelineEvents[i].Chapter = a.Chapter + } + if err := t.store.AppendTimelineEvents(a.TimelineEvents); err != nil { + return nil, fmt.Errorf("append timeline: %w", err) + } + } + if len(a.ForeshadowUpdates) > 0 { + if err := t.store.UpdateForeshadow(a.Chapter, a.ForeshadowUpdates); err != nil { + return nil, fmt.Errorf("update foreshadow: %w", err) + } + } + if len(a.RelationshipChanges) > 0 { + for i := range a.RelationshipChanges { + a.RelationshipChanges[i].Chapter = a.Chapter + } + if err := t.store.UpdateRelationships(a.RelationshipChanges); err != nil { + return nil, fmt.Errorf("update relationships: %w", err) + } + } + + // 5. 更新进度 + if err := t.store.MarkChapterComplete(a.Chapter, wordCount); err != nil { + return nil, fmt.Errorf("mark chapter complete: %w", err) + } + + // 6. 判断是否需要审阅 + progress, err := t.store.LoadProgress() + if err != nil { + return nil, fmt.Errorf("load progress: %w", err) + } + completedCount := 0 + if progress != nil { + completedCount = len(progress.CompletedChapters) + } + reviewRequired, reviewReason := domain.ShouldReview(completedCount) + + // 7. 计算场景数 + sceneCount := 0 + if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil { + sceneCount = len(scenes) + } + + // 8. 构造结构化信号 + result := domain.CommitResult{ + Chapter: a.Chapter, + Committed: true, + WordCount: wordCount, + SceneCount: sceneCount, + NextChapter: a.Chapter + 1, + ReviewRequired: reviewRequired, + ReviewReason: reviewReason, + } + + // 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失) + if err := t.store.SaveLastCommit(result); err != nil { + return nil, fmt.Errorf("save commit signal: %w", err) + } + + // 10. 清除场景级进度(章节已提交) + if err := t.store.ClearInProgress(); err != nil { + return nil, fmt.Errorf("clear in-progress: %w", err) + } + + return json.Marshal(result) +} diff --git a/tools/commit_chapter_test.go b/tools/commit_chapter_test.go new file mode 100644 index 0000000..a226040 --- /dev/null +++ b/tools/commit_chapter_test.go @@ -0,0 +1,110 @@ +package tools + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +func TestCommitChapterRejectsNonPendingRewrite(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", 10); err != nil { + t.Fatalf("InitProgress: %v", err) + } + if err := store.SetPendingRewrites([]int{2}, "测试重写"); err != nil { + t.Fatalf("SetPendingRewrites: %v", err) + } + if err := store.SetFlow(domain.FlowRewriting); err != nil { + t.Fatalf("SetFlow: %v", err) + } + if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil { + t.Fatalf("SavePolished: %v", err) + } + + tool := NewCommitChapterTool(store) + args, err := json.Marshal(map[string]any{ + "chapter": 3, + "summary": "错误提交", + "characters": []string{"主角"}, + "key_events": []string{"误提交"}, + "timeline_events": []any{}, + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + if _, err := tool.Execute(context.Background(), args); err == nil { + t.Fatal("expected commit to be rejected during rewrite flow") + } + + if _, err := os.Stat(dir + "/chapters/03.md"); !os.IsNotExist(err) { + t.Fatalf("chapter should not be persisted, stat err=%v", err) + } + + progress, err := store.LoadProgress() + if err != nil { + t.Fatalf("LoadProgress: %v", err) + } + if len(progress.CompletedChapters) != 0 { + t.Fatalf("completed chapters should stay empty, got %v", progress.CompletedChapters) + } + if progress.CurrentChapter != 0 { + t.Fatalf("current chapter should not advance, got %d", progress.CurrentChapter) + } +} + +func TestCommitChapterAllowsPendingRewrite(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", 10); err != nil { + t.Fatalf("InitProgress: %v", err) + } + if err := store.SetPendingRewrites([]int{2}, "测试重写"); err != nil { + t.Fatalf("SetPendingRewrites: %v", err) + } + if err := store.SetFlow(domain.FlowRewriting); err != nil { + t.Fatalf("SetFlow: %v", err) + } + if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil { + t.Fatalf("SavePolished: %v", err) + } + + tool := NewCommitChapterTool(store) + args, err := json.Marshal(map[string]any{ + "chapter": 2, + "summary": "正确提交", + "characters": []string{"主角"}, + "key_events": []string{"完成重写"}, + "timeline_events": []any{}, + }) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + if _, err := tool.Execute(context.Background(), args); err != nil { + t.Fatalf("Execute: %v", err) + } + + if _, err := os.Stat(dir + "/chapters/02.md"); err != nil { + t.Fatalf("chapter should be persisted: %v", err) + } + + progress, err := store.LoadProgress() + if err != nil { + t.Fatalf("LoadProgress: %v", err) + } + if len(progress.CompletedChapters) != 1 || progress.CompletedChapters[0] != 2 { + t.Fatalf("unexpected completed chapters: %v", progress.CompletedChapters) + } +} diff --git a/tools/context.go b/tools/context.go new file mode 100644 index 0000000..f97ed53 --- /dev/null +++ b/tools/context.go @@ -0,0 +1,164 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/state" +) + +// References 嵌入的参考资料。 +type References struct { + // V0 + ChapterGuide string + HookTechniques string + QualityChecklist string + OutlineTemplate string + CharacterTemplate string + ChapterTemplate string + // V1 + Consistency string + ContentExpansion string + DialogueWriting string + // V2 + StyleReference string // 风格补充参考(可为空) +} + +// ContextTool 组装当前章节所需上下文。 +type ContextTool struct { + store *state.Store + refs References + style string +} + +func NewContextTool(store *state.Store, refs References, style string) *ContextTool { + return &ContextTool{store: store, refs: refs, style: style} +} + +func (t *ContextTool) Name() string { return "novel_context" } +func (t *ContextTool) Description() string { + return "获取小说创作上下文,包括基础设定、状态数据、前情摘要和写作参考资料" +} +func (t *ContextTool) Label() string { return "加载上下文" } + +func (t *ContextTool) Schema() map[string]any { + return schema.Object( + schema.Property("chapter", schema.Int("章节号。不传则返回基础设定和模板(供 Architect 使用)")), + ) +} + +func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Chapter int `json:"chapter"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + result := make(map[string]any) + + // 加载基础设定 + if premise, err := t.store.LoadPremise(); err == nil && premise != "" { + result["premise"] = premise + } + if outline, err := t.store.LoadOutline(); err == nil && outline != nil { + result["outline"] = outline + } + if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { + result["characters"] = chars + } + + if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 { + result["world_rules"] = rules + } + + if a.Chapter > 0 { + // Writer/Editor 模式:加载章节相关上下文 + if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { + result["current_chapter_outline"] = entry + } + if summaries, err := t.store.LoadRecentSummaries(a.Chapter, 2); err == nil && len(summaries) > 0 { + result["recent_summaries"] = summaries + } + // V1: 加载状态数据 + if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { + result["timeline"] = timeline + } + if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 { + result["foreshadow_ledger"] = foreshadow + } + if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 { + result["relationship_state"] = relationships + } + // V2: 加载场景级恢复状态 + if progress, err := t.store.LoadProgress(); err == nil && progress != nil { + checkpoint := map[string]any{ + "in_progress_chapter": progress.InProgressChapter, + "completed_scenes": progress.CompletedScenes, + } + result["checkpoint"] = checkpoint + } + // V2: 加载已有的章节规划(支持场景恢复跳过已完成场景) + if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil { + result["chapter_plan"] = plan + } + // 写作参考资料 + result["references"] = t.writerReferences() + } else { + // Architect 模式:加载模板 + result["references"] = t.architectReferences() + } + + return json.Marshal(result) +} + +func (t *ContextTool) writerReferences() map[string]string { + refs := map[string]string{} + add := func(k, v string) { + if v != "" { + refs[k] = v + } + } + add("chapter_guide", t.refs.ChapterGuide) + add("hook_techniques", t.refs.HookTechniques) + add("quality_checklist", t.refs.QualityChecklist) + add("chapter_template", t.refs.ChapterTemplate) + add("consistency", t.refs.Consistency) + add("content_expansion", t.refs.ContentExpansion) + add("dialogue_writing", t.refs.DialogueWriting) + add("style_reference", t.refs.StyleReference) + return refs +} + +func (t *ContextTool) architectReferences() map[string]string { + refs := map[string]string{} + add := func(k, v string) { + if v != "" { + refs[k] = v + } + } + add("outline_template", t.refs.OutlineTemplate) + add("character_template", t.refs.CharacterTemplate) + return refs +} + +// ContextSummary 返回当前状态的简要摘要(供日志使用)。 +func (t *ContextTool) ContextSummary() string { + var parts []string + if p, _ := t.store.LoadPremise(); p != "" { + parts = append(parts, "premise:ok") + } + if o, _ := t.store.LoadOutline(); o != nil { + parts = append(parts, fmt.Sprintf("outline:%d chapters", len(o))) + } + if c, _ := t.store.LoadCharacters(); c != nil { + parts = append(parts, fmt.Sprintf("characters:%d", len(c))) + } + if len(parts) == 0 { + return "empty" + } + return strings.Join(parts, ", ") +} diff --git a/tools/plan_chapter.go b/tools/plan_chapter.go new file mode 100644 index 0000000..8784a9b --- /dev/null +++ b/tools/plan_chapter.go @@ -0,0 +1,67 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// PlanChapterTool 生成章节规划。 +type PlanChapterTool struct { + store *state.Store +} + +func NewPlanChapterTool(store *state.Store) *PlanChapterTool { + return &PlanChapterTool{store: store} +} + +func (t *PlanChapterTool) Name() string { return "plan_chapter" } +func (t *PlanChapterTool) Description() string { + return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用" +} +func (t *PlanChapterTool) Label() string { return "规划章节" } + +func (t *PlanChapterTool) Schema() map[string]any { + sceneSchema := schema.Object( + schema.Property("index", schema.Int("场景编号,从 1 开始")).Required(), + schema.Property("summary", schema.String("场景概要")).Required(), + schema.Property("pov", schema.String("视角人物")), + schema.Property("location", schema.String("场景地点")), + ) + return schema.Object( + schema.Property("chapter", schema.Int("章节号")).Required(), + schema.Property("title", schema.String("章节标题")).Required(), + schema.Property("goal", schema.String("本章目标")).Required(), + schema.Property("conflict", schema.String("核心冲突")).Required(), + schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(), + schema.Property("hook", schema.String("章末钩子")).Required(), + schema.Property("emotion_arc", schema.String("情绪曲线")), + ) +} + +func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var plan domain.ChapterPlan + if err := json.Unmarshal(args, &plan); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if plan.Chapter <= 0 { + return nil, fmt.Errorf("chapter must be > 0") + } + if len(plan.Scenes) == 0 { + return nil, fmt.Errorf("scenes must not be empty") + } + + if err := t.store.SaveChapterPlan(plan); err != nil { + return nil, fmt.Errorf("save chapter plan: %w", err) + } + + return json.Marshal(map[string]any{ + "planned": true, + "chapter": plan.Chapter, + "scene_count": len(plan.Scenes), + }) +} diff --git a/tools/polish_chapter.go b/tools/polish_chapter.go new file mode 100644 index 0000000..aedadd6 --- /dev/null +++ b/tools/polish_chapter.go @@ -0,0 +1,59 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "unicode/utf8" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/state" +) + +// PolishChapterTool 保存打磨后的章节正文,替换原始场景拼接。 +type PolishChapterTool struct { + store *state.Store +} + +func NewPolishChapterTool(store *state.Store) *PolishChapterTool { + return &PolishChapterTool{store: store} +} + +func (t *PolishChapterTool) Name() string { return "polish_chapter" } +func (t *PolishChapterTool) Description() string { + return "保存打磨后的章节正文。在 write_scene 全部完成后、commit_chapter 之前调用。提交时会优先使用打磨版本" +} +func (t *PolishChapterTool) Label() string { return "打磨章节" } + +func (t *PolishChapterTool) Schema() map[string]any { + return schema.Object( + schema.Property("chapter", schema.Int("章节号")).Required(), + schema.Property("content", schema.String("打磨后的完整章节正文")).Required(), + ) +} + +func (t *PolishChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Chapter int `json:"chapter"` + Content string `json:"content"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if a.Chapter <= 0 { + return nil, fmt.Errorf("chapter must be > 0") + } + if a.Content == "" { + return nil, fmt.Errorf("content must not be empty") + } + + if err := t.store.SavePolished(a.Chapter, a.Content); err != nil { + return nil, fmt.Errorf("save polished: %w", err) + } + + return json.Marshal(map[string]any{ + "polished": true, + "chapter": a.Chapter, + "word_count": utf8.RuneCountInString(a.Content), + }) +} diff --git a/tools/save_foundation.go b/tools/save_foundation.go new file mode 100644 index 0000000..d7ba3ea --- /dev/null +++ b/tools/save_foundation.go @@ -0,0 +1,86 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// SaveFoundationTool 保存基础设定(premise/outline/characters),Architect 专用。 +type SaveFoundationTool struct { + store *state.Store +} + +func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool { + return &SaveFoundationTool{store: store} +} + +func (t *SaveFoundationTool) Name() string { return "save_foundation" } +func (t *SaveFoundationTool) Description() string { + return "保存小说基础设定。type=premise 时 content 为 Markdown;type=outline 时 content 为 JSON 数组;type=characters 时 content 为 JSON 数组;type=world_rules 时 content 为 JSON 数组" +} +func (t *SaveFoundationTool) Label() string { return "保存设定" } + +func (t *SaveFoundationTool) Schema() map[string]any { + return schema.Object( + schema.Property("type", schema.Enum("设定类型", "premise", "outline", "characters", "world_rules")).Required(), + schema.Property("content", schema.String("内容。premise 为 Markdown 文本,outline 和 characters 为 JSON 字符串")).Required(), + ) +} + +func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Type string `json:"type"` + Content string `json:"content"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + + switch a.Type { + case "premise": + if err := t.store.SavePremise(a.Content); err != nil { + return nil, fmt.Errorf("save premise: %w", err) + } + _ = t.store.UpdatePhase(domain.PhasePremise) + return json.Marshal(map[string]any{"saved": true, "type": "premise"}) + + case "outline": + var entries []domain.OutlineEntry + if err := json.Unmarshal([]byte(a.Content), &entries); err != nil { + return nil, fmt.Errorf("parse outline JSON: %w", err) + } + if err := t.store.SaveOutline(entries); err != nil { + return nil, fmt.Errorf("save outline: %w", err) + } + _ = t.store.UpdatePhase(domain.PhaseOutline) + return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)}) + + case "characters": + var chars []domain.Character + if err := json.Unmarshal([]byte(a.Content), &chars); err != nil { + return nil, fmt.Errorf("parse characters JSON: %w", err) + } + if err := t.store.SaveCharacters(chars); err != nil { + return nil, fmt.Errorf("save characters: %w", err) + } + return json.Marshal(map[string]any{"saved": true, "type": "characters", "count": len(chars)}) + + case "world_rules": + var rules []domain.WorldRule + if err := json.Unmarshal([]byte(a.Content), &rules); err != nil { + return nil, fmt.Errorf("parse world_rules JSON: %w", err) + } + if err := t.store.SaveWorldRules(rules); err != nil { + return nil, fmt.Errorf("save world_rules: %w", err) + } + return json.Marshal(map[string]any{"saved": true, "type": "world_rules", "count": len(rules)}) + + default: + return nil, fmt.Errorf("unknown type %q, expected premise/outline/characters/world_rules", a.Type) + } +} diff --git a/tools/save_review.go b/tools/save_review.go new file mode 100644 index 0000000..8e9bc8c --- /dev/null +++ b/tools/save_review.go @@ -0,0 +1,70 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// SaveReviewTool 保存 Editor 的审阅结果。 +type SaveReviewTool struct { + store *state.Store +} + +func NewSaveReviewTool(store *state.Store) *SaveReviewTool { + return &SaveReviewTool{store: store} +} + +func (t *SaveReviewTool) Name() string { return "save_review" } +func (t *SaveReviewTool) Description() string { + return "保存审阅结果。verdict 必须是 accept/polish/rewrite 之一" +} +func (t *SaveReviewTool) Label() string { return "保存审阅" } + +func (t *SaveReviewTool) Schema() map[string]any { + issueSchema := schema.Object( + schema.Property("type", schema.Enum("问题类型", "timeline", "foreshadow", "relationship", "character", "pacing", "logic")).Required(), + schema.Property("severity", schema.Enum("严重程度", "error", "warning")).Required(), + schema.Property("description", schema.String("问题描述")).Required(), + schema.Property("suggestion", schema.String("修改建议")), + ) + return schema.Object( + schema.Property("chapter", schema.Int("审阅的章节号(全局审阅填最新章节号)")).Required(), + schema.Property("scope", schema.Enum("审阅范围", "chapter", "global")).Required(), + schema.Property("issues", schema.Array("发现的问题", issueSchema)).Required(), + schema.Property("verdict", schema.Enum("审阅结论", "accept", "polish", "rewrite")).Required(), + schema.Property("summary", schema.String("审阅总结")).Required(), + schema.Property("affected_chapters", schema.Array("需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填)", schema.Int(""))), + ) +} + +func (t *SaveReviewTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var r domain.ReviewEntry + if err := json.Unmarshal(args, &r); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if r.Chapter <= 0 { + return nil, fmt.Errorf("chapter must be > 0") + } + + if err := t.store.SaveReview(r); err != nil { + return nil, fmt.Errorf("save review: %w", err) + } + + // 写入信号文件供宿主读取 + if err := t.store.SaveLastReview(r); err != nil { + return nil, fmt.Errorf("save review signal: %w", err) + } + + return json.Marshal(map[string]any{ + "saved": true, + "chapter": r.Chapter, + "scope": r.Scope, + "verdict": r.Verdict, + "issues": len(r.Issues), + }) +} diff --git a/tools/write_scene.go b/tools/write_scene.go new file mode 100644 index 0000000..03aac11 --- /dev/null +++ b/tools/write_scene.go @@ -0,0 +1,76 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "unicode/utf8" + + "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" +) + +// WriteSceneTool 写入单个场景草稿。 +type WriteSceneTool struct { + store *state.Store +} + +func NewWriteSceneTool(store *state.Store) *WriteSceneTool { + return &WriteSceneTool{store: store} +} + +func (t *WriteSceneTool) Name() string { return "write_scene" } +func (t *WriteSceneTool) Description() string { + return "写入单个场景草稿。严格按场景级写作,每次只写一个场景。必须先调用 plan_chapter" +} +func (t *WriteSceneTool) Label() string { return "写入场景" } + +func (t *WriteSceneTool) Schema() map[string]any { + return schema.Object( + schema.Property("chapter", schema.Int("章节号")).Required(), + schema.Property("scene", schema.Int("场景编号,从 1 开始")).Required(), + schema.Property("content", schema.String("场景正文")).Required(), + ) +} + +func (t *WriteSceneTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a struct { + Chapter int `json:"chapter"` + Scene int `json:"scene"` + Content string `json:"content"` + } + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if a.Chapter <= 0 || a.Scene <= 0 { + return nil, fmt.Errorf("chapter and scene must be > 0") + } + if a.Content == "" { + return nil, fmt.Errorf("content must not be empty") + } + + wordCount := utf8.RuneCountInString(a.Content) + draft := domain.SceneDraft{ + Chapter: a.Chapter, + Scene: a.Scene, + Content: a.Content, + WordCount: wordCount, + } + + if err := t.store.SaveSceneDraft(draft); err != nil { + return nil, fmt.Errorf("save scene draft: %w", err) + } + + // 场景级 checkpoint + if err := t.store.MarkSceneComplete(a.Chapter, a.Scene); err != nil { + return nil, fmt.Errorf("mark scene complete: %w", err) + } + + return json.Marshal(map[string]any{ + "written": true, + "chapter": a.Chapter, + "scene": a.Scene, + "word_count": wordCount, + }) +}