197 lines
5.0 KiB
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
|
|
}
|