This commit is contained in:
voocel
2026-03-07 21:25:55 +08:00
commit 27bd85ef90
60 changed files with 5658 additions and 0 deletions

39
.gitignore vendored Normal file
View File

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

84
app/agents.go Normal file
View File

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

56
app/config.go Normal file
View File

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

351
app/run.go Normal file
View File

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

72
app/run_test.go Normal file
View File

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

32
domain/chapter.go Normal file
View File

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

51
domain/review.go Normal file
View File

@@ -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"` // 需要重写/打磨的章节号
}

73
domain/runtime.go Normal file
View File

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

32
domain/story.go Normal file
View File

@@ -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"` // 不可违反的边界
}

48
domain/writing.go Normal file
View File

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

7
go.mod Normal file
View File

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

4
go.sum Normal file
View File

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

124
main.go Normal file
View File

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

106
prompts/architect.md Normal file
View File

@@ -0,0 +1,106 @@
你是小说世界构建师。你负责从用户需求出发,构建小说的基础设定。
## 你的工具
- **novel_context**: 获取参考模板和当前状态
- **save_foundation**: 保存基础设定
## 工作流程
### 1. 获取模板
先调用 novel_context不传 chapter 参数)获取大纲模板和角色模板。
### 2. 生成 Premise
基于用户需求撰写故事前提Markdown 格式),包含:
- 题材和基调
- 核心冲突
- 主角目标
- 结局方向
- 写作禁区(不应出现的内容)
调用 save_foundation(type="premise", content=<Markdown文本>)
### 3. 生成 Outline
基于 premise 生成章节大纲JSON 格式),每章包含:
- chapter: 章节号
- title: 章节标题
- core_event: 核心事件
- hook: 章末钩子
- scenes: 场景概述列表3-5 个场景)
调用 save_foundation(type="outline", content=<JSON数组字符串>)
示例:
```json
[
{
"chapter": 1,
"title": "暗夜来客",
"core_event": "主角在暴雨夜收到神秘包裹",
"hook": "包裹里是一张二十年前失踪案的照片",
"scenes": ["雨夜独处", "快递到来", "打开包裹", "照片特写"]
}
]
```
### 4. 生成 Characters
基于 premise 和 outline 生成角色档案JSON 格式),每个角色包含:
- name: 姓名
- role: 角色定位(主角/配角/反派)
- description: 外貌与性格描写
- arc: 角色弧线从A到B的变化
- traits: 标签特征列表
调用 save_foundation(type="characters", content=<JSON数组字符串>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category: 规则类别magic / technology / geography / society / other
- rule: 规则描述
- boundary: 不可违反的边界
调用 save_foundation(type="world_rules", content=<JSON数组字符串>)
示例:
```json
[
{
"category": "magic",
"rule": "法术需要消耗精神力,精神力与修炼等级成正比",
"boundary": "不存在无消耗的法术,精神力耗尽会导致昏迷"
},
{
"category": "society",
"rule": "王国实行严格的等级制度,平民不得直视贵族",
"boundary": "没有例外,违反者会被当场处刑"
}
]
```
注意:不是所有小说都需要复杂的世界规则。现实题材可以只记录少量社会规则或物理限制。
## 增量修改模式
当任务中提到"增量修改"或"在现有设定基础上修改"时:
1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules
2. 仅修改受影响的部分,保持未受影响部分不变
3. 特别注意:已完成章节的设定不应产生矛盾
4. 修改 outline 时,已完成章节的大纲条目保持不变(除非明确要求重写)
5. 修改 characters 时,保持角色已展示的特征不变,只调整后续发展
6. 修改 world_rules 时,不得删除已在正文中体现的规则,只能新增或放宽边界
所有被修改的设定都必须用 save_foundation 保存完整版本(全量覆盖),包括 world_rules。
未修改的设定无需重新保存。
## 注意事项
- 大纲的场景拆分要具体,不要笼统
- 每章至少 3 个场景
- 角色弧线要有变化,不要扁平
- 钩子要制造悬念,吸引读者继续阅读

98
prompts/coordinator.md Normal file
View File

@@ -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 章。审阅意见:<summary>"}
```
- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写。每次调用:
```json
{"agent": "writer", "task": "重写第 N 章。重写原因:<summary>"}
```
重写完成后回到正常写作流程,继续写下一个未完成章节
**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。中断退出后重启会自动恢复到重写状态。
### 系统消息
宿主程序会在关键节点注入 `[系统]` 消息:
- **全书完成**:收到 `[系统] 全部 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 的流程与新写相同,旧文件会自动覆盖

49
prompts/editor.md Normal file
View File

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

81
prompts/writer.md Normal file
View File

@@ -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
## 注意事项
- 严格场景级写作,一次只写一个场景
- 不要整章一起写然后拆分
- 章末必须有悬念钩子
- 保持与前几章的连贯性
- 字数不够时用具体细节扩展,不用水话填充
- 注意时间线连贯和伏笔管理

261
references/chapter-guide.md Normal file
View File

@@ -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. 开头是否避免了平淡的日常/天气/背景说明?

View File

@@ -0,0 +1,19 @@
# 第[X]章:[章节标题]
## 本章概要
- **核心事件**[一句话概括本章发生的事]
- **承接上章**[回应上一章的悬念]
- **悬念钩子**[本章结尾的钩子]
---
## 正文
[章节正文内容 3000-5000 字,最低不低于 2500 字]
---
## 章节备注
- 本章悬念:[简述结尾钩子]
- 下章预告:[可选1-2句话]
- 伏笔标记:[如果埋下伏笔,在此记录]

View File

@@ -0,0 +1,220 @@
# 人物塑造原则
好的人物是故事的灵魂。读者记住的是人,不是情节。
## 人物档案模板
每个主要角色都应建立完整档案:
### 基本信息
```text
姓名:(有意义的名字更好)
年龄:
职业:
外貌特征2-3个显著特征避免泛泛而谈
```
### 性格核心
```text
核心价值观:(他最相信什么)
最大恐惧:(他最害怕什么)
致命缺陷:(什么会导致他失败)
内心渴望:(他真正想要什么)
```
### 背景故事
```text
成长环境:
创伤经历:(过去伤害他的事)
关键记忆:(塑造他现在的关键事件)
秘密:(别人不知道的事)
```
### 行为模式
```text
说话方式:(口头禅、语速、用词习惯)
肢体语言:(习惯动作、紧张时的小动作)
社交风格:(内向/外向、如何对待陌生人)
压力反应:(压力下如何表现)
```
---
## 人物类型塑造
### 主角Protagonist
**必须有:**
- **明确目标** - 他想要什么
- **强大动机** - 为什么想要
- **可共情性** - 读者能理解他的感受
- **成长空间** - 故事中会改变
**主角原型:**
| 类型 | 特征 | 故事作用 |
|-----|------|---------|
| 英雄型 | 勇敢、正义、利他 | 战胜外在威胁 |
| 成长型 | 从弱小到强大 | 克服内在缺陷 |
| 反英雄型 | 道德灰色、复杂 | 挑战传统道德 |
| 平凡型 | 普通人卷入非凡事 | 读者代入感强 |
### 反派Antagonist
**好反派的特点:**
- **强大可信** - 不应该是草台班子
- **有自己的逻辑** - 他相信自己在做正确的事
- **与主角有深层联系** - 不是单纯为了作恶
- **揭示主题** - 挑战主角的信念
**反派动机类型:**
- 理想主义扭曲("为了大局必须牺牲"
- 过去创伤("世界伤害了我,我要报复"
- 权力渴望("我配得上更多"
- 与主角相同目标(不同方法)
### 配角Supporting Characters
**配角功能:**
- **导师型** - 指引主角,传递信息
- **盟友型** - 协助主角,提供情感支持
- **搞笑型** - 缓解紧张,提供喜剧元素
- **爱情型** - 制造浪漫线索,增加个人利害
- **叛徒型** - 制造背叛和转折
**配角原则:**
- 每个配角必须有明确作用
- 删除"只是存在"的角色
- 避免刻板印象(除非是有意为之)
---
## 人物深度塑造技巧
### 1. 矛盾性
真实的人是复杂的,充满矛盾。
**示例:**
- 暴力的黑帮成员但爱护流浪猫
- 无神论的牧师
- 害怕黑暗的侦探
- 重视友情但总是背叛朋友
### 2. 侧面揭示
不要直接陈述性格,通过行为展示。
| 错误(直接陈述) | 正确(侧面展示) |
|----------------|----------------|
| 他很愤怒 | 他捏碎手中的纸杯 |
| 她很紧张 | 她反复调整眼镜位置 |
| 他很傲慢 | 他从不直视下属的眼睛 |
| 她很善良 | 她偷偷喂流浪狗三年 |
### 3. 声音独特性
每个人说话方式不同,对话中能分辨角色。
**区分要素:**
- 用词选择(正式/俚语/方言)
- 句子长度
- 是否打断别人
- 是否喜欢隐喻
- 情绪表达方式
### 4. 动机合理化
每个角色行为必须有合理动机,即使动机扭曲。
**反派动机合理化示例:**
- "我想毁灭世界" → 乏味
- "我失去了一切,世界对我没有意义" → 可理解但扭曲
- "人类是地球的病毒,我必须清除" → 有哲学支撑
### 5. 缺陷致命化
主角必须有缺陷,缺陷在关键时刻导致失败。
**经典缺陷模式:**
| 缺陷 | 导致的失败 |
|-----|----------|
| 傲慢 | 低估对手,落入陷阱 |
| 信任问题 | 拒绝帮助,孤立无援 |
| 完美主义 | 无法及时行动,错失机会 |
| 复仇心 | 被利用,失去理智 |
---
## 人物关系设计
### 关系类型
| 关系 | 戏剧潜力 | 应用 |
|-----|---------|-----|
| 亦敌亦友 | 高 | 悬疑、动作 |
| 禁忌之爱 | 高 | 言情、悲剧 |
| 师徒关系 | 中 | 成长故事 |
| 兄弟竞争 | 中 | 家庭剧 |
| 陌生人联盟 | 中 | 冒险、悬疑 |
### 关系动态变化
**好的关系会随故事发展:**
```text
第一章:陌生人
第三章:不情愿的盟友
第五章:建立信任
第七章:背叛/考验
终章:真正的友谊(或决裂)
```
### 关系揭示
**逐步揭示关系深度:**
- 表层:表面互动
- 中层:共同经历
- 深层:真实感受/秘密
---
## 人物一致性检查
角色行为必须符合已建立的性格。
**检查问题:**
- 这件事符合他的核心价值观吗?
- 以他的背景,会有这样的反应吗?
- 他的恐惧会如何影响这个决定?
- 他的缺陷会导致他犯什么错?
**例外处理:**
- 如果角色"不符合性格"行事,必须有原因
- 解释应该在相同/下一章提供
- 可以是成长的标志(角色克服缺陷)
---
## 人物出场设计
### 首次出场原则
**有效的出场方式:**
- **行动中** - 展示能力或性格
- **冲突中** - 立即建立关系/对立
- **误解中** - 建立悬念
**避免:**
- 镜子自照描写外貌
- 姓名+年龄+职业的简历式介绍
- 无意义的日常活动
### 出场示例对比
| 无效出场 | 有效出场 |
|---------|---------|
| 李明28岁是一名侦探。他走进办公室。| 李明跨过警戒线,警官试图拦住他。"市刑警队,李明。"他亮出证件,径直走向尸体。|
| 美丽的女孩坐在窗边,她叫小红。| 她已经三天没睡了,咖啡杯里的液体在颤抖。当门铃响起时,她几乎把杯子摔在地上。|

View File

@@ -0,0 +1,30 @@
# 人物档案
## 主角
### [角色一姓名]
- **年龄/职业**
- **外貌特征**
- **性格核心**
- **核心价值观**
- **最大恐惧**
- **致命缺陷**
- **内心渴望**
- **背景故事**
- **MBTI:**
### [角色二姓名]
......
## 反派
### [角色姓名]
- [同主角格式]
## 配角
### [角色姓名]
- [简化格式]

40
references/consistency.md Normal file
View File

@@ -0,0 +1,40 @@
# 连贯性保证机制
为确保长时间创作的故事连贯性:
## 写前必读
每次开始写新章节前:
1. 阅读 `00-大纲.md` 中所有已完成章节的摘要
2. 读取上一章文件,了解当前悬念
3. 检查人物状态(位置、情绪、关系)
## 穿针引线
在新章节中:
- 呼应前文埋下的伏笔和线索
- 提及之前发生的事件(自然融入)
- 让人物行为与之前保持一致
## 人物状态跟踪
注意人物在各章节中的变化和成长:
- 位置变化(人在哪里)
- 情绪状态(当前心情)
- 关系变化(与其他角色关系)
- 能力变化(获得新技能/信息)
## 悬念线延续
确保主线悬念逐步推进:
- 每章至少回应一个旧悬念
- 提出新悬念或升级现有悬念
- 不要遗忘任何未解的悬念
## 一致性检查清单
- [ ] 人物行为符合其性格设定
- [ ] 前后伏笔有呼应,逻辑闭环
- [ ] 高潮低谷分布合理,节奏恰当
- [ ] 时间线连贯(没有时间跳跃错误)
- [ ] 场景转换自然(没有凭空出现)

View File

@@ -0,0 +1,66 @@
# 内容扩充技巧
当章节内容不足时,使用以下技巧自然扩充。
## 1. 场景细节描写
不要只说"他走进房间",描写:
- 房间的布局、光线、气味
- 物品的细节和质感
- 环境对人物的影响
- 人物在空间中的移动
## 2. 人物内心活动
展示而非讲述内心世界:
- 角色的犹豫和纠结
- 过去记忆的闪回1-2段
- 对未来的担忧和期待
- 道德选择的内心辩论
## 3. 对话扩展
不要只推进剧情,让对话:
- 展现人物性格和说话方式
- 包含潜台词和暗示
- 有来回交锋和试探
- 偶尔跑题再拉回(更真实)
## 4. 感官体验
调动五感描写:
- 视觉:颜色、光影、形状
- 听觉:声音、音乐、沉默
- 触觉:温度、质感、疼痛
- 嗅觉:气味、香味、腐臭
- 味觉:食物、饮料、血腥味
## 5. 次要情节线
在主剧情中穿插:
- 配角的小故事
- 暗线的发展
- 伏笔的埋设
- 人物关系的微妙变化
## 6. 节奏放慢
关键时刻慢下来描写:
- 动作场景的分解
- 情感转变的过程
- 发现真相的时刻
- 紧张对峙的延展
## 7. 环境烘托
用环境反映情绪:
- 天气和氛围
- 社会环境背景
- 文化习俗细节
- 时代特征展现
## 扩充原则
- **自然融入** - 扩充内容要服务于故事,不要注水
- **保持张力** - 即使扩充场景也不能失去冲突
- **推进主线** - 所有扩充最终都要指向核心剧情

View File

@@ -0,0 +1,316 @@
# 对话写作规范
好对话是揭示人物、推动情节、制造冲突的有力工具。
## 对话核心原则
### 1. 对话必须有目的
每句对话应该至少完成以下之一:
| 目的 | 示例 |
|-----|------|
| **推动情节** | "我找到凶器了,在河边的草丛里。" |
| **揭示人物** | "我不信任警察,他们从来不帮我这样的人。" |
| **制造冲突** | "你骗了我。你从头到尾都在骗我。" |
| **传达信息** | "炸弹将在三点引爆。" |
| **表达情感** | "我...我不知道该说什么。" |
| **制造悬念** | "你知道那天晚上真正发生了什么吗?" |
**无效对话:**
> "你好。"
> "你好。"
> "吃了吗?"
> "吃了。"
> "哦,那就好。"
### 2. 对话应该简洁
人们说话不写论文。删除多余的词。
| 啰嗦 | 简洁 |
|-----|------|
| "我想告诉你的是,我认为我们应该立刻离开这里。" | "我们得马上走。" |
| "我非常抱歉,但我真的不知道你刚才说的那件事的答案。" | "我不知道。" |
| "如果你不介意的话,我能不能请你帮我把那个东西递给我?" | "递给我那个。" |
### 3. 真实的人不会完整表达
真实对话充满:
- 打断
- 迟疑
- 话题转移
- 话没说完
- 暗示而非明说
**示例:**
> "我本来想告诉你,但是——"
> "但是什么?"
> "算了,没什么。"
> "不,你说。"
> "真的没什么。"
---
## 对话格式规范
### 中文对话标点
**基础格式:**
```
"说话内容," 他说。
"说话内容?" 她问。
"说话内容!" 他大喊。
```
**多行对话:**
```
"第一句话,"他说,"第二句话。"
"第一句话。
第二句话,"他说,"第三句话。"
```
**对话动作:**
```
"说话内容。" 他做了动作。
他做了动作。"说话内容。"
```
### 对话标签使用
**规则:**
- 能辨识说话人时,省略标签
- 使用"说""问"等中性标签
- 避免过度使用副词修饰
| 过度使用 | 改进后 |
|---------|--------|
| "你骗了我,"他愤怒地说。| "你骗了我。"他的声音在颤抖。 |
| "好的,"她高兴地同意道。| 她眼睛亮了。"好的。" |
| "我不知道,"他悲伤地回答。| 他低下头。"我不知道。" |
**标签位置:**
- 对话前:[标签]"对话。"
- 对话后:"对话。"[标签]
- 对话中断:"对话,"[标签]"对话。"
### 段落划分
**规则:** 每个说话人的对话开始新段落。
```
正确:
"第一句,"甲说。
"第二句,"乙回答。
"第三句。"甲点头。
错误:
"第一句,"甲说。"第二句,"乙回答。"第三句。"甲点头。
```
---
## 对话声音区分
每个角色说话方式应该不同。
### 区分维度
| 维度 | 示例 |
|-----|------|
| **用词** | 正式/俚语/方言/专业术语 |
| **句式** | 长句/短句/破碎句 |
| **停顿** | 流畅/迟疑/频繁打断 |
| **语气** | 温和/激烈/冷嘲热讽/平淡 |
| **习惯语** | 特定口头禅或用词习惯 |
### 角色声音示例
**教授型角色:**
> "从理论角度分析,这个假设存在三个主要缺陷。首先,数据样本不足;其次,实验条件未受控制;最后,结论过于激进。"
**街头混混型角色:**
> "扯淡。那帮人就是在放屁,想蒙咱们呢。我告诉你,这事儿没那么简单。"
**害羞内向型角色:**
> "我...我是说,如果...如果你不介意的话...那个..."
**傲慢自大型角色:**
> "让我来告诉你什么叫专业。你们这些业余人士根本不懂。"
---
## 潜台词Subtext
好的对话,真正含义在表面之下。
### 直接 vs 潜台词
| 直接(乏味) | 潜台词(有趣) |
|-------------|---------------|
| "我很生气。" | "没事。我挺好的。真的。" |
| "我喜欢你。" | "你今天看起来...不错。" |
| "我不信任你。" | "谢谢你告诉我。我会记住的。" |
| "我想离开。" | "这个地方空气不太好。" |
### 潜台词技巧
**1. 话题转移**
```
"你爱我吗?"
"你看了天气预报吗?明天有雨。"
```
**2. 反问而非回答**
```
"你杀了他吗?"
"你觉得像我这样的人会做那种事?"
```
**3. 谈论其他事物**
```
"你想我吗?"
"我妈昨天打电话来了。"
```
**4. 沉默和动作**
```
"你愿意原谅我吗?"
她继续看杂志,翻了一页。
```
---
## 对话与动作结合
对话与肢体语言配合,增强表现力。
### 同步原则
动作与对话一致或矛盾,都有戏剧效果。
**一致(增强):**
> "我爱你。"她紧紧抱住他,眼泪流下来。
**矛盾(揭示真相):**
> "我完全支持你。"他目光看向别处,手在口袋里握紧拳头。
### 动作打断
动作插入可以控制节奏。
```
"我本来想告诉你,"他停下脚步,转过身,"但我想你已经知道了。"
```
### 动作替代标签
用动作替代"他说"。
```
"你在撒谎。"她拍案而起。
"坐下。"他头也不抬。
```
---
## 对话场景类型
### 争吵场景
**特征:**
- 短句
- 打断
- 重复强调
- 情绪升级
**示例:**
> "你答应过的!"
> "情况变了!"
> "那是你的借口!"
> "你根本不懂!"
> "我当然不懂!你什么都不告诉我!"
### 告白场景
**特征:**
- 迟疑
- 停顿
- 寻找词语
- 真诚或尴尬
**示例:**
> "我...我想说...这些年,我一直在想...如果我们..."
> 她低下头,声音变小。
> "如果我们什么?"
> "如果我们早一点相遇。"
### 审讯场景
**特征:**
- 提问控制
- 信息不对称
- 压力建立
- 操纵对话
**示例:**
> "那天晚上你在哪里?"
> "在家。"
> "有人能证明吗?"
> "...没有。"
> "你是一个人?"
> "是的。"
> "整个晚上?"
### 调情场景
**特征:**
- 双关语
- 试探
- 身体接近
- 暗示
**示例:**
> "你今天很漂亮。"
> "只是今天?"
> "嗯...今天特别漂亮。"
> "那我明天该担心了?"
> "明天...明天再看看。"
---
## 对话常见问题
### 避免
1. **信息倾倒** - 角色互相说已知信息
> 错误:"正如你所知我们的公司成立于1995年..."
> 正确:通过情节自然揭示信息
2. **所有人说话一样** - 无法区分角色
> 解决:给每个角色独特的说话方式
3. **过度礼貌** - 真实对话更粗糙
> 错误:"我很抱歉打扰你,能否请你..."
> 正确:"喂。帮我个忙。"
4. **无意义的闲聊** - 除非有特殊目的
> 删除天气、吃饭等无关对话,除非揭示人物/推动情节
5. **说教** - 角色发表长篇哲学论述
> 改为通过冲突和行动展示观点
---
## 对话练习自查
写完对话后检查:
- [ ] 每句对话是否有目的?
- [ ] 删除后情节是否受影响?
- [ ] 能否辨识说话人(不看标签)?
- [ ] 是否有潜台词?
- [ ] 节奏是否合适(快/慢)?
- [ ] 是否符合人物性格?
- [ ] 标签使用是否正确?

View File

@@ -0,0 +1,25 @@
# 奇幻风格补充参考
## 世界构建
- 魔法体系必须有明确代价,不能出现"无限能量"
- 种族特征要在行动中自然展示,禁止百科式灌输
- 新设定首次出现时通过角色互动引出,不用旁白解释
## 常见陷阱
- 龙傲天:主角能力攀升要有合理代价和挫折
- 设定膨胀:每章最多引入一个新设定概念
- 语言穿越:避免现代网络用语出现在古典背景中
## 战斗与能力描写
- 战斗重点是策略和代价,不是招式名称堆砌
- 能力上限在 world_rules 中已约定,严禁突破
- 受伤要有持续影响,不能"下一场景就好了"
## 氛围营造
- 通过五感描写建立异世界沉浸感
- 日常生活细节(货币、食物、交通)体现世界观
- 避免所有角色都说现代普通话,适当使用世界观内的表达方式

View File

@@ -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 | 终极悬念 | "一切到底是什么意思" | 全书结尾 |
**递进建议:** 故事中悬念强度应总体上升,但可以波动

View File

@@ -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字概括本章核心内容、重要情节、人物变化、悬念揭示等]
---
(后续章节摘要依次追加)

View File

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

View File

@@ -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 优秀** |

View File

@@ -0,0 +1,25 @@
# 言情风格补充参考
## 情感递进
- 感情发展遵循"排斥→好奇→动摇→确认→考验→稳固"曲线
- 每章推进一个情感阶段,禁止跳跃式发展
- 心动瞬间要通过具体细节呈现,不用"心跳加速"等抽象描写
## 关系张力
- 核心CP之间必须有持续的阻力源性格、身份、误会、外部
- 阻力要合理且难以轻易解决,不能为虐而虐
- 配角感情线不能抢主线篇幅,每章配角互动不超过 20%
## 对话质量
- 暧昧期对话要有潜台词,角色说的和想的应该不一样
- 吵架/冲突场景双方都要有道理,不能一方全错
- 甜蜜场景要有克制,过度撒糖会降低读者感受
## 常见陷阱
- 降智推剧情:不让角色为了制造误会而突然变笨
- 工具人配角:每个配角都应有自己的动机
- 人设崩塌:感情中的行为必须符合角色已建立的性格特征

View File

@@ -0,0 +1,25 @@
# 悬疑风格补充参考
## 线索布局
- 每章至少埋设一条线索(实线索或红鲱鱼)
- 关键线索必须在揭晓前至少出现两次
- 线索首次出现要自然融入场景,不能刻意强调
## 误导技法
- 红鲱鱼要有独立的合理性,不能事后看完全无意义
- 叙述性诡计需要严格遵守"不说谎但可以省略"原则
- 嫌疑人转移要有动机支撑,不能为了误导而误导
## 节奏控制
- 紧张→舒缓→更紧张的波浪式节奏
- 信息释放要节制,每次只给读者"刚好不够"的信息量
- 章末必须有悬念钩子,禁止"平静收尾"
## 逻辑严密性
- 时间线必须可回溯验证
- 不在场证明、动机、手法三要素缺一不可
- 揭晓时读者应该能用已知线索自行推导出结论

View File

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

45
state/characters.go Normal file
View File

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

114
state/drafts.go Normal file
View File

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

76
state/foreshadow.go Normal file
View File

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

77
state/outline.go Normal file
View File

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

233
state/progress.go Normal file
View File

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

116
state/progress_test.go Normal file
View File

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

70
state/relationships.go Normal file
View File

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

66
state/reviews.go Normal file
View File

@@ -0,0 +1,66 @@
package state
import (
"fmt"
"os"
"github.com/voocel/ainovel-cli/domain"
)
// SaveReview 保存审阅结果。scope=chapter 写 reviews/{ch}.jsonscope=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
}

91
state/run_meta.go Normal file
View File

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

183
state/run_meta_test.go Normal file
View File

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

76
state/store.go Normal file
View File

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

41
state/summaries.go Normal file
View File

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

51
state/timeline.go Normal file
View File

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

58
state/world_rules.go Normal file
View File

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

140
state/world_rules_test.go Normal file
View File

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

7
styles/default.md Normal file
View File

@@ -0,0 +1,7 @@
## 通用写作风格
- 叙事节奏:张弛有度,关键转折放慢,过渡紧凑
- 描写方式:五感具象描写优先于抽象概述
- 对话要求:体现人物性格差异,自然流畅,避免说教
- 情感表达:通过动作和细节传递,不直接点明情绪
- 文字风格:简洁有力,避免过度修饰

10
styles/fantasy.md Normal file
View File

@@ -0,0 +1,10 @@
## 奇幻冒险风格
- **世界观展开**:不集中灌输设定,通过角色互动和行动自然展示世界规则
- **魔法/能力体系**:有明确代价和限制,避免万能型能力,冲突中展示体系边界
- **史诗感营造**:宏大叙事与个人命运交织,小人物视角折射大格局
- **种族与文化**:不同种族/文化有独特语言习惯、价值观和行为模式
- **战斗场景**:注重策略和代价,避免单纯的力量碾压,利用环境和智谋取胜
- **旅途叙事**:每个新场景都有独特的视觉/感官特征,避免"又一个村庄"
- **成长弧线**:主角的能力成长与心智成长同步,代价与收获并存
- **命名体系**:保持风格统一,避免突兀的现代词汇打破沉浸感

10
styles/romance.md Normal file
View File

@@ -0,0 +1,10 @@
## 言情风格
- **情感递进**:遵循接触→好感→冲突→和解→深入的自然节奏,不急于推进
- **关系张力**:每个阶段都需要合理的阻碍,阻碍来源要多样(性格、立场、误解、外部压力)
- **内心描写**:深入角色内心,展现矛盾和挣扎,但避免大段独白式心理分析
- **互动细节**:用微表情、小动作、不经意的习惯传递情感,比直接告白更有张力
- **对话节奏**:暧昧期对话留白多,甜蜜期对话轻松自然,冲突期对话尖锐但克制
- **场景氛围**:环境描写与情感状态呼应,但不过度使用"下雨=悲伤"类刻板隐喻
- **配角功能**:闺蜜/兄弟角色推动情节发展,不沦为恋爱咨询工具
- **冲突设计**:误会不能靠"一句话就能解释清楚"维持,矛盾要触及核心价值观差异

10
styles/suspense.md Normal file
View File

@@ -0,0 +1,10 @@
## 悬疑推理风格
- **叙事结构**:多线叙事交织,信息差制造悬念,逐步揭示真相
- **误导技法**合理设置红鲱鱼红herring利用叙述视角盲区误导读者
- **线索管理**:关键线索必须在揭示前至少出现两次,但不能太明显
- **节奏控制**:紧张-舒缓交替,每章末留悬念钩子,高潮前适当减速蓄力
- **氛围营造**:环境描写服务于紧张感,利用光影、声音、天气渲染不安
- **人物行为**:角色的每个决策必须有动机支撑,避免"为了推动剧情而做蠢事"
- **对话风格**:言外之意多于字面意思,审讯/对峙场景注重攻防节奏
- **真相揭示**:不能靠巧合或未出现的证据,读者回看时能发现伏笔

125
tools/check_consistency.go Normal file
View File

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

168
tools/commit_chapter.go Normal file
View File

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

View File

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

164
tools/context.go Normal file
View File

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

67
tools/plan_chapter.go Normal file
View File

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

59
tools/polish_chapter.go Normal file
View File

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

86
tools/save_foundation.go Normal file
View File

@@ -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/charactersArchitect 专用。
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 为 Markdowntype=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)
}
}

70
tools/save_review.go Normal file
View File

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

76
tools/write_scene.go Normal file
View File

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