init
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
84
app/agents.go
Normal 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
56
app/config.go
Normal 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
351
app/run.go
Normal 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
72
app/run_test.go
Normal 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
32
domain/chapter.go
Normal 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
51
domain/review.go
Normal 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
73
domain/runtime.go
Normal 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
32
domain/story.go
Normal 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
48
domain/writing.go
Normal 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
7
go.mod
Normal 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
4
go.sum
Normal 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
124
main.go
Normal 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
106
prompts/architect.md
Normal 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
98
prompts/coordinator.md
Normal 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
49
prompts/editor.md
Normal 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
81
prompts/writer.md
Normal 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
261
references/chapter-guide.md
Normal 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. 开头是否避免了平淡的日常/天气/背景说明?
|
||||||
19
references/chapter-template.md
Normal file
19
references/chapter-template.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 第[X]章:[章节标题]
|
||||||
|
|
||||||
|
## 本章概要
|
||||||
|
- **核心事件**:[一句话概括本章发生的事]
|
||||||
|
- **承接上章**:[回应上一章的悬念]
|
||||||
|
- **悬念钩子**:[本章结尾的钩子]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 正文
|
||||||
|
|
||||||
|
[章节正文内容 3000-5000 字,最低不低于 2500 字]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 章节备注
|
||||||
|
- 本章悬念:[简述结尾钩子]
|
||||||
|
- 下章预告:[可选,1-2句话]
|
||||||
|
- 伏笔标记:[如果埋下伏笔,在此记录]
|
||||||
220
references/character-building.md
Normal file
220
references/character-building.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# 人物塑造原则
|
||||||
|
|
||||||
|
好的人物是故事的灵魂。读者记住的是人,不是情节。
|
||||||
|
|
||||||
|
## 人物档案模板
|
||||||
|
|
||||||
|
每个主要角色都应建立完整档案:
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
```text
|
||||||
|
姓名:(有意义的名字更好)
|
||||||
|
年龄:
|
||||||
|
职业:
|
||||||
|
外貌特征:(2-3个显著特征,避免泛泛而谈)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性格核心
|
||||||
|
|
||||||
|
```text
|
||||||
|
核心价值观:(他最相信什么)
|
||||||
|
最大恐惧:(他最害怕什么)
|
||||||
|
致命缺陷:(什么会导致他失败)
|
||||||
|
内心渴望:(他真正想要什么)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 背景故事
|
||||||
|
|
||||||
|
```text
|
||||||
|
成长环境:
|
||||||
|
创伤经历:(过去伤害他的事)
|
||||||
|
关键记忆:(塑造他现在的关键事件)
|
||||||
|
秘密:(别人不知道的事)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 行为模式
|
||||||
|
|
||||||
|
```text
|
||||||
|
说话方式:(口头禅、语速、用词习惯)
|
||||||
|
肢体语言:(习惯动作、紧张时的小动作)
|
||||||
|
社交风格:(内向/外向、如何对待陌生人)
|
||||||
|
压力反应:(压力下如何表现)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人物类型塑造
|
||||||
|
|
||||||
|
### 主角(Protagonist)
|
||||||
|
|
||||||
|
**必须有:**
|
||||||
|
- **明确目标** - 他想要什么
|
||||||
|
- **强大动机** - 为什么想要
|
||||||
|
- **可共情性** - 读者能理解他的感受
|
||||||
|
- **成长空间** - 故事中会改变
|
||||||
|
|
||||||
|
**主角原型:**
|
||||||
|
| 类型 | 特征 | 故事作用 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| 英雄型 | 勇敢、正义、利他 | 战胜外在威胁 |
|
||||||
|
| 成长型 | 从弱小到强大 | 克服内在缺陷 |
|
||||||
|
| 反英雄型 | 道德灰色、复杂 | 挑战传统道德 |
|
||||||
|
| 平凡型 | 普通人卷入非凡事 | 读者代入感强 |
|
||||||
|
|
||||||
|
### 反派(Antagonist)
|
||||||
|
|
||||||
|
**好反派的特点:**
|
||||||
|
- **强大可信** - 不应该是草台班子
|
||||||
|
- **有自己的逻辑** - 他相信自己在做正确的事
|
||||||
|
- **与主角有深层联系** - 不是单纯为了作恶
|
||||||
|
- **揭示主题** - 挑战主角的信念
|
||||||
|
|
||||||
|
**反派动机类型:**
|
||||||
|
- 理想主义扭曲("为了大局必须牺牲")
|
||||||
|
- 过去创伤("世界伤害了我,我要报复")
|
||||||
|
- 权力渴望("我配得上更多")
|
||||||
|
- 与主角相同目标(不同方法)
|
||||||
|
|
||||||
|
### 配角(Supporting Characters)
|
||||||
|
|
||||||
|
**配角功能:**
|
||||||
|
- **导师型** - 指引主角,传递信息
|
||||||
|
- **盟友型** - 协助主角,提供情感支持
|
||||||
|
- **搞笑型** - 缓解紧张,提供喜剧元素
|
||||||
|
- **爱情型** - 制造浪漫线索,增加个人利害
|
||||||
|
- **叛徒型** - 制造背叛和转折
|
||||||
|
|
||||||
|
**配角原则:**
|
||||||
|
- 每个配角必须有明确作用
|
||||||
|
- 删除"只是存在"的角色
|
||||||
|
- 避免刻板印象(除非是有意为之)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人物深度塑造技巧
|
||||||
|
|
||||||
|
### 1. 矛盾性
|
||||||
|
|
||||||
|
真实的人是复杂的,充满矛盾。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
- 暴力的黑帮成员但爱护流浪猫
|
||||||
|
- 无神论的牧师
|
||||||
|
- 害怕黑暗的侦探
|
||||||
|
- 重视友情但总是背叛朋友
|
||||||
|
|
||||||
|
### 2. 侧面揭示
|
||||||
|
|
||||||
|
不要直接陈述性格,通过行为展示。
|
||||||
|
|
||||||
|
| 错误(直接陈述) | 正确(侧面展示) |
|
||||||
|
|----------------|----------------|
|
||||||
|
| 他很愤怒 | 他捏碎手中的纸杯 |
|
||||||
|
| 她很紧张 | 她反复调整眼镜位置 |
|
||||||
|
| 他很傲慢 | 他从不直视下属的眼睛 |
|
||||||
|
| 她很善良 | 她偷偷喂流浪狗三年 |
|
||||||
|
|
||||||
|
### 3. 声音独特性
|
||||||
|
|
||||||
|
每个人说话方式不同,对话中能分辨角色。
|
||||||
|
|
||||||
|
**区分要素:**
|
||||||
|
- 用词选择(正式/俚语/方言)
|
||||||
|
- 句子长度
|
||||||
|
- 是否打断别人
|
||||||
|
- 是否喜欢隐喻
|
||||||
|
- 情绪表达方式
|
||||||
|
|
||||||
|
### 4. 动机合理化
|
||||||
|
|
||||||
|
每个角色行为必须有合理动机,即使动机扭曲。
|
||||||
|
|
||||||
|
**反派动机合理化示例:**
|
||||||
|
- "我想毁灭世界" → 乏味
|
||||||
|
- "我失去了一切,世界对我没有意义" → 可理解但扭曲
|
||||||
|
- "人类是地球的病毒,我必须清除" → 有哲学支撑
|
||||||
|
|
||||||
|
### 5. 缺陷致命化
|
||||||
|
|
||||||
|
主角必须有缺陷,缺陷在关键时刻导致失败。
|
||||||
|
|
||||||
|
**经典缺陷模式:**
|
||||||
|
| 缺陷 | 导致的失败 |
|
||||||
|
|-----|----------|
|
||||||
|
| 傲慢 | 低估对手,落入陷阱 |
|
||||||
|
| 信任问题 | 拒绝帮助,孤立无援 |
|
||||||
|
| 完美主义 | 无法及时行动,错失机会 |
|
||||||
|
| 复仇心 | 被利用,失去理智 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人物关系设计
|
||||||
|
|
||||||
|
### 关系类型
|
||||||
|
|
||||||
|
| 关系 | 戏剧潜力 | 应用 |
|
||||||
|
|-----|---------|-----|
|
||||||
|
| 亦敌亦友 | 高 | 悬疑、动作 |
|
||||||
|
| 禁忌之爱 | 高 | 言情、悲剧 |
|
||||||
|
| 师徒关系 | 中 | 成长故事 |
|
||||||
|
| 兄弟竞争 | 中 | 家庭剧 |
|
||||||
|
| 陌生人联盟 | 中 | 冒险、悬疑 |
|
||||||
|
|
||||||
|
### 关系动态变化
|
||||||
|
|
||||||
|
**好的关系会随故事发展:**
|
||||||
|
```text
|
||||||
|
第一章:陌生人
|
||||||
|
第三章:不情愿的盟友
|
||||||
|
第五章:建立信任
|
||||||
|
第七章:背叛/考验
|
||||||
|
终章:真正的友谊(或决裂)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关系揭示
|
||||||
|
|
||||||
|
**逐步揭示关系深度:**
|
||||||
|
- 表层:表面互动
|
||||||
|
- 中层:共同经历
|
||||||
|
- 深层:真实感受/秘密
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人物一致性检查
|
||||||
|
|
||||||
|
角色行为必须符合已建立的性格。
|
||||||
|
|
||||||
|
**检查问题:**
|
||||||
|
- 这件事符合他的核心价值观吗?
|
||||||
|
- 以他的背景,会有这样的反应吗?
|
||||||
|
- 他的恐惧会如何影响这个决定?
|
||||||
|
- 他的缺陷会导致他犯什么错?
|
||||||
|
|
||||||
|
**例外处理:**
|
||||||
|
- 如果角色"不符合性格"行事,必须有原因
|
||||||
|
- 解释应该在相同/下一章提供
|
||||||
|
- 可以是成长的标志(角色克服缺陷)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人物出场设计
|
||||||
|
|
||||||
|
### 首次出场原则
|
||||||
|
|
||||||
|
**有效的出场方式:**
|
||||||
|
- **行动中** - 展示能力或性格
|
||||||
|
- **冲突中** - 立即建立关系/对立
|
||||||
|
- **误解中** - 建立悬念
|
||||||
|
|
||||||
|
**避免:**
|
||||||
|
- 镜子自照描写外貌
|
||||||
|
- 姓名+年龄+职业的简历式介绍
|
||||||
|
- 无意义的日常活动
|
||||||
|
|
||||||
|
### 出场示例对比
|
||||||
|
|
||||||
|
| 无效出场 | 有效出场 |
|
||||||
|
|---------|---------|
|
||||||
|
| 李明,28岁,是一名侦探。他走进办公室。| 李明跨过警戒线,警官试图拦住他。"市刑警队,李明。"他亮出证件,径直走向尸体。|
|
||||||
|
| 美丽的女孩坐在窗边,她叫小红。| 她已经三天没睡了,咖啡杯里的液体在颤抖。当门铃响起时,她几乎把杯子摔在地上。|
|
||||||
30
references/character-template.md
Normal file
30
references/character-template.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# 人物档案
|
||||||
|
|
||||||
|
## 主角
|
||||||
|
|
||||||
|
### [角色一姓名]
|
||||||
|
- **年龄/职业**:
|
||||||
|
- **外貌特征**:
|
||||||
|
- **性格核心**:
|
||||||
|
- **核心价值观**:
|
||||||
|
- **最大恐惧**:
|
||||||
|
- **致命缺陷**:
|
||||||
|
- **内心渴望**:
|
||||||
|
- **背景故事**:
|
||||||
|
- **MBTI:**
|
||||||
|
|
||||||
|
### [角色二姓名]
|
||||||
|
|
||||||
|
......
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 反派
|
||||||
|
|
||||||
|
### [角色姓名]
|
||||||
|
- [同主角格式]
|
||||||
|
|
||||||
|
## 配角
|
||||||
|
|
||||||
|
### [角色姓名]
|
||||||
|
- [简化格式]
|
||||||
40
references/consistency.md
Normal file
40
references/consistency.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 连贯性保证机制
|
||||||
|
|
||||||
|
为确保长时间创作的故事连贯性:
|
||||||
|
|
||||||
|
## 写前必读
|
||||||
|
|
||||||
|
每次开始写新章节前:
|
||||||
|
1. 阅读 `00-大纲.md` 中所有已完成章节的摘要
|
||||||
|
2. 读取上一章文件,了解当前悬念
|
||||||
|
3. 检查人物状态(位置、情绪、关系)
|
||||||
|
|
||||||
|
## 穿针引线
|
||||||
|
|
||||||
|
在新章节中:
|
||||||
|
- 呼应前文埋下的伏笔和线索
|
||||||
|
- 提及之前发生的事件(自然融入)
|
||||||
|
- 让人物行为与之前保持一致
|
||||||
|
|
||||||
|
## 人物状态跟踪
|
||||||
|
|
||||||
|
注意人物在各章节中的变化和成长:
|
||||||
|
- 位置变化(人在哪里)
|
||||||
|
- 情绪状态(当前心情)
|
||||||
|
- 关系变化(与其他角色关系)
|
||||||
|
- 能力变化(获得新技能/信息)
|
||||||
|
|
||||||
|
## 悬念线延续
|
||||||
|
|
||||||
|
确保主线悬念逐步推进:
|
||||||
|
- 每章至少回应一个旧悬念
|
||||||
|
- 提出新悬念或升级现有悬念
|
||||||
|
- 不要遗忘任何未解的悬念
|
||||||
|
|
||||||
|
## 一致性检查清单
|
||||||
|
|
||||||
|
- [ ] 人物行为符合其性格设定
|
||||||
|
- [ ] 前后伏笔有呼应,逻辑闭环
|
||||||
|
- [ ] 高潮低谷分布合理,节奏恰当
|
||||||
|
- [ ] 时间线连贯(没有时间跳跃错误)
|
||||||
|
- [ ] 场景转换自然(没有凭空出现)
|
||||||
66
references/content-expansion.md
Normal file
66
references/content-expansion.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 内容扩充技巧
|
||||||
|
|
||||||
|
当章节内容不足时,使用以下技巧自然扩充。
|
||||||
|
|
||||||
|
## 1. 场景细节描写
|
||||||
|
|
||||||
|
不要只说"他走进房间",描写:
|
||||||
|
- 房间的布局、光线、气味
|
||||||
|
- 物品的细节和质感
|
||||||
|
- 环境对人物的影响
|
||||||
|
- 人物在空间中的移动
|
||||||
|
|
||||||
|
## 2. 人物内心活动
|
||||||
|
|
||||||
|
展示而非讲述内心世界:
|
||||||
|
- 角色的犹豫和纠结
|
||||||
|
- 过去记忆的闪回(1-2段)
|
||||||
|
- 对未来的担忧和期待
|
||||||
|
- 道德选择的内心辩论
|
||||||
|
|
||||||
|
## 3. 对话扩展
|
||||||
|
|
||||||
|
不要只推进剧情,让对话:
|
||||||
|
- 展现人物性格和说话方式
|
||||||
|
- 包含潜台词和暗示
|
||||||
|
- 有来回交锋和试探
|
||||||
|
- 偶尔跑题再拉回(更真实)
|
||||||
|
|
||||||
|
## 4. 感官体验
|
||||||
|
|
||||||
|
调动五感描写:
|
||||||
|
- 视觉:颜色、光影、形状
|
||||||
|
- 听觉:声音、音乐、沉默
|
||||||
|
- 触觉:温度、质感、疼痛
|
||||||
|
- 嗅觉:气味、香味、腐臭
|
||||||
|
- 味觉:食物、饮料、血腥味
|
||||||
|
|
||||||
|
## 5. 次要情节线
|
||||||
|
|
||||||
|
在主剧情中穿插:
|
||||||
|
- 配角的小故事
|
||||||
|
- 暗线的发展
|
||||||
|
- 伏笔的埋设
|
||||||
|
- 人物关系的微妙变化
|
||||||
|
|
||||||
|
## 6. 节奏放慢
|
||||||
|
|
||||||
|
关键时刻慢下来描写:
|
||||||
|
- 动作场景的分解
|
||||||
|
- 情感转变的过程
|
||||||
|
- 发现真相的时刻
|
||||||
|
- 紧张对峙的延展
|
||||||
|
|
||||||
|
## 7. 环境烘托
|
||||||
|
|
||||||
|
用环境反映情绪:
|
||||||
|
- 天气和氛围
|
||||||
|
- 社会环境背景
|
||||||
|
- 文化习俗细节
|
||||||
|
- 时代特征展现
|
||||||
|
|
||||||
|
## 扩充原则
|
||||||
|
|
||||||
|
- **自然融入** - 扩充内容要服务于故事,不要注水
|
||||||
|
- **保持张力** - 即使扩充场景也不能失去冲突
|
||||||
|
- **推进主线** - 所有扩充最终都要指向核心剧情
|
||||||
316
references/dialogue-writing.md
Normal file
316
references/dialogue-writing.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
# 对话写作规范
|
||||||
|
|
||||||
|
好对话是揭示人物、推动情节、制造冲突的有力工具。
|
||||||
|
|
||||||
|
## 对话核心原则
|
||||||
|
|
||||||
|
### 1. 对话必须有目的
|
||||||
|
|
||||||
|
每句对话应该至少完成以下之一:
|
||||||
|
|
||||||
|
| 目的 | 示例 |
|
||||||
|
|-----|------|
|
||||||
|
| **推动情节** | "我找到凶器了,在河边的草丛里。" |
|
||||||
|
| **揭示人物** | "我不信任警察,他们从来不帮我这样的人。" |
|
||||||
|
| **制造冲突** | "你骗了我。你从头到尾都在骗我。" |
|
||||||
|
| **传达信息** | "炸弹将在三点引爆。" |
|
||||||
|
| **表达情感** | "我...我不知道该说什么。" |
|
||||||
|
| **制造悬念** | "你知道那天晚上真正发生了什么吗?" |
|
||||||
|
|
||||||
|
**无效对话:**
|
||||||
|
> "你好。"
|
||||||
|
> "你好。"
|
||||||
|
> "吃了吗?"
|
||||||
|
> "吃了。"
|
||||||
|
> "哦,那就好。"
|
||||||
|
|
||||||
|
### 2. 对话应该简洁
|
||||||
|
|
||||||
|
人们说话不写论文。删除多余的词。
|
||||||
|
|
||||||
|
| 啰嗦 | 简洁 |
|
||||||
|
|-----|------|
|
||||||
|
| "我想告诉你的是,我认为我们应该立刻离开这里。" | "我们得马上走。" |
|
||||||
|
| "我非常抱歉,但我真的不知道你刚才说的那件事的答案。" | "我不知道。" |
|
||||||
|
| "如果你不介意的话,我能不能请你帮我把那个东西递给我?" | "递给我那个。" |
|
||||||
|
|
||||||
|
### 3. 真实的人不会完整表达
|
||||||
|
|
||||||
|
真实对话充满:
|
||||||
|
- 打断
|
||||||
|
- 迟疑
|
||||||
|
- 话题转移
|
||||||
|
- 话没说完
|
||||||
|
- 暗示而非明说
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
> "我本来想告诉你,但是——"
|
||||||
|
> "但是什么?"
|
||||||
|
> "算了,没什么。"
|
||||||
|
> "不,你说。"
|
||||||
|
> "真的没什么。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对话格式规范
|
||||||
|
|
||||||
|
### 中文对话标点
|
||||||
|
|
||||||
|
**基础格式:**
|
||||||
|
```
|
||||||
|
"说话内容," 他说。
|
||||||
|
"说话内容?" 她问。
|
||||||
|
"说话内容!" 他大喊。
|
||||||
|
```
|
||||||
|
|
||||||
|
**多行对话:**
|
||||||
|
```
|
||||||
|
"第一句话,"他说,"第二句话。"
|
||||||
|
|
||||||
|
"第一句话。
|
||||||
|
第二句话,"他说,"第三句话。"
|
||||||
|
```
|
||||||
|
|
||||||
|
**对话动作:**
|
||||||
|
```
|
||||||
|
"说话内容。" 他做了动作。
|
||||||
|
他做了动作。"说话内容。"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 对话标签使用
|
||||||
|
|
||||||
|
**规则:**
|
||||||
|
- 能辨识说话人时,省略标签
|
||||||
|
- 使用"说""问"等中性标签
|
||||||
|
- 避免过度使用副词修饰
|
||||||
|
|
||||||
|
| 过度使用 | 改进后 |
|
||||||
|
|---------|--------|
|
||||||
|
| "你骗了我,"他愤怒地说。| "你骗了我。"他的声音在颤抖。 |
|
||||||
|
| "好的,"她高兴地同意道。| 她眼睛亮了。"好的。" |
|
||||||
|
| "我不知道,"他悲伤地回答。| 他低下头。"我不知道。" |
|
||||||
|
|
||||||
|
**标签位置:**
|
||||||
|
- 对话前:[标签]"对话。"
|
||||||
|
- 对话后:"对话。"[标签]
|
||||||
|
- 对话中断:"对话,"[标签]"对话。"
|
||||||
|
|
||||||
|
### 段落划分
|
||||||
|
|
||||||
|
**规则:** 每个说话人的对话开始新段落。
|
||||||
|
|
||||||
|
```
|
||||||
|
正确:
|
||||||
|
"第一句,"甲说。
|
||||||
|
"第二句,"乙回答。
|
||||||
|
"第三句。"甲点头。
|
||||||
|
|
||||||
|
错误:
|
||||||
|
"第一句,"甲说。"第二句,"乙回答。"第三句。"甲点头。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对话声音区分
|
||||||
|
|
||||||
|
每个角色说话方式应该不同。
|
||||||
|
|
||||||
|
### 区分维度
|
||||||
|
|
||||||
|
| 维度 | 示例 |
|
||||||
|
|-----|------|
|
||||||
|
| **用词** | 正式/俚语/方言/专业术语 |
|
||||||
|
| **句式** | 长句/短句/破碎句 |
|
||||||
|
| **停顿** | 流畅/迟疑/频繁打断 |
|
||||||
|
| **语气** | 温和/激烈/冷嘲热讽/平淡 |
|
||||||
|
| **习惯语** | 特定口头禅或用词习惯 |
|
||||||
|
|
||||||
|
### 角色声音示例
|
||||||
|
|
||||||
|
**教授型角色:**
|
||||||
|
> "从理论角度分析,这个假设存在三个主要缺陷。首先,数据样本不足;其次,实验条件未受控制;最后,结论过于激进。"
|
||||||
|
|
||||||
|
**街头混混型角色:**
|
||||||
|
> "扯淡。那帮人就是在放屁,想蒙咱们呢。我告诉你,这事儿没那么简单。"
|
||||||
|
|
||||||
|
**害羞内向型角色:**
|
||||||
|
> "我...我是说,如果...如果你不介意的话...那个..."
|
||||||
|
|
||||||
|
**傲慢自大型角色:**
|
||||||
|
> "让我来告诉你什么叫专业。你们这些业余人士根本不懂。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 潜台词(Subtext)
|
||||||
|
|
||||||
|
好的对话,真正含义在表面之下。
|
||||||
|
|
||||||
|
### 直接 vs 潜台词
|
||||||
|
|
||||||
|
| 直接(乏味) | 潜台词(有趣) |
|
||||||
|
|-------------|---------------|
|
||||||
|
| "我很生气。" | "没事。我挺好的。真的。" |
|
||||||
|
| "我喜欢你。" | "你今天看起来...不错。" |
|
||||||
|
| "我不信任你。" | "谢谢你告诉我。我会记住的。" |
|
||||||
|
| "我想离开。" | "这个地方空气不太好。" |
|
||||||
|
|
||||||
|
### 潜台词技巧
|
||||||
|
|
||||||
|
**1. 话题转移**
|
||||||
|
```
|
||||||
|
"你爱我吗?"
|
||||||
|
"你看了天气预报吗?明天有雨。"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 反问而非回答**
|
||||||
|
```
|
||||||
|
"你杀了他吗?"
|
||||||
|
"你觉得像我这样的人会做那种事?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 谈论其他事物**
|
||||||
|
```
|
||||||
|
"你想我吗?"
|
||||||
|
"我妈昨天打电话来了。"
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 沉默和动作**
|
||||||
|
```
|
||||||
|
"你愿意原谅我吗?"
|
||||||
|
她继续看杂志,翻了一页。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对话与动作结合
|
||||||
|
|
||||||
|
对话与肢体语言配合,增强表现力。
|
||||||
|
|
||||||
|
### 同步原则
|
||||||
|
|
||||||
|
动作与对话一致或矛盾,都有戏剧效果。
|
||||||
|
|
||||||
|
**一致(增强):**
|
||||||
|
> "我爱你。"她紧紧抱住他,眼泪流下来。
|
||||||
|
|
||||||
|
**矛盾(揭示真相):**
|
||||||
|
> "我完全支持你。"他目光看向别处,手在口袋里握紧拳头。
|
||||||
|
|
||||||
|
### 动作打断
|
||||||
|
|
||||||
|
动作插入可以控制节奏。
|
||||||
|
|
||||||
|
```
|
||||||
|
"我本来想告诉你,"他停下脚步,转过身,"但我想你已经知道了。"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动作替代标签
|
||||||
|
|
||||||
|
用动作替代"他说"。
|
||||||
|
|
||||||
|
```
|
||||||
|
"你在撒谎。"她拍案而起。
|
||||||
|
"坐下。"他头也不抬。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对话场景类型
|
||||||
|
|
||||||
|
### 争吵场景
|
||||||
|
|
||||||
|
**特征:**
|
||||||
|
- 短句
|
||||||
|
- 打断
|
||||||
|
- 重复强调
|
||||||
|
- 情绪升级
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
> "你答应过的!"
|
||||||
|
> "情况变了!"
|
||||||
|
> "那是你的借口!"
|
||||||
|
> "你根本不懂!"
|
||||||
|
> "我当然不懂!你什么都不告诉我!"
|
||||||
|
|
||||||
|
### 告白场景
|
||||||
|
|
||||||
|
**特征:**
|
||||||
|
- 迟疑
|
||||||
|
- 停顿
|
||||||
|
- 寻找词语
|
||||||
|
- 真诚或尴尬
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
> "我...我想说...这些年,我一直在想...如果我们..."
|
||||||
|
> 她低下头,声音变小。
|
||||||
|
> "如果我们什么?"
|
||||||
|
> "如果我们早一点相遇。"
|
||||||
|
|
||||||
|
### 审讯场景
|
||||||
|
|
||||||
|
**特征:**
|
||||||
|
- 提问控制
|
||||||
|
- 信息不对称
|
||||||
|
- 压力建立
|
||||||
|
- 操纵对话
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
> "那天晚上你在哪里?"
|
||||||
|
> "在家。"
|
||||||
|
> "有人能证明吗?"
|
||||||
|
> "...没有。"
|
||||||
|
> "你是一个人?"
|
||||||
|
> "是的。"
|
||||||
|
> "整个晚上?"
|
||||||
|
|
||||||
|
### 调情场景
|
||||||
|
|
||||||
|
**特征:**
|
||||||
|
- 双关语
|
||||||
|
- 试探
|
||||||
|
- 身体接近
|
||||||
|
- 暗示
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
> "你今天很漂亮。"
|
||||||
|
> "只是今天?"
|
||||||
|
> "嗯...今天特别漂亮。"
|
||||||
|
> "那我明天该担心了?"
|
||||||
|
> "明天...明天再看看。"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对话常见问题
|
||||||
|
|
||||||
|
### 避免
|
||||||
|
|
||||||
|
1. **信息倾倒** - 角色互相说已知信息
|
||||||
|
> 错误:"正如你所知,我们的公司成立于1995年..."
|
||||||
|
> 正确:通过情节自然揭示信息
|
||||||
|
|
||||||
|
2. **所有人说话一样** - 无法区分角色
|
||||||
|
> 解决:给每个角色独特的说话方式
|
||||||
|
|
||||||
|
3. **过度礼貌** - 真实对话更粗糙
|
||||||
|
> 错误:"我很抱歉打扰你,能否请你..."
|
||||||
|
> 正确:"喂。帮我个忙。"
|
||||||
|
|
||||||
|
4. **无意义的闲聊** - 除非有特殊目的
|
||||||
|
> 删除天气、吃饭等无关对话,除非揭示人物/推动情节
|
||||||
|
|
||||||
|
5. **说教** - 角色发表长篇哲学论述
|
||||||
|
> 改为通过冲突和行动展示观点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对话练习自查
|
||||||
|
|
||||||
|
写完对话后检查:
|
||||||
|
|
||||||
|
- [ ] 每句对话是否有目的?
|
||||||
|
- [ ] 删除后情节是否受影响?
|
||||||
|
- [ ] 能否辨识说话人(不看标签)?
|
||||||
|
- [ ] 是否有潜台词?
|
||||||
|
- [ ] 节奏是否合适(快/慢)?
|
||||||
|
- [ ] 是否符合人物性格?
|
||||||
|
- [ ] 标签使用是否正确?
|
||||||
25
references/fantasy/style-references.md
Normal file
25
references/fantasy/style-references.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 奇幻风格补充参考
|
||||||
|
|
||||||
|
## 世界构建
|
||||||
|
|
||||||
|
- 魔法体系必须有明确代价,不能出现"无限能量"
|
||||||
|
- 种族特征要在行动中自然展示,禁止百科式灌输
|
||||||
|
- 新设定首次出现时通过角色互动引出,不用旁白解释
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
- 龙傲天:主角能力攀升要有合理代价和挫折
|
||||||
|
- 设定膨胀:每章最多引入一个新设定概念
|
||||||
|
- 语言穿越:避免现代网络用语出现在古典背景中
|
||||||
|
|
||||||
|
## 战斗与能力描写
|
||||||
|
|
||||||
|
- 战斗重点是策略和代价,不是招式名称堆砌
|
||||||
|
- 能力上限在 world_rules 中已约定,严禁突破
|
||||||
|
- 受伤要有持续影响,不能"下一场景就好了"
|
||||||
|
|
||||||
|
## 氛围营造
|
||||||
|
|
||||||
|
- 通过五感描写建立异世界沉浸感
|
||||||
|
- 日常生活细节(货币、食物、交通)体现世界观
|
||||||
|
- 避免所有角色都说现代普通话,适当使用世界观内的表达方式
|
||||||
195
references/hook-techniques.md
Normal file
195
references/hook-techniques.md
Normal 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 | 终极悬念 | "一切到底是什么意思" | 全书结尾 |
|
||||||
|
|
||||||
|
**递进建议:** 故事中悬念强度应总体上升,但可以波动
|
||||||
47
references/outline-template.md
Normal file
47
references/outline-template.md
Normal 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字概括本章核心内容、重要情节、人物变化、悬念揭示等]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
(后续章节摘要依次追加)
|
||||||
259
references/plot-structures.md
Normal file
259
references/plot-structures.md
Normal 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章 |
|
||||||
262
references/quality-checklist.md
Normal file
262
references/quality-checklist.md
Normal 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 优秀** |
|
||||||
25
references/romance/style-references.md
Normal file
25
references/romance/style-references.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 言情风格补充参考
|
||||||
|
|
||||||
|
## 情感递进
|
||||||
|
|
||||||
|
- 感情发展遵循"排斥→好奇→动摇→确认→考验→稳固"曲线
|
||||||
|
- 每章推进一个情感阶段,禁止跳跃式发展
|
||||||
|
- 心动瞬间要通过具体细节呈现,不用"心跳加速"等抽象描写
|
||||||
|
|
||||||
|
## 关系张力
|
||||||
|
|
||||||
|
- 核心CP之间必须有持续的阻力源(性格、身份、误会、外部)
|
||||||
|
- 阻力要合理且难以轻易解决,不能为虐而虐
|
||||||
|
- 配角感情线不能抢主线篇幅,每章配角互动不超过 20%
|
||||||
|
|
||||||
|
## 对话质量
|
||||||
|
|
||||||
|
- 暧昧期对话要有潜台词,角色说的和想的应该不一样
|
||||||
|
- 吵架/冲突场景双方都要有道理,不能一方全错
|
||||||
|
- 甜蜜场景要有克制,过度撒糖会降低读者感受
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
- 降智推剧情:不让角色为了制造误会而突然变笨
|
||||||
|
- 工具人配角:每个配角都应有自己的动机
|
||||||
|
- 人设崩塌:感情中的行为必须符合角色已建立的性格特征
|
||||||
25
references/suspense/style-references.md
Normal file
25
references/suspense/style-references.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 悬疑风格补充参考
|
||||||
|
|
||||||
|
## 线索布局
|
||||||
|
|
||||||
|
- 每章至少埋设一条线索(实线索或红鲱鱼)
|
||||||
|
- 关键线索必须在揭晓前至少出现两次
|
||||||
|
- 线索首次出现要自然融入场景,不能刻意强调
|
||||||
|
|
||||||
|
## 误导技法
|
||||||
|
|
||||||
|
- 红鲱鱼要有独立的合理性,不能事后看完全无意义
|
||||||
|
- 叙述性诡计需要严格遵守"不说谎但可以省略"原则
|
||||||
|
- 嫌疑人转移要有动机支撑,不能为了误导而误导
|
||||||
|
|
||||||
|
## 节奏控制
|
||||||
|
|
||||||
|
- 紧张→舒缓→更紧张的波浪式节奏
|
||||||
|
- 信息释放要节制,每次只给读者"刚好不够"的信息量
|
||||||
|
- 章末必须有悬念钩子,禁止"平静收尾"
|
||||||
|
|
||||||
|
## 逻辑严密性
|
||||||
|
|
||||||
|
- 时间线必须可回溯验证
|
||||||
|
- 不在场证明、动机、手法三要素缺一不可
|
||||||
|
- 揭晓时读者应该能用已知线索自行推导出结论
|
||||||
162
scripts/check_chapter_wordcount.py
Executable file
162
scripts/check_chapter_wordcount.py
Executable 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
45
state/characters.go
Normal 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
114
state/drafts.go
Normal 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
76
state/foreshadow.go
Normal 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
77
state/outline.go
Normal 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
233
state/progress.go
Normal 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
116
state/progress_test.go
Normal 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
70
state/relationships.go
Normal 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
66
state/reviews.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveReview 保存审阅结果。scope=chapter 写 reviews/{ch}.json,scope=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
91
state/run_meta.go
Normal 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
183
state/run_meta_test.go
Normal 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
76
state/store.go
Normal 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
41
state/summaries.go
Normal 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
51
state/timeline.go
Normal 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
58
state/world_rules.go
Normal 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
140
state/world_rules_test.go
Normal 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
7
styles/default.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## 通用写作风格
|
||||||
|
|
||||||
|
- 叙事节奏:张弛有度,关键转折放慢,过渡紧凑
|
||||||
|
- 描写方式:五感具象描写优先于抽象概述
|
||||||
|
- 对话要求:体现人物性格差异,自然流畅,避免说教
|
||||||
|
- 情感表达:通过动作和细节传递,不直接点明情绪
|
||||||
|
- 文字风格:简洁有力,避免过度修饰
|
||||||
10
styles/fantasy.md
Normal file
10
styles/fantasy.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## 奇幻冒险风格
|
||||||
|
|
||||||
|
- **世界观展开**:不集中灌输设定,通过角色互动和行动自然展示世界规则
|
||||||
|
- **魔法/能力体系**:有明确代价和限制,避免万能型能力,冲突中展示体系边界
|
||||||
|
- **史诗感营造**:宏大叙事与个人命运交织,小人物视角折射大格局
|
||||||
|
- **种族与文化**:不同种族/文化有独特语言习惯、价值观和行为模式
|
||||||
|
- **战斗场景**:注重策略和代价,避免单纯的力量碾压,利用环境和智谋取胜
|
||||||
|
- **旅途叙事**:每个新场景都有独特的视觉/感官特征,避免"又一个村庄"
|
||||||
|
- **成长弧线**:主角的能力成长与心智成长同步,代价与收获并存
|
||||||
|
- **命名体系**:保持风格统一,避免突兀的现代词汇打破沉浸感
|
||||||
10
styles/romance.md
Normal file
10
styles/romance.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## 言情风格
|
||||||
|
|
||||||
|
- **情感递进**:遵循接触→好感→冲突→和解→深入的自然节奏,不急于推进
|
||||||
|
- **关系张力**:每个阶段都需要合理的阻碍,阻碍来源要多样(性格、立场、误解、外部压力)
|
||||||
|
- **内心描写**:深入角色内心,展现矛盾和挣扎,但避免大段独白式心理分析
|
||||||
|
- **互动细节**:用微表情、小动作、不经意的习惯传递情感,比直接告白更有张力
|
||||||
|
- **对话节奏**:暧昧期对话留白多,甜蜜期对话轻松自然,冲突期对话尖锐但克制
|
||||||
|
- **场景氛围**:环境描写与情感状态呼应,但不过度使用"下雨=悲伤"类刻板隐喻
|
||||||
|
- **配角功能**:闺蜜/兄弟角色推动情节发展,不沦为恋爱咨询工具
|
||||||
|
- **冲突设计**:误会不能靠"一句话就能解释清楚"维持,矛盾要触及核心价值观差异
|
||||||
10
styles/suspense.md
Normal file
10
styles/suspense.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## 悬疑推理风格
|
||||||
|
|
||||||
|
- **叙事结构**:多线叙事交织,信息差制造悬念,逐步揭示真相
|
||||||
|
- **误导技法**:合理设置红鲱鱼(红herring),利用叙述视角盲区误导读者
|
||||||
|
- **线索管理**:关键线索必须在揭示前至少出现两次,但不能太明显
|
||||||
|
- **节奏控制**:紧张-舒缓交替,每章末留悬念钩子,高潮前适当减速蓄力
|
||||||
|
- **氛围营造**:环境描写服务于紧张感,利用光影、声音、天气渲染不安
|
||||||
|
- **人物行为**:角色的每个决策必须有动机支撑,避免"为了推动剧情而做蠢事"
|
||||||
|
- **对话风格**:言外之意多于字面意思,审讯/对峙场景注重攻防节奏
|
||||||
|
- **真相揭示**:不能靠巧合或未出现的证据,读者回看时能发现伏笔
|
||||||
125
tools/check_consistency.go
Normal file
125
tools/check_consistency.go
Normal 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
168
tools/commit_chapter.go
Normal 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)
|
||||||
|
}
|
||||||
110
tools/commit_chapter_test.go
Normal file
110
tools/commit_chapter_test.go
Normal 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
164
tools/context.go
Normal 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
67
tools/plan_chapter.go
Normal 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
59
tools/polish_chapter.go
Normal 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
86
tools/save_foundation.go
Normal 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/characters),Architect 专用。
|
||||||
|
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 为 Markdown;type=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
70
tools/save_review.go
Normal 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
76
tools/write_scene.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user