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

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
}