refactor: Agent驱动重构,整章写入替代场景拼接

This commit is contained in:
voocel
2026-03-15 14:14:46 +08:00
parent 25e219e934
commit 568ef0b1d1
27 changed files with 942 additions and 568 deletions

View File

@@ -3,21 +3,19 @@ package state
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"regexp"
"strings"
"unicode/utf8"
"github.com/voocel/ainovel-cli/domain"
)
// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。
// 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 读取章节规划
// 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 {
@@ -29,47 +27,30 @@ func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
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)
// SaveDraft 保存整章草稿到 drafts/{ch}.draft.md。
func (s *Store) SaveDraft(chapter int, content string) error {
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.draft.md", chapter), 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
// AppendDraft 追加内容到现有草稿(续写模式)
func (s *Store) AppendDraft(chapter int, content string) error {
rel := fmt.Sprintf("drafts/%02d.draft.md", chapter)
existing, err := s.readFile(rel)
if err != nil && !os.IsNotExist(err) {
return 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)),
})
var merged string
if len(existing) > 0 {
merged = string(existing) + "\n\n" + content
} else {
merged = content
}
return drafts, nil
return s.writeMarkdown(rel, merged)
}
// 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))
// LoadDraft 读取整章草稿
func (s *Store) LoadDraft(chapter int) (string, error) {
data, err := s.readFile(fmt.Sprintf("drafts/%02d.draft.md", chapter))
if os.IsNotExist(err) {
return "", nil
}
@@ -79,21 +60,16 @@ func (s *Store) LoadPolished(chapter int) (string, error) {
return string(data), nil
}
// LoadChapterContent 加载章节正文:优先 polished否则 merge scenes
// LoadChapterContent 加载章节草稿正文及字数
func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
polished, err := s.LoadPolished(chapter)
draft, err := s.LoadDraft(chapter)
if err != nil {
return "", 0, err
}
if polished != "" {
return polished, utf8.RuneCountInString(polished), nil
if draft != "" {
return draft, utf8.RuneCountInString(draft), nil
}
drafts, err := s.LoadSceneDrafts(chapter)
if err != nil {
return "", 0, err
}
content, wc := domain.MergeScenes(drafts)
return content, wc, nil
return "", 0, nil
}
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。
@@ -101,14 +77,120 @@ 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
// LoadChapterText 读取已提交的终稿原文
func (s *Store) LoadChapterText(chapter int) (string, error) {
data, err := s.readFile(fmt.Sprintf("chapters/%02d.md", chapter))
if os.IsNotExist(err) {
return "", nil
}
numStr := strings.TrimSuffix(parts[1], ".md")
n, _ := strconv.Atoi(numStr)
return n
if err != nil {
return "", err
}
return string(data), nil
}
// LoadChapterRange 读取指定范围的终稿原文片段(每章截取前 maxRunes 个字符)。
func (s *Store) LoadChapterRange(from, to, maxRunes int) (map[int]string, error) {
result := make(map[int]string)
for ch := from; ch <= to; ch++ {
text, err := s.LoadChapterText(ch)
if err != nil {
return nil, err
}
if text == "" {
continue
}
if maxRunes > 0 {
runes := []rune(text)
if len(runes) > maxRunes {
text = string(runes[:maxRunes]) + "..."
}
}
result[ch] = text
}
return result, nil
}
// dialogueRe 匹配中文引号对话。
var dialogueRe = regexp.MustCompile(`"[^"]*"`)
// ExtractDialogue 从已提交章节中提取指定角色的对话片段。
// 通过检查对话所在段落是否包含角色名/别名来关联。
func (s *Store) ExtractDialogue(characterName string, aliases []string, maxSamples int) []string {
if maxSamples <= 0 {
maxSamples = 5
}
names := append([]string{characterName}, aliases...)
var samples []string
// 从最近的章节开始向前搜索
for ch := 99; ch >= 1 && len(samples) < maxSamples; ch-- {
text, err := s.LoadChapterText(ch)
if err != nil || text == "" {
continue
}
paragraphs := strings.Split(text, "\n")
for _, para := range paragraphs {
if len(samples) >= maxSamples {
break
}
// 段落中要包含角色名
found := false
for _, name := range names {
if strings.Contains(para, name) {
found = true
break
}
}
if !found {
continue
}
// 提取该段落中的对话
matches := dialogueRe.FindAllString(para, -1)
for _, m := range matches {
if len(samples) >= maxSamples {
break
}
if utf8.RuneCountInString(m) > 5 { // 过滤太短的
samples = append(samples, characterName+": "+m)
}
}
}
}
return samples
}
// ExtractStyleAnchors 从已提交章节中提取代表性段落作为风格锚点。
// 选取描写密度高(非对话、非短句)的段落。
func (s *Store) ExtractStyleAnchors(maxAnchors int) []string {
if maxAnchors <= 0 {
maxAnchors = 5
}
var anchors []string
// 从第 1 章开始,均匀采样
for ch := 1; ch <= 99 && len(anchors) < maxAnchors; ch++ {
text, err := s.LoadChapterText(ch)
if err != nil || text == "" {
continue
}
paragraphs := strings.Split(text, "\n\n")
for _, para := range paragraphs {
if len(anchors) >= maxAnchors {
break
}
para = strings.TrimSpace(para)
runeCount := utf8.RuneCountInString(para)
// 选取 50-300 字的非对话段落
if runeCount < 50 || runeCount > 300 {
continue
}
// 跳过纯对话段落
if strings.Count(para, "\u201c") > 2 {
continue
}
anchors = append(anchors, para)
}
}
return anchors
}

View File

@@ -157,30 +157,7 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
return &r, nil
}
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint
// 切换到不同章节时自动清空旧的 CompletedScenes。
func (s *Store) MarkSceneComplete(chapter, scene int) error {
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
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.saveProgressUnlocked(p)
})
}
// ClearInProgress 清除场景级进度状态(章节提交后调用)。
// ClearInProgress 清除进度中间状态(章节提交后调用)
func (s *Store) ClearInProgress() error {
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()