Files
ainovel-clients/state/drafts.go

197 lines
5.0 KiB
Go

package state
import (
"fmt"
"os"
"regexp"
"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
}
// 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)
}
// 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
}
var merged string
if len(existing) > 0 {
merged = string(existing) + "\n\n" + content
} else {
merged = content
}
return s.writeMarkdown(rel, merged)
}
// 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
}
if err != nil {
return "", err
}
return string(data), nil
}
// LoadChapterContent 加载章节草稿正文及字数。
func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
draft, err := s.LoadDraft(chapter)
if err != nil {
return "", 0, err
}
if draft != "" {
return draft, utf8.RuneCountInString(draft), nil
}
return "", 0, nil
}
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。
func (s *Store) SaveFinalChapter(chapter int, content string) error {
return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content)
}
// 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
}
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
}