Compare commits

..

14 Commits

Author SHA1 Message Date
shiyue
c73f024c83 fix: tmux detach 后 ask_user 不再永久阻塞
问题根因:askUserBridge.requests 是无缓冲 channel,TUI 退出后
listenAskUser goroutine 消失,LLM 调用 ask_user 工具时 handler()
阻塞在 `b.requests <- req`,只有 ctx 取消才能解除(会杀掉整个任务)。

修复:
- askUserBridge 新增 detachCh(chan struct{})和 atomic 的 detached 标志
- Detach() 用 CAS 保证只关闭一次 channel,防止 double-close panic
- handler() 两处 select 均增加 `<-b.detachCh` case,立即返回
  "用户不在线,请自行决策" 错误,LLM 收到后自主继续
- tui/app.go fallback 分支(p.Run() 退出且任务仍在运行时)
  立即调用 bridge.Detach(),解除所有 ask_user 阻塞
2026-03-18 22:31:15 +08:00
shiyue
8900332910 fix: tmux 脱离后任务不再被中断
bubbletea TUI 退出时检查任务是否仍在运行,
若仍在运行则回退到无 UI 阻塞等待模式,
而不是调用 AbortSilent() 杀死 coordinator。
2026-03-18 13:37:26 +08:00
shiyue
7ed7a6c81b feat: 添加 start 命令,交互式选择小说和风格 2026-03-18 09:56:49 +08:00
shiyue
351c12fdaa docs: 添加 tmux 使用说明到 help 2026-03-18 09:38:23 +08:00
shiyue
40a3479e2a feat: 完善 help 说明及多项功能优化 2026-03-18 00:20:12 +08:00
voocel
b23ac0fb6b feat: 实时数据展示优化 2026-03-17 09:50:32 +08:00
voocel
c913a49ffd perf: 上下文分级裁剪与Agent完成性保障 2026-03-15 22:52:17 +08:00
voocel
568ef0b1d1 refactor: Agent驱动重构,整章写入替代场景拼接 2026-03-15 14:14:46 +08:00
voocel
25e219e934 perf: ask user 2026-03-13 01:15:00 +08:00
voocel
7488198461 perf: 拆分规划策略 2026-03-13 00:19:21 +08:00
voocel
16e790a372 feat: 支持六维评审评分及别名管理 2026-03-12 22:25:34 +08:00
voocel
bce0adeff1 feat: 支持长篇小说分层架构(卷/弧/章三级结构) 2026-03-12 16:27:15 +08:00
voocel
3d65afa276 perf: 上下文策略自适应优化 2026-03-12 15:17:53 +08:00
voocel
e9c8220bc3 feat: add tab pane 2026-03-11 19:03:33 +08:00
62 changed files with 5700 additions and 1004 deletions

206
README.md Normal file
View File

@@ -0,0 +1,206 @@
# ainovel-cli
全自动 AI 长篇小说创作引擎。基于多智能体协作架构,从一句话需求到完整小说,全程无需人工干预。
<p align="center">
<img src="scripts/sample.gif" alt="ainovel-cli demo" width="800">
</p>
## 特性
- **多智能体协作** — Coordinator 调度 Architect / Writer / Editor 三个专职智能体,各司其职
- **确定性控制面** — 宿主程序通过信号文件驱动流程,不依赖 LLM 判断控制流
- **章节级断点恢复** — 中断后从上次写到的章节续写,不丢失进度
- **自适应上下文策略** — 根据总章节数自动切换全量 / 滑窗 / 分层摘要,支持 500+ 章长篇
- **七维质量评审** — Editor 从设定一致性、角色行为、节奏、叙事连贯、伏笔、钩子、审美品质七个维度评审,审美维度必须引用原文举证
- **用户实时干预** — 写作过程中可随时注入修改意见,系统自动评估影响范围并重写
- **双模式运行** — CLI 一行命令直接跑TUI 交互界面实时观察进度
- **多 LLM 支持** — OpenRouter / Anthropic / Gemini / OpenAI 等等随意切换
## 架构
```
┌─────────────────────────────────────────────────┐
│ Host控制面
│ 读取信号文件 → 确定性决策 → 注入 FollowUp 指令 │
└────────────┬────────────────────────┬───────────┘
│ │
┌───────▼───────┐ ┌────────▼────────┐
│ Coordinator │◄────►│ State Store │
│ (调度中枢) │ │ JSON 持久化) │
└──┬────┬────┬──┘ └─────────────────┘
│ │ │
┌────▼┐ ┌▼───┐ ┌▼─────┐
│Arch.│ │Wri.│ │Edit. │
│建筑师│ │作家 │ │编辑 │
└─────┘ └────┘ └──────┘
```
### 智能体职责
| 智能体 | 职责 | 工具 |
|--------|------|------|
| **Coordinator** | 调度全局,处理评审裁定和用户干预 | `subagent` `novel_context` `ask_user` |
| **Architect** | 生成前提、大纲、角色档案、世界规则 | `novel_context` `save_foundation` |
| **Writer** | 自主完成一章的构思、写作、自审和提交 | `novel_context` `read_chapter` `plan_chapter` `draft_chapter` `check_consistency` `commit_chapter` |
| **Editor** | 阅读原文,从结构和审美两个层面审阅 | `novel_context` `read_chapter` `save_review` `save_arc_summary` `save_volume_summary` |
### 写作流程
```
用户需求 → Architect 建基 → Writer 逐章写作 → Editor 评审
↑ │
└── 重写/打磨 ◄───┘
```
Writer 自主决定每章的创作流程,建议路径:
1. `novel_context` — 加载上下文(前情摘要、时间线、伏笔、角色状态、风格锚点、声纹)
2. `read_chapter` — 回读前一章结尾和角色对话,找回语气和节奏
3. `plan_chapter` — 构思本章目标、冲突、情绪弧线
4. `draft_chapter` — 写入整章正文
5. `read_chapter` + `check_consistency` — 自审:回读草稿,对照状态数据检查一致性
6. `commit_chapter` — 提交终稿,更新全局状态(可选附带大纲偏离反馈)
### 长篇分层架构
500+ 章小说采用三级结构自动管理上下文:
```
Volume
└── 弧Arc
└── 章Chapter
└── 场景Scene
```
- **卷摘要** — 压缩整卷为一段话,供后续卷参考
- **弧摘要 + 角色快照** — 弧结束时自动生成,追踪角色状态演变
- **章摘要** — 滑窗加载最近 3 章,远处靠弧/卷摘要覆盖
- **弧边界检测** — 自动识别弧/卷结束,触发对应评审和摘要生成
## 快速开始
```bash
# 安装
go install github.com/voocel/ainovel-cli@latest
# 配置 API Key任选一个 Provider
export LLM_PROVIDER=openrouter
export OPENROUTER_API_KEY=sk-xxx
# CLI 模式:一行启动
ainovel-cli "写一部12章都市悬疑小说主角是刑警暗线是家族秘密"
# TUI 模式:交互界面
ainovel-cli
```
### 环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `LLM_PROVIDER` | LLM 提供商(`openrouter` / `anthropic` / `gemini` / `openai` | `openrouter` |
| `OPENROUTER_API_KEY` | OpenRouter API Key | — |
| `ANTHROPIC_API_KEY` | Anthropic API Key | — |
| `GEMINI_API_KEY` | Gemini API Key | — |
| `Z_OPENAI_API_KEY` | OpenAI 兼容接口 API Key通用 | — |
| `Z_OPENAI_BASE_URL` | OpenAI 兼容接口 Base URL | — |
| `NOVEL_STYLE` | 写作风格 | `default` |
### 写作风格
通过 `NOVEL_STYLE` 环境变量切换:
- `default` — 通用风格
- `suspense` — 悬疑推理
- `fantasy` — 奇幻仙侠
- `romance` — 言情
## 输出结构
```
output/{novel_name}/
├── chapters/ # 终稿Markdown
│ ├── 01.md
│ └── ...
├── summaries/ # 章节摘要JSON
├── drafts/ # 章节草稿
├── reviews/ # 评审报告
├── meta/
│ ├── premise.md # 故事前提
│ ├── outline.json # 章节大纲
│ ├── characters.json # 角色档案
│ ├── world_rules.json# 世界规则
│ ├── progress.json # 进度状态
│ ├── timeline.json # 时间线
│ ├── foreshadow.json # 伏笔台账
│ ├── snapshots/ # 角色状态快照(长篇)
│ ├── characters.md # 角色档案(可读版)
│ └── world_rules.md # 世界规则(可读版)
```
## 设计理念
### Agent 驱动原则
**工具负责 IOAgent 负责思考。不要用流水线绑住 Agent 的手脚。**
这是本项目所有设计决策的最高优先级准则。具体要求:
1. **工具只做数据读写** — 工具不包含业务逻辑判断,不强制执行顺序。工具是 Agent 的手和眼,不是 Agent 的脑。
2. **决策权归 Agent** — 规划、写作、打磨、自审都是 Agent 的思考行为不是工具调用节点。Agent 自主决定何时读、何时写、何时审。
3. **不用流水线约束创作** — 不强制"先规划→再按场景写→再打磨→再检查"的固定流程。Writer 可以先写完整章,回读后修改,自审后提交,顺序自定。
4. **给 Agent 感知能力** — Agent 能回读自己写的文字和前文原文,而非只看结构化摘要。风格保持靠阅读原文,不靠字段描述。
5. **Host 只兜底控制流** — 确定性状态机只负责"下一步该做什么"的流程判断,不干预创作内容。
任何新增功能或工具设计,都必须先问:**这是 IO 操作还是思考行为?** 如果是思考,交给 Agent如果是 IO才做成工具。
### 全自动闭环
一句话输入,完整小说输出,中间零人工干预。系统自主完成全部创作决策:
```
"写一部悬疑小说" → 构建世界观 → 设计角色 → 规划大纲
→ 逐章写作 → 质量评审 → 自动重写
→ 弧级摘要 → 角色快照 → 完整成书
```
**自主决策能力:**
- **Architect 自主构建** — 从用户一句话需求推导出完整的前提、大纲、角色关系和世界规则
- **Writer 自主创作** — 每章独立完成规划、写作、打磨、一致性校验的完整闭环
- **Editor 自主评审** — 跨章节分析结构问题,输出裁定(通过 / 打磨 / 重写)及影响范围
- **Coordinator 自主调度** — 根据评审裁定安排重写,根据弧边界触发摘要生成,无需外部指令
- **自动伏笔管理** — 埋设、推进、回收全程由 Agent 自行追踪,不会烂尾
- **自动节奏调控** — 追踪叙事线和钩子类型历史,避免连续章节结构雷同
### 确定性控制面
Agent 负责创造Host 负责兜底。**控制流不交给 LLM 判断**。
Writer 调用 `commit_chapter` 后,宿主程序读取信号文件 `meta/last_commit.json`,确定性地决定下一步:
| 信号 | 宿主动作 |
|------|----------|
| 全部章节完成 | 标记完成,通知 Coordinator 总结全书 |
| `review_required=true` | 注入 Editor 评审指令 |
| `arc_end=true` | 注入弧级评审 + 弧摘要生成指令 |
| `volume_end=true` | 额外注入卷摘要生成指令 |
| 有待重写章节 | 注入重写指令 |
| 以上皆否 | 注入"继续写下一章"指令 |
Editor 评审裁定同理:`accept` → 继续,`polish/rewrite` → 注入修改指令。
这种设计保证:即使 LLM 幻觉或遗忘,宿主层的状态机也能把流程拉回正轨。
## 技术栈
- **Go 1.25** — 主语言
- **[agentcore](https://github.com/voocel/agentcore)** — 多智能体编排框架tool-calling + streaming
- **[litellm](https://github.com/voocel/litellm)** — 统一 LLM 接口适配
- **[Bubble Tea](https://github.com/charmbracelet/bubbletea)** — 终端 TUI 框架
- **[Lip Gloss](https://github.com/charmbracelet/lipgloss)** — 终端样式
## License
MIT

View File

@@ -2,6 +2,7 @@ package app
import (
"github.com/voocel/agentcore"
"github.com/voocel/agentcore/memory"
"github.com/voocel/ainovel-cli/state"
"github.com/voocel/ainovel-cli/tools"
)
@@ -18,6 +19,7 @@ func BuildCoordinator(
) (*agentcore.Agent, *tools.AskUserTool) {
// 共享工具
contextTool := tools.NewContextTool(store, refs, cfg.Style)
readChapter := tools.NewReadChapterTool(store)
askUser := tools.NewAskUserTool()
// Architect SubAgent 工具
@@ -26,31 +28,52 @@ func BuildCoordinator(
tools.NewSaveFoundationTool(store),
}
// Writer SubAgent 工具V1: +polish_chapter +check_consistency
// Writer SubAgent 工具:读写 + 规划 + 一致性检查 + 提交
writerTools := []agentcore.Tool{
contextTool,
readChapter,
tools.NewPlanChapterTool(store),
tools.NewWriteSceneTool(store),
tools.NewPolishChapterTool(store),
tools.NewDraftChapterTool(store),
tools.NewCheckConsistencyTool(store),
tools.NewCommitChapterTool(store),
}
// Editor SubAgent 工具V1
// Editor SubAgent 工具:读原文 + 审阅 + 摘要
editorTools := []agentcore.Tool{
contextTool,
readChapter,
tools.NewSaveReviewTool(store),
tools.NewSaveArcSummaryTool(store),
tools.NewSaveVolumeSummaryTool(store),
}
architect := agentcore.SubAgentConfig{
Name: "architect",
Description: "世界构建师:生成小说前提、大纲和角色档案",
architectShort := agentcore.SubAgentConfig{
Name: "architect_short",
Description: "短篇规划师:为单卷、单冲突、高密度故事生成紧凑设定与扁平大纲",
Model: model,
SystemPrompt: prompts.Architect,
SystemPrompt: prompts.ArchitectShort,
Tools: architectTools,
MaxTurns: 10,
}
architectMid := agentcore.SubAgentConfig{
Name: "architect_mid",
Description: "中篇规划师:为多阶段但篇幅受控的故事生成可推进的设定与阶段化大纲",
Model: model,
SystemPrompt: prompts.ArchitectMid,
Tools: architectTools,
MaxTurns: 12,
}
architectLong := agentcore.SubAgentConfig{
Name: "architect_long",
Description: "长篇规划师:为连载型、可持续升级的故事生成分层设定与卷弧大纲",
Model: model,
SystemPrompt: prompts.ArchitectLong,
Tools: architectTools,
MaxTurns: 14,
}
// 动态拼接风格指令到 Writer prompt
writerPrompt := prompts.Writer
if style, ok := styles[cfg.Style]; ok {
@@ -58,30 +81,46 @@ func BuildCoordinator(
}
writer := agentcore.SubAgentConfig{
Name: "writer",
Description: "场景写作者:逐场景完成一章的创作,包含打磨和一致性检查",
Model: model,
SystemPrompt: writerPrompt,
Tools: writerTools,
MaxTurns: 25,
Name: "writer",
Description: "创作者:自主完成一章的构思、写作、自审和提交",
Model: model,
SystemPrompt: writerPrompt,
Tools: writerTools,
MaxTurns: 20,
TransformContext: memory.NewCompaction(memory.CompactionConfig{
Model: model,
ContextWindow: cfg.ContextWindow,
ReserveTokens: 16384,
KeepRecentTokens: 20000,
}),
ConvertToLLM: memory.CompactionConvertToLLM,
}
editor := agentcore.SubAgentConfig{
Name: "editor",
Description: "全局审阅者:发现跨章结构问题,输出审阅结果",
Description: "审阅者:阅读原文,从结构和审美两个层面发现问题",
Model: model,
SystemPrompt: prompts.Editor,
Tools: editorTools,
MaxTurns: 10,
}
subagentTool := agentcore.NewSubAgentTool(architect, writer, editor)
subagentTool := agentcore.NewSubAgentTool(architectShort, architectMid, architectLong, writer, editor)
agent := agentcore.NewAgent(
agentcore.WithModel(model),
agentcore.WithSystemPrompt(prompts.Coordinator),
agentcore.WithTools(subagentTool, contextTool, askUser),
agentcore.WithMaxTurns(60),
agentcore.WithContextPipeline(
memory.NewCompaction(memory.CompactionConfig{
Model: model,
ContextWindow: cfg.ContextWindow,
ReserveTokens: 32000,
KeepRecentTokens: 30000,
}),
memory.CompactionConvertToLLM,
),
)
return agent, askUser
}

View File

@@ -7,22 +7,25 @@ import (
// Config 小说应用配置。
type Config struct {
Prompt string // 用户的小说需求
NovelName string // 小说名(用作输出目录名)
OutputDir string // 输出根目录,默认 output/{NovelName}
Provider string // LLM 提供商openai / anthropic / gemini
ModelName string // LLM 模型名
APIKey string // API Key
BaseURL string // API Base URL可选
Style string // 写作风格default/suspense/fantasy/romance
Prompt string // 用户的小说需求
NovelName string // 小说名(用作输出目录名)
OutputDir string // 输出根目录,默认 output/{NovelName}
Provider string // LLM 提供商openai / anthropic / gemini
ModelName string // LLM 模型名
APIKey string // API Key
BaseURL string // API Base URL可选
Style string // 写作风格default/suspense/fantasy/romance
ContextWindow int // 模型上下文窗口大小token默认 128000
}
// Prompts 嵌入的提示词。
type Prompts struct {
Coordinator string
Architect string
Writer string
Editor string
Coordinator string
ArchitectShort string
ArchitectMid string
ArchitectLong string
Writer string
Editor string
}
// Validate 校验配置CLI 模式,要求 Prompt 非空)。
@@ -36,7 +39,7 @@ func (c *Config) Validate() error {
// ValidateBase 校验基础配置TUI 模式下 Prompt 由用户输入,不在此检查)。
func (c *Config) ValidateBase() error {
if c.APIKey == "" {
return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)")
return fmt.Errorf("api key is required (set OPENROUTER_API_KEY, Z_OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY)")
}
switch c.Provider {
case "openai", "anthropic", "gemini", "openrouter":
@@ -70,4 +73,7 @@ func (c *Config) FillDefaults() {
if c.Style == "" {
c.Style = "default"
}
if c.ContextWindow <= 0 {
c.ContextWindow = 128000
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
"github.com/voocel/ainovel-cli/tools"
"github.com/voocel/litellm"
)
// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。
@@ -38,6 +39,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
if err := cfg.Validate(); err != nil {
return err
}
log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir)
// 1. 初始化状态
store := state.NewStore(cfg.OutputDir)
@@ -61,10 +63,10 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
askUser.SetHandler(cliAskUserHandler)
// 4. 确定性控制面:事件监听 + FollowUp 注入
registerSubscription(coordinator, store, nil, nil, nil)
registerSubscription(coordinator, store, cfg.Provider, nil, nil, nil)
// 5. 初始化运行元信息(保留已有 SteerHistory
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil {
log.Printf("[warn] 初始化运行元信息失败: %v", err)
}
@@ -90,7 +92,10 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
return fmt.Errorf("init progress: %w", err)
}
log.Printf("新建模式:%s", cfg.NovelName)
promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", cfg.Prompt)
promptText := fmt.Sprintf(
"请创作一部小说。若需求中有明确章节数,请严格遵守;若无明确章节数且题材适合长篇连载,请优先规划为分层长篇结构。要求如下:\n\n%s",
cfg.Prompt,
)
if err := coordinator.Prompt(promptText); err != nil {
return fmt.Errorf("prompt: %w", err)
}
@@ -115,7 +120,12 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
}
// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit emitFn, onDelta deltaFn, onClear clearFn) {
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, provider string, emit emitFn, onDelta deltaFn, onClear clearFn) {
var lastProgressSummary string
agentExt := newFieldExtractor("agent") // Coordinator → subagent 目标 agent 名称
taskExt := newFieldExtractor("task") // Coordinator → subagent 调度指令
subFilter := newStreamFilter("content") // SubAgent文本透传 + JSON 提取 content
coordinator.Subscribe(func(ev agentcore.Event) {
switch ev.Type {
case agentcore.EventToolExecStart:
@@ -128,46 +138,98 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
// 区分流式 delta 和进度摘要
if delta, ok := parseStreamDelta(ev); ok {
if onDelta != nil {
onDelta(delta)
if text := subFilter.Feed(delta); text != "" {
onDelta(text)
}
}
return
}
summary := parseProgressSummary(ev)
if summary == "" {
return
}
if summary == lastProgressSummary {
return
}
lastProgressSummary = summary
log.Printf("[progress] %s", summary)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"})
}
case agentcore.EventMessageStart:
// 新一轮 LLM 输出开始,清空流式缓冲
// 新一轮 LLM 输出开始,重置提取器 + 清空流式缓冲
agentExt.Reset()
taskExt.Reset()
subFilter.Reset()
if onClear != nil {
onClear()
}
case agentcore.EventMessageUpdate:
// Coordinator 自身思考时的流式 token
// Coordinator 的流式 token:先提取 agent 名称做标题,再提取 task 内容
if ev.Delta != "" && onDelta != nil {
onDelta(ev.Delta)
if name := agentExt.Feed(ev.Delta); name != "" {
onDelta("\n▸ " + agentLabel(name) + "\n")
}
if text := taskExt.Feed(ev.Delta); text != "" {
onDelta(text)
}
}
case agentcore.EventToolExecEnd:
lastProgressSummary = ""
if ev.IsError {
log.Printf("[tool:error] %s", ev.Tool)
detail := extractToolErrorText(ev.Result)
if detail != "" {
log.Printf("[tool:error] %s → %s", ev.Tool, detail)
} else {
log.Printf("[tool:error] %s", ev.Tool)
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: ev.Tool + " 执行失败", Level: "error"})
summary := ev.Tool + " 执行失败"
if detail != "" {
summary += " " + truncateLog(detail, 80)
}
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: summary, Level: "error"})
}
return
}
// subagent 结果:提取 usage 和 error单独记录
if ev.Tool == "subagent" {
logSubAgentResult(ev.Result, emit)
handleFoundationCheck(coordinator, store, emit)
committed := handleSubAgentDone(coordinator, store, emit)
if !committed {
handleUncommittedDraft(coordinator, store, emit)
}
handleEditorDone(coordinator, store, emit)
break
}
// novel_context提取加载摘要替代原始 JSON
if ev.Tool == "novel_context" {
if summary := extractLoadingSummary(ev.Result); summary != "" {
log.Printf("[tool:done] novel_context → %s", summary)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "CONTEXT", Summary: summary, Level: "info"})
}
} else {
log.Printf("[tool:done] novel_context → %s", truncateLog(string(ev.Result), 200))
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: "novel_context.done", Level: "info"})
}
break
}
// 其他工具:保持原样
log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200))
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".done", Level: "info"})
}
if ev.Tool == "subagent" {
handleSubAgentDone(coordinator, store, emit)
handleEditorDone(coordinator, store, emit)
}
case agentcore.EventMessageEnd:
if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant {
text := truncateLog(ev.Message.TextContent(), 300)
@@ -178,14 +240,30 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
}
case agentcore.EventError:
log.Printf("[error] %v", ev.Err)
log.Printf("[error][provider=%s] %v", provider, ev.Err)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("%v", ev.Err), Level: "error"})
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("[%s] %v", provider, ev.Err), Level: "error"})
}
}
})
}
func planningTierGuidance(runMeta *domain.RunMeta) string {
if runMeta == nil {
return ""
}
switch runMeta.PlanningTier {
case domain.PlanningTierShort:
return "当前规划级别short。如需调整设定或重做大纲优先调用 architect_short。"
case domain.PlanningTierMid:
return "当前规划级别mid。如需调整设定或重做大纲优先调用 architect_mid。"
case domain.PlanningTierLong:
return "当前规划级别long。如需调整设定或重做大纲优先调用 architect_long并保持分层大纲的一致性。"
default:
return ""
}
}
// submitSteer 提交用户干预CLI 和 Runtime 共用)。
func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) {
log.Printf("[steer] 用户干预: %s", text)
@@ -201,8 +279,17 @@ func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string)
if err := store.SetFlow(domain.FlowSteering); err != nil {
log.Printf("[warn] 设置流程状态失败: %v", err)
}
runMeta, err := store.LoadRunMeta()
if err != nil {
log.Printf("[warn] 读取运行元信息失败: %v", err)
}
guidance := planningTierGuidance(runMeta)
message := fmt.Sprintf("[用户干预] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。", text)
if guidance != "" {
message += "\n" + guidance
}
coordinator.Steer(agentcore.UserMsg(fmt.Sprintf(
"[用户干预] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。", text)))
"%s", message)))
}
// recoveryResult 恢复链的判断结果。
@@ -215,18 +302,42 @@ type recoveryResult struct {
// determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。
// 章节总数完全来自 Progress.TotalChapters由大纲自动设定不再由外部传入。
func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recoveryResult {
if progress == nil {
if progress == nil || progress.Phase == domain.PhaseInit {
return recoveryResult{IsNew: true}
}
guidance := planningTierGuidance(runMeta)
withGuidance := func(prompt string) string {
if guidance == "" {
return prompt
}
return prompt + "\n" + guidance
}
// 规划阶段恢复premise 已保存但 outline/characters 尚未完成
if progress.Phase == domain.PhasePremise {
return recoveryResult{
PromptText: withGuidance("前提设定已保存,但大纲/角色设定尚未完成。请调用 novel_context 查看已有设定,然后继续完成 outline、characters、world_rules 的规划,完成后开始逐章写作。"),
Label: "规划恢复:继续生成大纲/角色设定",
}
}
// 规划阶段恢复outline 已保存但写作尚未开始
if progress.Phase == domain.PhaseOutline {
return recoveryResult{
PromptText: withGuidance(fmt.Sprintf(
"大纲规划已完成,尚未开始写作。请从第 1 章开始逐章写作,总共需要写 %d 章。",
progress.TotalChapters)),
Label: fmt.Sprintf("写作恢复大纲就绪从第1章开始共 %d 章)", progress.TotalChapters),
}
}
if progress.InProgressChapter > 0 {
ch := progress.InProgressChapter
scenes := len(progress.CompletedScenes)
return recoveryResult{
PromptText: fmt.Sprintf(
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。",
ch, scenes, scenes+1, progress.TotalChapters),
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes),
PromptText: withGuidance(fmt.Sprintf(
"第 %d 章正在进行中,已有部分草稿。请调用 writer 继续完成该章(可用 read_chapter 读取已有草稿)。总共需要写 %d 章。",
ch, progress.TotalChapters)),
Label: fmt.Sprintf("恢复:第 %d 章进行中", ch),
}
}
@@ -236,18 +347,18 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
verb = "打磨"
}
return recoveryResult{
PromptText: fmt.Sprintf(
PromptText: withGuidance(fmt.Sprintf(
"有 %d 章待%s受影响章节%v。原因%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。",
len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters),
len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters)),
Label: fmt.Sprintf("%s恢复%d 章待处理 %v", verb, len(progress.PendingRewrites), progress.PendingRewrites),
}
}
if progress.Flow == domain.FlowReviewing {
return recoveryResult{
PromptText: fmt.Sprintf(
PromptText: withGuidance(fmt.Sprintf(
"上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。",
len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters),
len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters)),
Label: "审阅恢复:上次审阅中断",
}
}
@@ -255,9 +366,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" {
next := progress.NextChapter()
return recoveryResult{
PromptText: fmt.Sprintf(
PromptText: withGuidance(fmt.Sprintf(
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。",
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer),
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer)),
Label: "Steer 恢复:上次干预未完成,重新注入",
}
}
@@ -265,9 +376,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.IsResumable() {
next := progress.NextChapter()
return recoveryResult{
PromptText: fmt.Sprintf(
PromptText: withGuidance(fmt.Sprintf(
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。",
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters),
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters)),
Label: fmt.Sprintf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)",
next, len(progress.CompletedChapters), progress.TotalWordCount),
}
@@ -276,27 +387,80 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
return recoveryResult{IsNew: true}
}
// handleFoundationCheck 在 SubAgent 完成后检查基础设定是否完备。
// 如果 phase 仍在 premise有 premise 但无 outline注入确定性提醒。
func handleFoundationCheck(coordinator *agentcore.Agent, store *state.Store, emit emitFn) {
progress, _ := store.LoadProgress()
if progress == nil {
return
}
// 只在规划阶段检查premise 已保存但 outline 未保存)
if progress.Phase != domain.PhasePremise {
return
}
var missing []string
if o, _ := store.LoadOutline(); len(o) == 0 {
missing = append(missing, "outline")
}
if c, _ := store.LoadCharacters(); len(c) == 0 {
missing = append(missing, "characters")
}
if r, _ := store.LoadWorldRules(); len(r) == 0 {
missing = append(missing, "world_rules")
}
if len(missing) == 0 {
return
}
log.Printf("[host] 基础设定不完整,缺失: %v", missing)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("基础设定不完整,缺失: %v", missing), Level: "warn"})
}
runMeta, _ := store.LoadRunMeta()
guidance := planningTierGuidance(runMeta)
msg := fmt.Sprintf(
"[系统] 基础设定不完整,以下项目尚未保存:%v。请重新调用对应规划师补全这些设定。在基础设定全部完备前不要调用 writer。",
missing)
if guidance != "" {
msg += "\n" + guidance
}
coordinator.FollowUp(agentcore.UserMsg(msg))
}
// handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。
func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) {
// 返回 true 表示检测到 commit 信号Writer 正常完成)。
func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) bool {
result, err := store.LoadLastCommit()
if err != nil || result == nil {
return
return false
}
if err := store.ClearLastCommit(); err != nil {
log.Printf("[host] 清除 commit 信号失败: %v", err)
}
log.Printf("[host] 章节提交信号:第 %d 章,%d 字%d 个场景",
result.Chapter, result.WordCount, result.SceneCount)
log.Printf("[host] 章节提交信号:第 %d 章,%d 字",
result.Chapter, result.WordCount)
if emit != nil {
emit(UIEvent{
Time: time.Now(),
Category: "SYSTEM",
Summary: fmt.Sprintf("第 %d 章已提交:%d 字%d 个场景", result.Chapter, result.WordCount, result.SceneCount),
Summary: fmt.Sprintf("第 %d 章已提交:%d 字", result.Chapter, result.WordCount),
Level: "success",
})
}
// outline_feedback 处理Writer 反馈大纲偏离
if result.Feedback != nil && result.Feedback.Deviation != "" {
log.Printf("[host] outline_feedback: %s", result.Feedback.Deviation)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: "Writer 反馈大纲偏离: " + truncateLog(result.Feedback.Deviation, 60), Level: "info"})
}
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] Writer 在第 %d 章写作中发现大纲偏离。偏离:%s。建议%s。请评估是否需要调整后续大纲。",
result.Chapter, result.Feedback.Deviation, result.Feedback.Suggestion)))
}
// 确定性判断 0正在重写/打磨流程中
progress, _ := store.LoadProgress()
if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) {
@@ -305,7 +469,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 当前处于重写流程,但提交了非队列章节(第 %d 章)。请先完成待重写章节 %v 后再继续新章节。",
result.Chapter, progress.PendingRewrites)))
return
return true
}
if err := store.CompleteRewrite(result.Chapter); err != nil {
log.Printf("[host] 完成重写标记失败: %v", err)
@@ -323,7 +487,65 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
log.Printf("[host] 还有 %d 章待处理:%v", len(updated.PendingRewrites), updated.PendingRewrites)
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
}
return
return true
}
// 确定性判断 1.5:长篇弧/卷边界处理
if progress != nil && progress.Layered && result.ArcEnd {
// 判断是否全书最后一弧
isBookEnd := progress.TotalChapters > 0 && result.NextChapter > progress.TotalChapters
if result.VolumeEnd {
log.Printf("[host] 第 %d 卷第 %d 弧结束(卷结束),注入弧级+卷级评审指令", result.Volume, result.Arc)
if err := store.SetFlow(domain.FlowReviewing); err != nil {
log.Printf("[host] 设置审阅流程失败: %v", err)
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发评审", result.Volume, result.Arc), Level: "warn"})
}
tail := "完成后继续写下一卷。"
if isBookEnd {
tail = "完成后总结全书并结束。不要再调用 writer。"
}
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 第 %d 卷第 %d 弧结束(卷结束)。请依次:\n"+
"1. 调用 editor 进行弧级评审scope=arc最新章节为第 %d 章)\n"+
"2. 调用 editor 生成弧摘要和角色快照save_arc_summaryvolume=%darc=%d\n"+
"3. 调用 editor 生成卷摘要save_volume_summaryvolume=%d\n"+
"%s",
result.Volume, result.Arc, result.Chapter, result.Volume, result.Arc, result.Volume, tail)))
} else {
log.Printf("[host] 第 %d 卷第 %d 弧结束,注入弧级评审指令", result.Volume, result.Arc)
if err := store.SetFlow(domain.FlowReviewing); err != nil {
log.Printf("[host] 设置审阅流程失败: %v", err)
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("第 %d 卷第 %d 弧结束,触发弧级评审", result.Volume, result.Arc), Level: "warn"})
}
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 第 %d 卷第 %d 弧结束。请依次:\n"+
"1. 调用 editor 进行弧级评审scope=arc最新章节为第 %d 章)\n"+
"2. 调用 editor 生成弧摘要和角色快照save_arc_summaryvolume=%darc=%d\n"+
"完成后继续写下一弧的章节。",
result.Volume, result.Arc, result.Chapter, result.Volume, result.Arc)))
}
if isBookEnd {
log.Printf("[host] 全书最后一弧,评审完成后将结束")
if err := store.MarkComplete(); err != nil {
log.Printf("[host] 标记完成失败: %v", err)
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("全部 %d 章已完成,等待最终评审", progress.TotalChapters), Level: "success"})
}
}
clearHandledSteer(store)
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
return true
}
// 确定性判断 1全书完成TotalChapters 由大纲自动设定)
@@ -344,7 +566,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。",
totalChapters)))
return
return true
}
// 确定性判断 2需要全局审阅
@@ -362,6 +584,37 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
}
clearHandledSteer(store)
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
return true
}
// handleUncommittedDraft 在 Writer 结束但没有 commit 时检测是否存在未提交的草稿。
// 如果存在,提醒 Coordinator 重新调用 writer 完成提交。
func handleUncommittedDraft(coordinator *agentcore.Agent, store *state.Store, emit emitFn) {
progress, _ := store.LoadProgress()
if progress == nil || progress.Phase == domain.PhaseComplete {
return
}
// 确定下一个应该写的章节
next := 1
if progress.InProgressChapter > 0 {
next = progress.InProgressChapter
} else if len(progress.CompletedChapters) > 0 {
next = progress.NextChapter()
}
// 检查该章节是否有草稿但未提交
draft, _ := store.LoadDraft(next)
if draft == "" {
return
}
// 有草稿但没有 commit 信号
log.Printf("[host] Writer 结束但第 %d 章草稿未提交", next)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("第 %d 章有草稿但未提交", next), Level: "warn"})
}
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] Writer 结束但第 %d 章草稿未提交。请重新调用 writer 完成该章的自审和提交commit_chapter。",
next)))
}
// handleEditorDone 在 Editor SubAgent 完成后读取审阅信号。
@@ -378,7 +631,15 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store, emit emi
log.Printf("[host] 清除审阅信号失败: %v", err)
}
log.Printf("[host] 审阅信号verdict=%s%d 个问题", review.Verdict, len(review.Issues))
criticalN := review.CriticalCount()
log.Printf("[host] 审阅信号verdict=%s%d 个问题critical=%derror=%d",
review.Verdict, len(review.Issues), criticalN, review.ErrorCount())
// 宿主兜底:如果 LLM 给了 accept 但存在 critical 问题,强制升级为 rewrite
if review.Verdict == "accept" && criticalN > 0 {
log.Printf("[host] 检测到 %d 个 critical 问题但 verdict=accept强制升级为 rewrite", criticalN)
review.Verdict = "rewrite"
}
chaptersInfo := ""
if len(review.AffectedChapters) > 0 {
@@ -460,16 +721,25 @@ func parseProgressSummary(ev agentcore.Event) string {
return "progress"
}
var data struct {
Agent string `json:"agent"`
Tool string `json:"tool"`
Turn int `json:"turn"`
Error bool `json:"error"`
Agent string `json:"agent"`
Tool string `json:"tool"`
Turn int `json:"turn"`
Error bool `json:"error"`
Message string `json:"message"`
Thinking string `json:"thinking"`
}
if err := json.Unmarshal(ev.Result, &data); err != nil {
return truncateLog(string(ev.Result), 60)
}
// subagent 的 thinking 更新属于高频内部推理,不适合刷到事件流面板。
if data.Thinking != "" && data.Tool == "" {
return ""
}
if data.Tool != "" {
if data.Error {
if data.Message != "" {
return fmt.Sprintf("%s → %s (error: %s)", data.Agent, data.Tool, truncateLog(data.Message, 120))
}
return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool)
}
return fmt.Sprintf("%s → %s", data.Agent, data.Tool)
@@ -480,6 +750,107 @@ func parseProgressSummary(ev agentcore.Event) string {
return truncateLog(string(ev.Result), 60)
}
// extractLoadingSummary 从 novel_context 的返回 JSON 中提取 _loading_summary 字段。
func extractLoadingSummary(result json.RawMessage) string {
if len(result) == 0 {
return ""
}
var data struct {
Summary string `json:"_loading_summary"`
}
if err := json.Unmarshal(result, &data); err != nil {
return ""
}
return data.Summary
}
// logSubAgentResult 从 subagent 结果中提取 usage 和 error分别记录结构化日志。
func logSubAgentResult(result json.RawMessage, emit emitFn) {
if len(result) == 0 {
log.Printf("[tool:done] subagent → (empty)")
return
}
var data struct {
Output string `json:"output"`
Error string `json:"error"`
Usage struct {
Input int `json:"input"`
Output int `json:"output"`
CacheRead int `json:"cache_read"`
CacheWrite int `json:"cache_write"`
Cost float64 `json:"cost"`
Turns int `json:"turns"`
Tools int `json:"tools"`
} `json:"usage"`
}
if err := json.Unmarshal(result, &data); err != nil {
log.Printf("[tool:done] subagent → %s", truncateLog(string(result), 200))
return
}
// 记录 usage
u := data.Usage
log.Printf("[usage] input=%d output=%d cache_read=%d turns=%d tools=%d",
u.Input, u.Output, u.CacheRead, u.Turns, u.Tools)
if data.Error != "" {
log.Printf("[subagent:error] %s", data.Error)
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "ERROR",
Summary: "subagent: " + truncateLog(data.Error, 80), Level: "error"})
}
return
}
log.Printf("[tool:done] subagent → %s", truncateLog(data.Output, 200))
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: "subagent.done", Level: "info"})
}
}
func extractToolErrorText(result json.RawMessage) string {
if len(result) == 0 {
return ""
}
var plain string
if err := json.Unmarshal(result, &plain); err == nil {
return plain
}
var obj struct {
Error string `json:"error"`
Message string `json:"message"`
Detail string `json:"detail"`
}
if err := json.Unmarshal(result, &obj); err == nil {
switch {
case obj.Error != "":
return obj.Error
case obj.Message != "":
return obj.Message
case obj.Detail != "":
return obj.Detail
}
}
return truncateLog(string(result), 160)
}
// agentLabel 将内部 agent 名称映射为用户友好的标签。
func agentLabel(name string) string {
switch name {
case "architect_short", "architect_mid", "architect_long":
return "Architect 规划中"
case "writer":
return "Writer 创作中"
case "editor":
return "Editor 审阅中"
default:
return name
}
}
func truncateLog(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
@@ -540,11 +911,25 @@ func createModel(cfg Config) (agentcore.ChatModel, error) {
return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...)
case "gemini":
return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...)
default: // openai, openrouter 及其他 OpenAI 兼容服务
case "openrouter":
return newOpenRouterModel(cfg.ModelName, cfg.APIKey, baseURL...)
default: // openai 及其他 OpenAI 兼容服务
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
}
}
func newOpenRouterModel(model, apiKey string, baseURL ...string) (agentcore.ChatModel, error) {
cfg := litellm.ProviderConfig{APIKey: apiKey}
if len(baseURL) > 0 {
cfg.BaseURL = baseURL[0]
}
client, err := litellm.NewWithProvider("openrouter", cfg)
if err != nil {
return nil, fmt.Errorf("openrouter: %w", err)
}
return llm.NewLiteLLMAdapter(model, client), nil
}
// cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。
func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
resp := &tools.AskUserResponse{

View File

@@ -1,8 +1,11 @@
package app
import (
"encoding/json"
"strings"
"testing"
"github.com/voocel/agentcore"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
@@ -70,3 +73,107 @@ func TestFinalizeSteerIfIdleKeepsActiveFlow(t *testing.T) {
t.Fatalf("expected pending steer preserved, got %q", runMeta.PendingSteer)
}
}
func TestParseProgressSummaryIgnoresThinkingUpdate(t *testing.T) {
result, err := json.Marshal(map[string]any{
"agent": "architect",
"thinking": "好的,我已经获得了模板。",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
summary := parseProgressSummary(agentcore.Event{Result: result})
if summary != "" {
t.Fatalf("expected thinking update to be ignored, got %q", summary)
}
}
func TestParseProgressSummaryKeepsToolProgress(t *testing.T) {
result, err := json.Marshal(map[string]any{
"agent": "writer",
"tool": "plan_chapter",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
summary := parseProgressSummary(agentcore.Event{Result: result})
if summary != "writer → plan_chapter" {
t.Fatalf("unexpected summary: %q", summary)
}
}
func TestCreateModelUsesOpenRouterProvider(t *testing.T) {
model, err := createModel(Config{
Provider: "openrouter",
ModelName: "stepfun/step-3.5-flash:free",
APIKey: "test-key",
BaseURL: "https://openrouter.ai/api/v1",
})
if err != nil {
t.Fatalf("createModel: %v", err)
}
providerModel, ok := model.(interface{ ProviderName() string })
if !ok {
t.Fatalf("model does not expose provider name")
}
if provider := providerModel.ProviderName(); provider != "openrouter" {
t.Fatalf("expected provider openrouter, got %q", provider)
}
}
func TestDetermineRecoveryIncludesPlanningTierGuidance(t *testing.T) {
progress := &domain.Progress{
Phase: domain.PhaseWriting,
CurrentChapter: 3,
CompletedChapters: []int{1, 2},
TotalWordCount: 2400,
TotalChapters: 12,
}
runMeta := &domain.RunMeta{
PlanningTier: domain.PlanningTierLong,
}
recovery := determineRecovery(progress, runMeta)
if !strings.Contains(recovery.PromptText, "architect_long") {
t.Fatalf("expected architect_long guidance, got %q", recovery.PromptText)
}
if !strings.Contains(recovery.PromptText, "分层大纲") {
t.Fatalf("expected layered-outline guidance, got %q", recovery.PromptText)
}
}
func TestPlanningTierGuidanceForMid(t *testing.T) {
guidance := planningTierGuidance(&domain.RunMeta{PlanningTier: domain.PlanningTierMid})
if !strings.Contains(guidance, "architect_mid") {
t.Fatalf("expected architect_mid guidance, got %q", guidance)
}
}
func TestExtractToolErrorTextFromJSONString(t *testing.T) {
result, err := json.Marshal("save planning tier: permission denied")
if err != nil {
t.Fatalf("Marshal: %v", err)
}
text := extractToolErrorText(result)
if text != "save planning tier: permission denied" {
t.Fatalf("unexpected error text: %q", text)
}
}
func TestExtractToolErrorTextFromJSONObject(t *testing.T) {
result, err := json.Marshal(map[string]any{
"message": "parse outline JSON: invalid character",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
text := extractToolErrorText(result)
if text != "parse outline JSON: invalid character" {
t.Fatalf("unexpected error text: %q", text)
}
}

View File

@@ -25,6 +25,7 @@ type UIEvent struct {
// UISnapshot 是 TUI 渲染所需的聚合状态快照。
type UISnapshot struct {
Provider string
NovelName string
ModelName string
Style string
@@ -36,7 +37,6 @@ type UISnapshot struct {
CompletedCount int
TotalWordCount int
InProgressChapter int
CompletedScenes int
PendingRewrites []int
RewriteReason string
PendingSteer string
@@ -94,6 +94,7 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
if err := cfg.ValidateBase(); err != nil {
return nil, err
}
log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir)
store := state.NewStore(cfg.OutputDir)
if err := store.Init(); err != nil {
@@ -119,10 +120,10 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
}
// 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发
registerSubscription(coordinator, store, rt.emit, rt.emitDelta, rt.emitClear)
registerSubscription(coordinator, store, cfg.Provider, rt.emit, rt.emitDelta, rt.emitClear)
// 初始化运行元信息
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil {
log.Printf("[warn] 初始化运行元信息失败: %v", err)
}
@@ -196,7 +197,10 @@ func (rt *Runtime) Start(prompt string) error {
return fmt.Errorf("init progress: %w", err)
}
promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", prompt)
promptText := fmt.Sprintf(
"请创作一部小说。若需求中有明确章节数,请严格遵守;若无明确章节数且题材适合长篇连载,请优先规划为分层长篇结构。要求如下:\n\n%s",
prompt,
)
if err := rt.coordinator.Prompt(promptText); err != nil {
return fmt.Errorf("prompt: %w", err)
}
@@ -249,6 +253,7 @@ func (rt *Runtime) Steer(text string) {
func (rt *Runtime) Snapshot() UISnapshot {
snap := UISnapshot{
NovelName: rt.cfg.NovelName,
Provider: rt.cfg.Provider,
ModelName: rt.cfg.ModelName,
Style: rt.cfg.Style,
}
@@ -266,7 +271,6 @@ func (rt *Runtime) Snapshot() UISnapshot {
snap.CompletedCount = len(progress.CompletedChapters)
snap.TotalWordCount = progress.TotalWordCount
snap.InProgressChapter = progress.InProgressChapter
snap.CompletedScenes = len(progress.CompletedScenes)
snap.PendingRewrites = progress.PendingRewrites
snap.RewriteReason = progress.RewriteReason
}

216
app/stream_extract.go Normal file
View File

@@ -0,0 +1,216 @@
package app
import "strings"
// jsonFieldExtractor 从流式 JSON 碎片中提取指定字段的字符串值。
//
// LLM 流式生成 tool call 时参数是逐片段到达的OpenAI/Anthropic
// 或一次性到达的Gemini。本提取器用状态机逐字符扫描
// 检测到目标 key 后提取其字符串值,处理 JSON 转义。
type jsonFieldExtractor struct {
key string // 匹配目标,如 `"content"` 或 `"task"`
state extractState
matchPos int
escape bool
buf strings.Builder
}
type extractState int
const (
stateScan extractState = iota // 扫描,寻找目标 key
stateColon // 已匹配 key等冒号和开头引号
stateExtract // 提取字符串值中
)
func newFieldExtractor(fieldName string) *jsonFieldExtractor {
return &jsonFieldExtractor{key: `"` + fieldName + `"`}
}
// Feed 处理一段 delta返回提取到的文本可能为空
func (e *jsonFieldExtractor) Feed(delta string) string {
e.buf.Reset()
for _, r := range delta {
switch e.state {
case stateScan:
e.feedScan(r)
case stateColon:
e.feedColon(r)
case stateExtract:
e.feedExtract(r)
}
}
return e.buf.String()
}
func (e *jsonFieldExtractor) feedScan(r rune) {
if e.matchPos < len(e.key) && byte(r) == e.key[e.matchPos] {
e.matchPos++
if e.matchPos == len(e.key) {
e.state = stateColon
e.matchPos = 0
}
return
}
e.matchPos = 0
if byte(r) == e.key[0] {
e.matchPos = 1
}
}
func (e *jsonFieldExtractor) feedColon(r rune) {
switch r {
case ':', ' ', '\t':
// 跳过
case '"':
e.state = stateExtract
e.escape = false
default:
e.state = stateScan
e.matchPos = 0
if byte(r) == e.key[0] {
e.matchPos = 1
}
}
}
func (e *jsonFieldExtractor) feedExtract(r rune) {
if e.escape {
e.escape = false
switch r {
case 'n':
e.buf.WriteByte('\n')
case 't':
e.buf.WriteByte('\t')
case 'r':
e.buf.WriteByte('\r')
case '"', '\\', '/':
e.buf.WriteRune(r)
default:
e.buf.WriteByte('\\')
e.buf.WriteRune(r)
}
return
}
switch r {
case '\\':
e.escape = true
case '"':
e.state = stateScan
e.matchPos = 0
default:
e.buf.WriteRune(r)
}
}
// Reset 重置状态(新 LLM 消息轮次时调用)。
func (e *jsonFieldExtractor) Reset() {
e.state = stateScan
e.matchPos = 0
e.escape = false
}
// ThinkingSep 是思考文本与正文之间的分隔标记。
// streamFilter 在思考文本段前插入此标记TUI 据此切换渲染样式。
const ThinkingSep = "\x02"
// streamFilter 区分 SubAgent 的文本回复和 JSON 工具调用。
// 文本回复标记为思考内容(前缀 ThinkingSepJSON 工具调用只提取指定字段。
//
// 判断依据:遇到 { 进入 JSON 模式(追踪大括号深度),
// 深度归零后回到文本模式。
type streamFilter struct {
fieldExt *jsonFieldExtractor
mode filterMode
braceDepth int
inString bool // 在 JSON 字符串内(大括号不计数)
escJSON bool // JSON 字符串内的转义
thinking bool // 当前处于思考文本段
buf strings.Builder
}
type filterMode int
const (
filterText filterMode = iota // 文本回复,直接透传
filterJSON // JSON 工具调用,提取目标字段
)
func newStreamFilter(fieldName string) *streamFilter {
return &streamFilter{fieldExt: newFieldExtractor(fieldName)}
}
// Feed 处理一段 delta返回可展示文本。
// 文本回复直接输出JSON 中的目标字段值被提取输出;其余 JSON 结构丢弃。
func (f *streamFilter) Feed(delta string) string {
f.buf.Reset()
for _, r := range delta {
switch f.mode {
case filterText:
if r == '{' {
f.thinking = false
f.mode = filterJSON
f.braceDepth = 1
f.inString = false
f.escJSON = false
f.fieldExt.Reset()
f.feedExtractor(r)
} else {
if !f.thinking {
f.thinking = true
f.buf.WriteString(ThinkingSep)
}
f.buf.WriteRune(r)
}
case filterJSON:
f.feedExtractor(r)
f.trackBraces(r)
}
}
return f.buf.String()
}
// feedExtractor 将单个字符喂给 fieldExt提取结果写入 buf。
func (f *streamFilter) feedExtractor(r rune) {
if text := f.fieldExt.Feed(string(r)); text != "" {
f.buf.WriteString(text)
}
}
// trackBraces 追踪 JSON 大括号深度,深度归零时切回文本模式。
func (f *streamFilter) trackBraces(r rune) {
if f.escJSON {
f.escJSON = false
return
}
if f.inString {
switch r {
case '\\':
f.escJSON = true
case '"':
f.inString = false
}
return
}
switch r {
case '"':
f.inString = true
case '{':
f.braceDepth++
case '}':
f.braceDepth--
if f.braceDepth <= 0 {
f.mode = filterText
}
}
}
// Reset 重置状态。
func (f *streamFilter) Reset() {
f.mode = filterText
f.braceDepth = 0
f.inString = false
f.escJSON = false
f.thinking = false
f.fieldExt.Reset()
}

194
app/stream_extract_test.go Normal file
View File

@@ -0,0 +1,194 @@
package app
import "testing"
// --- jsonFieldExtractor tests ---
func TestFieldExtractor_SingleFeed(t *testing.T) {
e := newFieldExtractor("content")
got := e.Feed(`{"chapter":1,"content":"hello world","mode":"write"}`)
if got != "hello world" {
t.Errorf("got %q, want %q", got, "hello world")
}
}
func TestFieldExtractor_CrossDelta(t *testing.T) {
e := newFieldExtractor("content")
var result string
for _, d := range []string{`{"chapter":1,"con`, `tent":"`, `第三章`, `\n\n夜幕低垂`, `","mode":"write"}`} {
result += e.Feed(d)
}
if want := "第三章\n\n夜幕低垂"; result != want {
t.Errorf("got %q, want %q", result, want)
}
}
func TestFieldExtractor_JSONEscape(t *testing.T) {
e := newFieldExtractor("content")
got := e.Feed(`{"content":"line1\nline2\t\"quoted\"\\end"}`)
if want := "line1\nline2\t\"quoted\"\\end"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFieldExtractor_NoTargetField(t *testing.T) {
e := newFieldExtractor("content")
got := e.Feed(`{"chapter":1,"summary":"test","characters":["A"]}`)
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestFieldExtractor_ColonWithSpace(t *testing.T) {
e := newFieldExtractor("content")
got := e.Feed(`{"content" : "spaced"}`)
if got != "spaced" {
t.Errorf("got %q, want %q", got, "spaced")
}
}
func TestFieldExtractor_Reset(t *testing.T) {
e := newFieldExtractor("content")
e.Feed(`{"content":"partial`)
e.Reset()
got := e.Feed(`{"content":"fresh"}`)
if got != "fresh" {
t.Errorf("got %q, want %q", got, "fresh")
}
}
func TestFieldExtractor_TaskField(t *testing.T) {
e := newFieldExtractor("task")
got := e.Feed(`{"agent":"writer","task":"写第1章。核心事件林尘目睹斗法"}`)
if want := "写第1章。核心事件林尘目睹斗法"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFieldExtractor_TaskCrossDelta(t *testing.T) {
e := newFieldExtractor("task")
var result string
for _, d := range []string{`{"agent":"writer","ta`, `sk":"写第`, `1章"}`, `extra`} {
result += e.Feed(d)
}
if want := "写第1章"; result != want {
t.Errorf("got %q, want %q", result, want)
}
}
func TestFieldExtractor_Chinese(t *testing.T) {
e := newFieldExtractor("content")
var result string
for _, d := range []string{`{"content":"`, `林远站在窗前,`, `望着远处的山峦。`, `"}`} {
result += e.Feed(d)
}
if want := "林远站在窗前,望着远处的山峦。"; result != want {
t.Errorf("got %q, want %q", result, want)
}
}
// --- streamFilter tests ---
func TestStreamFilter_TextPassthrough(t *testing.T) {
f := newStreamFilter("content")
got := f.Feed("好的,我来加载上下文信息。")
if want := ThinkingSep + "好的,我来加载上下文信息。"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestStreamFilter_JSONExtractContent(t *testing.T) {
f := newStreamFilter("content")
got := f.Feed(`{"chapter":1,"content":"第一章 晨曦","mode":"write"}`)
if want := "第一章 晨曦"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestStreamFilter_JSONNoContent(t *testing.T) {
f := newStreamFilter("content")
got := f.Feed(`{"chapter":1,"title":"暗流","goal":"揭示线索"}`)
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestStreamFilter_TextThenJSON(t *testing.T) {
f := newStreamFilter("content")
var result string
result += f.Feed("规划完成,开始写作。")
result += f.Feed(`{"chapter":1,"content":"正文","mode":"write"}`)
if want := ThinkingSep + "规划完成,开始写作。正文"; result != want {
t.Errorf("got %q, want %q", result, want)
}
}
func TestStreamFilter_JSONThenText(t *testing.T) {
f := newStreamFilter("content")
var result string
result += f.Feed(`{"chapter":1,"summary":"摘要"}`)
result += f.Feed("提交完成。")
if want := ThinkingSep + "提交完成。"; result != want {
t.Errorf("got %q, want %q", result, want)
}
}
func TestStreamFilter_CrossDeltaMixed(t *testing.T) {
f := newStreamFilter("content")
var result string
deltas := []string{
"好的,开始",
"写作。",
`{"chapter":1`,
`,"content":"`,
"第一章",
"\n\n正文",
`","mode":"write"}`,
"已写入。",
}
for _, d := range deltas {
result += f.Feed(d)
}
want := ThinkingSep + "好的,开始写作。第一章\n\n正文" + ThinkingSep + "已写入。"
if result != want {
t.Errorf("got %q, want %q", result, want)
}
}
func TestStreamFilter_NestedBraces(t *testing.T) {
f := newStreamFilter("content")
got := f.Feed(`{"summary":"摘要","foreshadow":[{"type":"plant"}]}`)
if got != "" {
t.Errorf("got %q, want empty (nested JSON should be fully consumed)", got)
}
}
func TestStreamFilter_BracesInString(t *testing.T) {
f := newStreamFilter("content")
got := f.Feed(`{"content":"文中有{大括号}和\"引号\""}后续文本`)
want := "文中有{大括号}和\"引号\"" + ThinkingSep + "后续文本"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestStreamFilter_Reset(t *testing.T) {
f := newStreamFilter("content")
f.Feed(`{"content":"半截`)
f.Reset()
got := f.Feed("重新开始")
if want := ThinkingSep + "重新开始"; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestStreamFilter_ThinkingMarkerOnce(t *testing.T) {
// 连续文本只在段首插入一次标记
f := newStreamFilter("content")
var result string
result += f.Feed("好的")
result += f.Feed(",继续")
if want := ThinkingSep + "好的,继续"; result != want {
t.Errorf("got %q, want %q", result, want)
}
}

View File

@@ -2,31 +2,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 根据已完成章节数判断是否需要全局审阅。
// ShouldReview 根据已完成章节数判断是否需要全局审阅(短篇/中篇模式)
func ShouldReview(completedCount int) (bool, string) {
if completedCount > 0 && completedCount%ReviewInterval == 0 {
return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount)
}
return false, ""
}
// ShouldArcReview 长篇模式下判断是否需要弧级/卷级评审。
func ShouldArcReview(isArcEnd, isVolumeEnd bool, volume, arc int) (bool, string) {
if isVolumeEnd {
return true, fmt.Sprintf("第 %d 卷第 %d 弧结束(卷结束),触发弧级+卷级评审", volume, arc)
}
if isArcEnd {
return true, fmt.Sprintf("第 %d 卷第 %d 弧结束,触发弧级评审", volume, arc)
}
return false, ""
}
// WordCount 按 rune 计算字数。
func WordCount(content string) int {
return utf8.RuneCountInString(content)
}

View File

@@ -34,18 +34,49 @@ type RelationshipEntry struct {
// ConsistencyIssue 一致性问题。
type ConsistencyIssue struct {
Type string `json:"type"` // timeline / foreshadow / relationship / character
Severity string `json:"severity"` // error / warning
Type string `json:"type"` // consistency / character / pacing / continuity / foreshadow / hook
Severity string `json:"severity"` // critical / error / warning
Description string `json:"description"`
Suggestion string `json:"suggestion,omitempty"`
}
// DimensionScore 单维度评审评分。
type DimensionScore struct {
Dimension string `json:"dimension"` // consistency / character / pacing / continuity / foreshadow / hook
Score int `json:"score"` // 0-100
Verdict string `json:"verdict"` // pass / warning / fail
Comment string `json:"comment,omitempty"` // 该维度的简要结论
}
// ReviewEntry Editor 的审阅条目。
type ReviewEntry struct {
Chapter int `json:"chapter"`
Scope string `json:"scope"` // chapter / global
Scope string `json:"scope"` // chapter / global / arc
Issues []ConsistencyIssue `json:"issues"`
Verdict string `json:"verdict"` // accept / polish / rewrite
Dimensions []DimensionScore `json:"dimensions,omitempty"` // 分维度评分
Verdict string `json:"verdict"` // accept / polish / rewrite
Summary string `json:"summary"`
AffectedChapters []int `json:"affected_chapters,omitempty"` // 需要重写/打磨的章节号
}
// CriticalCount 返回 critical 级别问题数量。
func (r *ReviewEntry) CriticalCount() int {
n := 0
for _, issue := range r.Issues {
if issue.Severity == "critical" {
n++
}
}
return n
}
// ErrorCount 返回 error 级别问题数量。
func (r *ReviewEntry) ErrorCount() int {
n := 0
for _, issue := range r.Issues {
if issue.Severity == "error" {
n++
}
}
return n
}

View File

@@ -22,6 +22,15 @@ const (
FlowSteering FlowState = "steering"
)
// PlanningTier 表示作品规划的长度级别。
type PlanningTier string
const (
PlanningTierShort PlanningTier = "short"
PlanningTierMid PlanningTier = "mid"
PlanningTierLong PlanningTier = "long"
)
// Progress 进度追踪,持久化到 meta/progress.json。
type Progress struct {
NovelName string `json:"novel_name"`
@@ -34,10 +43,14 @@ type Progress struct {
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"` // 重写原因
StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand
HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type
PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列
RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因
StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand
HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type
// 长篇分层追踪(仅长篇模式使用,短篇/中篇为零值)
CurrentVolume int `json:"current_volume,omitempty"`
CurrentArc int `json:"current_arc,omitempty"`
Layered bool `json:"layered,omitempty"`
}
// IsResumable 判断是否可以从断点恢复。
@@ -59,11 +72,32 @@ func (p *Progress) NextChapter() int {
return max + 1
}
// ContextProfile 上下文加载策略,根据总章节数自适应。
type ContextProfile struct {
SummaryWindow int // 加载最近 N 章摘要
TimelineWindow int // 加载最近 N 章时间线
Layered bool // true = 启用分层摘要加载(卷摘要+弧摘要+章摘要)
}
// NewContextProfile 根据总章节数计算上下文策略。
func NewContextProfile(totalChapters int) ContextProfile {
switch {
case totalChapters <= 15:
return ContextProfile{SummaryWindow: 10, TimelineWindow: 10}
case totalChapters <= 50:
return ContextProfile{SummaryWindow: 5, TimelineWindow: 8}
default:
return ContextProfile{SummaryWindow: 3, TimelineWindow: 5, Layered: true}
}
}
// RunMeta 运行元信息,持久化到 meta/run.json。
type RunMeta struct {
StartedAt string `json:"started_at"`
Provider string `json:"provider,omitempty"`
Style string `json:"style"`
Model string `json:"model"`
PlanningTier PlanningTier `json:"planning_tier,omitempty"`
SteerHistory []SteerEntry `json:"steer_history,omitempty"`
PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入
}

View File

@@ -18,6 +18,7 @@ type OutlineEntry struct {
// Character 角色档案。
type Character struct {
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"` // 别名/称号/绰号(如"废物少年"、"炎哥"
Role string `json:"role"`
Description string `json:"description"`
Arc string `json:"arc"`
@@ -25,6 +26,49 @@ type Character struct {
Tier string `json:"tier,omitempty"` // core / important / secondary / decorative默认 important
}
// VolumeOutline 卷级大纲(长篇分层模式)。
type VolumeOutline struct {
Index int `json:"index"`
Title string `json:"title"`
Theme string `json:"theme"` // 本卷核心冲突/主题
Arcs []ArcOutline `json:"arcs"`
}
// ArcOutline 弧级大纲。
type ArcOutline struct {
Index int `json:"index"` // 卷内弧序号
Title string `json:"title"`
Goal string `json:"goal"` // 弧目标(起承转合)
Chapters []OutlineEntry `json:"chapters"`
}
// TotalChapters 计算分层大纲的总章节数。
func TotalChapters(volumes []VolumeOutline) int {
n := 0
for _, v := range volumes {
for _, a := range v.Arcs {
n += len(a.Chapters)
}
}
return n
}
// FlattenOutline 将分层大纲展开为扁平章节列表,保持全局章节号连续。
func FlattenOutline(volumes []VolumeOutline) []OutlineEntry {
var result []OutlineEntry
ch := 1
for _, v := range volumes {
for _, a := range v.Arcs {
for _, e := range a.Chapters {
e.Chapter = ch
result = append(result, e)
ch++
}
}
}
return result
}
// WorldRule 世界观规则条目。
type WorldRule struct {
Category string `json:"category"` // magic / technology / geography / society / other

11
domain/tracking.go Normal file
View File

@@ -0,0 +1,11 @@
package domain
// StateChange 角色/实体状态变化记录。
type StateChange struct {
Chapter int `json:"chapter"`
Entity string `json:"entity"` // 角色名或实体名
Field string `json:"field"` // 变化属性realm/location/status/power/relation 等
OldValue string `json:"old_value,omitempty"` // 变化前(首次出现可空)
NewValue string `json:"new_value"` // 变化后
Reason string `json:"reason,omitempty"` // 变化原因
}

View File

@@ -1,30 +1,15 @@
package domain
// ChapterPlan 章节规划,写入 drafts/{ch}.plan.json
// ChapterPlan 章节写作构思Writer 自主生成
// 不再强制场景拆分Agent 自己决定如何组织内容。
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"`
Chapter int `json:"chapter"`
Title string `json:"title"`
Goal string `json:"goal"`
Conflict string `json:"conflict"`
Hook string `json:"hook"`
EmotionArc string `json:"emotion_arc,omitempty"`
Notes string `json:"notes,omitempty"` // Agent 的自由备忘
}
// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。
@@ -35,16 +20,55 @@ type ChapterSummary struct {
KeyEvents []string `json:"key_events"`
}
// ArcSummary 弧级摘要,弧结束时由 Editor 生成。
type ArcSummary struct {
Volume int `json:"volume"`
Arc int `json:"arc"`
Title string `json:"title"`
Summary string `json:"summary"`
KeyEvents []string `json:"key_events"`
}
// VolumeSummary 卷级摘要,卷结束时生成。
type VolumeSummary struct {
Volume int `json:"volume"`
Title string `json:"title"`
Summary string `json:"summary"`
KeyEvents []string `json:"key_events"`
}
// CharacterSnapshot 角色状态快照,弧边界时记录。
type CharacterSnapshot struct {
Volume int `json:"volume"`
Arc int `json:"arc"`
Name string `json:"name"`
Status string `json:"status"`
Power string `json:"power,omitempty"`
Motivation string `json:"motivation"`
Relations string `json:"relations,omitempty"`
}
// OutlineFeedback Writer 对大纲的反馈,提交章节时可选。
type OutlineFeedback struct {
Deviation string `json:"deviation"` // 偏离描述
Suggestion string `json:"suggestion"` // 调整建议
}
// 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"`
HookType string `json:"hook_type,omitempty"` // 钩子类型crisis/mystery/desire/emotion/choice
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线quest/fire/constellation
Chapter int `json:"chapter"`
Committed bool `json:"committed"`
WordCount int `json:"word_count"`
NextChapter int `json:"next_chapter"`
ReviewRequired bool `json:"review_required"`
ReviewReason string `json:"review_reason,omitempty"`
HookType string `json:"hook_type,omitempty"`
DominantStrand string `json:"dominant_strand,omitempty"`
Feedback *OutlineFeedback `json:"feedback,omitempty"`
// 长篇分层信号
ArcEnd bool `json:"arc_end,omitempty"`
VolumeEnd bool `json:"volume_end,omitempty"`
Volume int `json:"volume,omitempty"`
Arc int `json:"arc,omitempty"`
}

8
go.mod
View File

@@ -1,12 +1,13 @@
module github.com/voocel/ainovel-cli
go 1.25.5
go 1.25
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/voocel/agentcore v1.5.1
github.com/voocel/agentcore v1.5.3
github.com/voocel/litellm v1.6.3
)
require (
@@ -28,8 +29,9 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/voocel/litellm v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
replace github.com/voocel/agentcore => ../agentcore

8
go.sum
View File

@@ -44,10 +44,10 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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=
github.com/voocel/litellm v1.6.2 h1:TJ1s7B7UqgV86O1EcuwQTZua0FK1tbOg0+oUsDmgmuA=
github.com/voocel/litellm v1.6.2/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4=
github.com/voocel/litellm v1.6.3 h1:FKHx+XQbXCZVvjnnMk2kuJ5dyXuXa8j5MVStWL7NaQs=
github.com/voocel/litellm v1.6.3/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=

213
main.go
View File

@@ -1,9 +1,12 @@
package main
import (
"bufio"
"embed"
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"github.com/voocel/ainovel-cli/app"
@@ -21,6 +24,12 @@ var referencesFS embed.FS
var stylesFS embed.FS
func main() {
// 处理 start 子命令
if len(os.Args) > 1 && os.Args[1] == "start" {
handleStartCommand()
return
}
style := envOr("NOVEL_STYLE", "default")
refs := loadReferences(style)
prompts := loadPrompts()
@@ -62,11 +71,11 @@ func buildConfig(style string) app.Config {
}
cfg := app.Config{
NovelName: "novel",
NovelName: envOr("NOVEL_NAME", ""),
Provider: provider,
APIKey: apiKey,
BaseURL: baseURL,
ModelName: "stepfun/step-3.5-flash:free",
ModelName: envOr("LLM_MODEL", "stepfun/step-3.5-flash:free"),
Style: style,
}
return cfg
@@ -76,9 +85,58 @@ func parsePrompt() string {
if len(os.Args) < 2 {
return ""
}
if os.Args[1] == "-help" || os.Args[1] == "--help" || os.Args[1] == "-h" {
printHelp()
os.Exit(0)
}
return strings.Join(os.Args[1:], " ")
}
func printHelp() {
fmt.Println(`ainovel-cli - AI 小说生成工具
用法:
ainovel-cli start 交互式开始:选择小说 → 选择风格 → 启动 TUI
ainovel-cli [prompt] CLI 模式:直接生成小说
ainovel-cli TUI 模式:启动交互界面
环境变量:
NOVEL_NAME 小说名称默认novel
NOVEL_STYLE 小说风格默认default
可选值:
default 通用风格,叙事张弛有度,五感描写,对话自然
fantasy 奇幻冒险,世界观自然展开,魔法体系有代价感
romance 言情,情感递进有节奏,关系张力与内心描写并重
suspense 悬疑推理,多线叙事,信息差悬念,线索管理严谨
LLM_PROVIDER LLM 提供商openrouter|anthropic|gemini默认openrouter
LLM_MODEL 模型名称默认stepfun/step-3.5-flash:free
Z_OPENAI_API_KEY API 密钥
Z_OPENAI_BASE_URL API 地址
ANTHROPIC_API_KEY Anthropic API 密钥
ANTHROPIC_BASE_URL Anthropic API 地址
GEMINI_API_KEY Gemini API 密钥
GEMINI_BASE_URL Gemini API 地址
OPENROUTER_API_KEY OpenRouter API 密钥
OPENROUTER_BASE_URL OpenRouter API 地址
示例:
ainovel-cli "写一部科幻小说,主角是时间旅行者"
NOVEL_NAME=时空之旅 ainovel-cli "写一部科幻小说"
ainovel-cli # 启动 TUI 交互模式
tmux 使用(推荐后台运行):
tmux new -s novel # 新建名为 novel 的会话
ainovel-cli "写一部科幻小说" # 在会话中运行,关闭终端不中断
Ctrl+b d # 脱离会话(程序继续跑)
tmux attach -t novel # 随时回来查看进度
tmux ls # 列出所有会话
tmux kill-session -t novel # 结束会话`)
}
func loadReferences(style string) tools.References {
refs := tools.References{
ChapterGuide: mustRead(referencesFS, "references/chapter-guide.md"),
@@ -90,6 +148,8 @@ func loadReferences(style string) tools.References {
Consistency: mustRead(referencesFS, "references/consistency.md"),
ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"),
DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.md"),
LongformPlanning: mustRead(referencesFS, "references/longform-planning.md"),
Differentiation: mustRead(referencesFS, "references/differentiation.md"),
}
if style != "" && style != "default" {
path := "references/" + style + "/style-references.md"
@@ -102,10 +162,12 @@ func loadReferences(style string) tools.References {
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"),
Coordinator: mustRead(promptsFS, "prompts/coordinator.md"),
ArchitectShort: mustRead(promptsFS, "prompts/architect-short.md"),
ArchitectMid: mustRead(promptsFS, "prompts/architect-mid.md"),
ArchitectLong: mustRead(promptsFS, "prompts/architect-long.md"),
Writer: mustRead(promptsFS, "prompts/writer.md"),
Editor: mustRead(promptsFS, "prompts/editor.md"),
}
}
@@ -143,3 +205,142 @@ func envOr(key, fallback string) string {
}
return fallback
}
// handleStartCommand 处理 start 子命令:交互式选择小说和风格
func handleStartCommand() {
// Step 1: 选择小说
novelName := selectNovel()
if novelName == "" {
fmt.Println("已取消")
os.Exit(0)
}
// Step 2: 选择风格
style := selectStyle()
if style == "" {
fmt.Println("已取消")
os.Exit(0)
}
// 设置环境变量并启动 TUI
os.Setenv("NOVEL_NAME", novelName)
os.Setenv("NOVEL_STYLE", style)
refs := loadReferences(style)
prompts := loadPrompts()
styles := loadStyles()
cfg := buildConfig(style)
cfg.NovelName = novelName
if err := tui.Run(cfg, refs, prompts, styles); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
// selectNovel 显示小说选择菜单
func selectNovel() string {
options := []string{"📝 创建新小说"}
// 扫描 output 目录下现有小说
outputDir := "output"
entries, err := ioutil.ReadDir(outputDir)
novels := []string{}
if err == nil {
for _, e := range entries {
if e.IsDir() && e.Name() != "." && e.Name() != ".." {
novels = append(novels, e.Name())
options = append(options, "📖 "+e.Name())
}
}
}
if len(options) == 1 {
// 只有创建选项,直接输入新名称
return promptNovelName()
}
// 显示菜单
fmt.Println("\n=== 选择小说或创建新的 ===")
for i, opt := range options {
fmt.Printf("%d. %s\n", i+1, opt)
}
choice := promptChoice(len(options))
if choice < 0 {
return ""
}
if choice == 0 {
// 创建新小说
return promptNovelName()
}
// 返回现有小说名称
return novels[choice-1]
}
// selectStyle 显示风格选择菜单
func selectStyle() string {
styles := []struct {
name string
desc string
}{
{"default", "通用风格,叙事张弛有度,五感描写,对话自然"},
{"fantasy", "奇幻冒险,世界观自然展开,魔法体系有代价感"},
{"romance", "言情,情感递进有节奏,关系张力与内心描写并重"},
{"suspense", "悬疑推理,多线叙事,信息差悬念,线索管理严谨"},
}
fmt.Println("\n=== 选择小说风格 ===")
for i, s := range styles {
fmt.Printf("%d. %-10s %s\n", i+1, s.name, s.desc)
}
choice := promptChoice(len(styles))
if choice < 0 {
// EOF 或错误,使用默认风格
fmt.Println("使用默认风格...")
return "default"
}
return styles[choice].name
}
// promptNovelName 提示输入新小说名称
func promptNovelName() string {
reader := bufio.NewReader(os.Stdin)
fmt.Print("\n请输入小说名称 (默认: novel): ")
name, err := reader.ReadString('\n')
if err != nil {
return "novel"
}
name = strings.TrimSpace(name)
if name == "" {
return "novel"
}
return name
}
// promptChoice 提示用户选择菜单项
func promptChoice(maxChoice int) int {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("\n请选择 (输入数字): ")
input, err := reader.ReadString('\n')
if err != nil {
// EOF 或其他错误,返回 -1 表示取消
return -1
}
input = strings.TrimSpace(input)
if input == "" {
continue
}
choice, err := strconv.Atoi(input)
if err != nil || choice < 1 || choice > maxChoice {
fmt.Printf("❌ 请输入 1 到 %d 之间的数字\n", maxChoice)
continue
}
return choice - 1 // 转换为 0-based 索引
}
}

118
prompts/architect-long.md Normal file
View File

@@ -0,0 +1,118 @@
你是长篇规划师。你负责把用户需求规划成一个可长期展开、可持续升级、可分卷分弧推进的连载型故事。
## 你的工具
- **novel_context**: 获取参考模板和当前状态
- **save_foundation**: 保存基础设定
## 适用范围
适用于这些情况:
- 题材天然适合长期升级或长期连载
- 世界观、势力、关系、身份、谜团可以持续扩展
- 故事存在多个阶段性目标和多个中后期转向
- 适合 80 章以上,或明显需要卷弧结构
长篇规划默认使用 layered_outline。不要把长篇压缩成短篇式十几章梗概。
## 工作流程
### 1. 获取模板
先调用 novel_context不传 chapter 参数)获取:
- outline_template
- character_template
- longform_planning
- differentiation
- style_reference如有
### 2. 生成 Premise
基于用户需求撰写故事前提Markdown 格式),至少包含:
- 题材和基调
- 核心冲突
- 主角目标
- 结局方向
- 写作禁区
- 差异化卖点(至少 3 条)
- 故事引擎:外部推进与内部推进分别是什么
- 升级路径:前期、中期、后期靠什么升级
- 中期转向:前期方法何时失效,故事如何换挡
- 终局命题:后期真正要回答的最终问题
调用 save_foundation(type="premise", scale="long", content=<Markdown文本字符串>)
### 3. 生成 Layered Outline
长篇默认使用分层结构,生成 JSON 格式的 layered_outline
-Volume阶段主题、阶段升级、阶段代价
-Arc局部目标、局部阻力、阶段转折
-Chapter章节标题、核心事件、钩子、要点
调用 save_foundation(type="layered_outline", scale="long", content=<JSON数组>)
注意:`content` 对于 layered_outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
要求:
- **若任务中包含明确章节数要求(如”【硬约束】用户明确要求写 N 章”),生成大纲时必须严格按照指定总章节数生成,不多不少,章节数为最高优先级约束,卷弧结构需服务于这个总章节数**
- 前 3 卷必须各自承担不同功能,而不是重复”升级打怪换地图”
- 每卷都必须回答:新增了什么、失去了什么、关系如何变化、为何必须进入下一卷
- 每弧都必须有明确目标、阻力、转折和结果
- 每章都必须服务于当前弧目标
- 中期必须有结构转向,后期必须有终局级命题
- 钩子类型要多样化,避免全靠“发现秘密”
### 4. 生成 Characters
基于 premise 和 layered_outline 生成角色档案JSON 格式),每个角色包含:
- name
- aliases
- role
- description
- arc
- traits
要求:
- 主要角色必须与长期故事引擎有关
- 角色弧线要能跨卷演化
- 重要配角不能只是阶段性工具人
- 关系线必须具备长期张力,而不是只服务某一章剧情
调用 save_foundation(type="characters", scale="long", content=<JSON数组>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category
- rule
- boundary
要求:
- 规则必须会持续影响剧情决策
- 特别注意资源、代价、限制、秩序、势力边界
- 规则要能支撑中后期升级,而不是只服务前几章
调用 save_foundation(type="world_rules", scale="long", content=<JSON数组>)
## 增量修改模式
当任务中提到“增量修改”时:
1. 先调用 novel_context 获取当前 premise、outline、layered_outline、characters、world_rules
2. 保持已完成章节的一致性
3. 保持卷弧结构稳定,避免修改后退化成短篇式节奏
4. 若需调整长期规划,优先调整未展开卷弧
## 注意事项
- 长篇的核心是可持续展开,而不是简单变长
- 不要过早透支所有高潮和谜底
- 不要把同一种爽点反复复制到每一卷
- 不要让中后期只是前期的放大版
- **你必须按顺序完成全部 4 步premise → layered_outline → characters → world_rules全部保存后才算完成。每次 save_foundation 返回值中的 `remaining` 字段会告诉你还有哪些未完成,不要在 remaining 非空时停止。**

113
prompts/architect-mid.md Normal file
View File

@@ -0,0 +1,113 @@
你是中篇规划师。你负责把用户需求规划成一个多阶段推进、篇幅受控、能够稳定展开但不过度膨胀的故事。
## 你的工具
- **novel_context**: 获取参考模板和当前状态
- **save_foundation**: 保存基础设定
## 适用范围
适用于这些情况:
- 有阶段性升级,但不需要超长连载
- 有 2-4 条重要支线或关系线
- 存在明显的中段转折与后段收束
- 适合 25-60 章
如果题材明显具备长期世界扩张、长期升级、长期关系博弈、多卷结构,优先交给长篇规划师。
## 工作流程
### 1. 获取模板
先调用 novel_context不传 chapter 参数)获取:
- outline_template
- character_template
- longform_planning
- differentiation
- style_reference如有
### 2. 生成 Premise
基于用户需求撰写故事前提Markdown 格式),至少包含:
- 题材和基调
- 核心冲突
- 主角目标
- 结局方向
- 写作禁区
- 差异化卖点(至少 2-3 条)
- 故事引擎:中篇靠什么持续推进
- 中段转折:故事在哪个阶段会发生结构变化
调用 save_foundation(type="premise", scale="mid", content=<Markdown文本字符串>)
### 3. 生成 Outline
中篇默认使用扁平 outline只有当阶段差异很强、用户明确要求更强结构时才考虑用 layered_outline。
生成章节大纲JSON 格式),每章包含:
- chapter
- title
- core_event
- hook
- scenes3-5 个要点,描述本章的关键段落和事件)
要求:
- **若任务中包含明确章节数要求(如"【硬约束】用户明确要求写 N 章"),生成大纲时必须严格按照指定章节数生成,不多不少,章节数为最高优先级约束**
- 至少划分出 3 个阶段:建立、升级、收束
- 每个阶段的主问题要有区别
- 中段必须出现一次改变后续推进方式的转折
- 支线不能游离,必须服务主线或人物关系变化
调用 save_foundation(type="outline", scale="mid", content=<JSON数组>)
注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
### 4. 生成 Characters
基于 premise 和 outline 生成角色档案JSON 格式),每个角色包含:
- name
- aliases
- role
- description
- arc
- traits
要求:
- 主要角色要承担不同功能
- 角色弧线要跨越多个阶段,而不是一章完成
- 配角要能反向影响主线
调用 save_foundation(type="characters", scale="mid", content=<JSON数组>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category
- rule
- boundary
要求:
- 规则必须制造选择或代价
- 不能只是背景百科
调用 save_foundation(type="world_rules", scale="mid", content=<JSON数组>)
## 增量修改模式
当任务中提到“增量修改”时:
1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules
2. 保持已完成章节的一致性
3. 保持中篇节奏,不要因为补设定而破坏阶段推进
## 注意事项
- 中篇的关键是阶段推进和平衡
- 不要像短篇那样过度压缩
- 也不要像长篇那样预留过多远期空间
- **你必须按顺序完成全部 4 步premise → outline → characters → world_rules全部保存后才算完成。每次 save_foundation 返回值中的 `remaining` 字段会告诉你还有哪些未完成,不要在 remaining 非空时停止。**

111
prompts/architect-short.md Normal file
View File

@@ -0,0 +1,111 @@
你是短篇规划师。你负责把用户需求规划成一个高密度、强收束、单卷完成的故事。
## 你的工具
- **novel_context**: 获取参考模板和当前状态
- **save_foundation**: 保存基础设定
## 适用范围
只适用于这些情况:
- 单冲突、单目标、单段关键关系
- 单案、单任务、单次危机、单次恋爱推进
- 故事高潮和结局集中在一个阶段完成
- 适合 8-25 章内收束
如果需求明显具备长期升级空间、持续展开世界、长期关系张力或多阶段主矛盾,不要用短篇思路硬压。
## 工作流程
### 1. 获取模板
先调用 novel_context不传 chapter 参数)获取:
- outline_template
- character_template
- differentiation
- style_reference如有
### 2. 生成 Premise
基于用户需求撰写故事前提Markdown 格式),至少包含:
- 题材和基调
- 核心冲突
- 主角目标
- 结局方向
- 写作禁区
- 差异化卖点(至少 2 条)
- 本作为什么适合短篇/单卷收束
调用 save_foundation(type="premise", scale="short", content=<Markdown文本字符串>)
### 3. 生成 Outline
短篇一律使用扁平 outline不使用 layered_outline。
生成章节大纲JSON 格式),每章包含:
- chapter
- title
- core_event
- hook
- scenes3-5 个要点,描述本章的关键段落和事件)
要求:
- **若任务中包含明确章节数要求(如"【硬约束】用户明确要求写 N 章"),生成大纲时必须严格按照指定章节数生成,不多不少,章节数为最高优先级约束**
- 每章都必须推动主冲突
- 不允许“中期再慢慢展开”的拖延式设计
- 配角数量控制在必要范围
- 世界规则只保留会直接影响剧情的部分
- 结局必须回收核心承诺
调用 save_foundation(type="outline", scale="short", content=<JSON数组>)
注意:`content` 对于 outline / characters / world_rules 直接传 JSON 数组,不要再手动包成转义字符串。
### 4. 生成 Characters
基于 premise 和 outline 生成角色档案JSON 格式),每个角色包含:
- name
- aliases
- role
- description
- arc
- traits
要求:
- 角色功能必须清晰,避免冗余
- 主要角色弧线要在单卷内完成
调用 save_foundation(type="characters", scale="short", content=<JSON数组>)
### 5. 生成 World Rules
基于 premise 和世界观设定生成世界规则JSON 格式),每条规则包含:
- category
- rule
- boundary
要求:
- 只保留必要规则,避免为短篇过度设计世界
- 规则必须直接服务当前冲突
调用 save_foundation(type="world_rules", scale="short", content=<JSON数组>)
## 增量修改模式
当任务中提到“增量修改”时:
1. 先调用 novel_context 获取当前 premise、outline、characters、world_rules
2. 保持已完成章节的一致性
3. 保持短篇结构的紧凑性,不要越改越膨胀
## 注意事项
- 短篇最重要的是集中与收束
- 不要预埋大量未来再说的线
- 不要把短篇写成”长篇开头”
- **你必须按顺序完成全部 4 步premise → outline → characters → world_rules全部保存后才算完成。每次 save_foundation 返回值中的 `remaining` 字段会告诉你还有哪些未完成,不要在 remaining 非空时停止。**

View File

@@ -1,106 +0,0 @@
你是小说世界构建师。你负责从用户需求出发,构建小说的基础设定。
## 你的工具
- **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 个场景
- 角色弧线要有变化,不要扁平
- 钩子要制造悬念,吸引读者继续阅读

View File

@@ -1,21 +1,87 @@
你是一个长篇小说创作总协调者。你通过调度子 Agent 完成整本小说的创作。
你是一个小说创作总协调者。你通过调度子 Agent 完成整本小说的创作。
你的职责不是追求最快开写,而是先选对规划策略,再进入写作。现在有三种不同长度级别的规划师:
- **architect_short**:短篇/单卷故事8-25 章,高密度、强收束
- **architect_mid**:中篇/多阶段故事25-60 章,阶段推进、平衡展开
- **architect_long**:长篇/连载型故事80 章以上或明显需要分卷分弧,强调持续升级与卷弧结构
## 你的工具
- **subagent**: 调度 architect、writer 和 editor 子 Agent
- **subagent**: 调度 architect_short、architect_mid、architect_long、writer 和 editor 子 Agent
- **novel_context**: 检查当前创作状态
- **ask_user**: 当需求信息不足,且缺失信息会明显影响规划方向时,向用户补充询问 1-3 个关键问题。返回的是可直接使用的中文摘要,例如:`用户回答:[篇幅] 长篇;[重心] 剧情升级`
## 工作流程
### 第一阶段:基础设定
### 第一阶段:选择合适的规划师并生成基础设定
调用 architect 完成基础设定:
如果用户需求已经足够明确,就直接判断并开始规划,不要为了形式感额外提问。
如果用户需求过于稀薄,导致你无法稳定判断作品规模、核心方向或关键卖点,先调用 `ask_user` 做最少必要的澄清,再进入规划。典型场景包括:
- 只有一个题材词或一句非常短的描述,例如“凡人修仙”“都市悬疑”
- 没有说明更偏短篇、中篇还是长篇连载
- 没有说明主角路线、核心冲突、基调偏向,而这些信息会显著影响大纲方向
提问约束:
- 每次只问 1-3 个最关键问题
- 优先问会改变规划方向的问题,不要问细枝末节
- 能自己合理推断的,不要问用户
- 用户回答后,再选择对应的规划师
- `ask_user` 的问题必须是结构化选择题header 简短清楚,选项之间要有明确区分
- 优先询问:篇幅预期、剧情重心、主角路线、必须避免的元素、基调偏好
- 不要询问:你已经可以从题材常识中合理补全的基础信息
- 不要连续多轮追问;一轮问完后先进入规划
- 用户如果给出明确偏好,应把这些偏好视为更高优先级约束
使用原则:
- `ask_user` 是补足关键信息的工具,不是把规划责任转交给用户
- 你的目标是“最少提问后就能稳定规划”,不是收集尽可能多的设定
- 对于“凡人修仙”“都市悬疑”“校园恋爱”这类过短输入,如果你发现不同理解会导向完全不同的大纲,应优先先问再规划
- 对于已经明确给出篇幅、主角、冲突、风格的输入,不要再问,直接进入规划
在第一次规划前,你必须先判断用户需求更适合哪一种长度级别:
- **短篇**:单冲突、单案、单任务、单段关键关系、结局集中
- **中篇**:有阶段性升级、几条重要支线、需要中段转折,但不需要超长连载
- **长篇**:题材具备持续升级空间、可扩展世界、长期关系张力、多阶段目标、多卷推进
选择规则:
**第一优先级:用户明确指定的章节数。** 在判断题材之前,先从用户输入中提取明确的章节数。中文数字和阿拉伯数字均需识别(如"十章"="10章"=10章"二十章"=20章"一百章"=100章。如果提取到了明确章节数 N
- N ≤ 25 → 使用 `architect_short`
- 26 ≤ N ≤ 60 → 使用 `architect_mid`
- N > 60 → 使用 `architect_long`
此时章节数为硬约束,必须在传给架构师的 task 中追加:`\n\n【硬约束】用户明确要求写 N 章,请生成恰好 N 章的大纲,不多不少。`
**第二优先级:题材判断。** 仅当用户没有指定明确章节数时,才按以下规则判断:
- 只要题材明显适合长期展开,优先使用 `architect_long`
- 只有当需求明显更像单卷故事时,才使用 `architect_short`
- 不确定时,优先 `architect_mid`,但对连载型商业题材宁可偏长,不要误压成短篇
如果经过 `ask_user` 用户明确表达了篇幅或连载预期(包括明确章节数),同样优先遵从用户选择,按上述章节数规则映射。
调用对应规划师完成基础设定:
```json
{"agent": "architect", "task": "根据以下需求生成小说基础设定premiseoutlinecharacters\n\n<用户需求>"}
{"agent": "architect_short", "task": "根据以下需求生成短篇/单卷小说基础设定premise + outline + characters + world_rules全部保存后才算完成。\\n\\n<用户需求>"}
```
architect 完成后,用 novel_context 确认设定已保存。
```json
{"agent": "architect_mid", "task": "根据以下需求生成中篇/多阶段小说基础设定premise + outline + characters + world_rules全部保存后才算完成。\\n\\n<用户需求>"}
```
```json
{"agent": "architect_long", "task": "根据以下需求生成长篇/连载型小说基础设定premise + layered_outline + characters + world_rules全部保存后才算完成。\\n\\n<用户需求>"}
```
规划完成后,用 novel_context不传 chapter确认设定已保存。**必须检查返回值中的 `foundation_status.ready` 为 true 且 `foundation_status.missing` 为空**。如果有缺失项,重新调用对应规划师补全缺失部分,不要跳过直接写作。
### 第二阶段:逐章写作
@@ -40,17 +106,10 @@ architect 完成后,用 novel_context 确认设定已保存。
收到 `[系统] Editor 审阅结论` 消息后,按 verdict 处理:
- **accept**: 继续写下一章
- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨。每次调用:
```json
{"agent": "writer", "task": "打磨第 N 章。审阅意见:<summary>"}
```
- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写。每次调用:
```json
{"agent": "writer", "task": "重写第 N 章。重写原因:<summary>"}
```
重写完成后回到正常写作流程,继续写下一个未完成章节
- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨
- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写
**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。中断退出后重启会自动恢复到重写状态。
**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。
### 系统消息
@@ -59,7 +118,7 @@ architect 完成后,用 novel_context 确认设定已保存。
- **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer
- **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅
你必须遵守系统消息中的确定性指令(如"不要再调用 writer"
你必须遵守系统消息中的确定性指令。
### 第四阶段:完成
@@ -73,26 +132,41 @@ architect 完成后,用 novel_context 确认设定已保存。
收到 `[用户干预]` 消息后:
1. **评估影响范围**:判断用户的修改要求影响哪些内容
2. **更新设定**(如需要):调用 architect 更新 premise、outline 或 characters
```json
{"agent": "architect", "task": "用户要求修改:<干预内容>。请在现有设定基础上做增量修改,保持已完成章节的一致性。"}
```
3. **重写章节**(如需要):如果已完成章节受到影响,逐章调用 writer 重写
4. **继续写作**:从下一个未完成章节继续
1. 评估影响范围
2. 如需更新设定,调用与当前作品长度级别一致的规划师进行增量修改
3. 如需重写已完成章节,逐章调用 writer 重写
4. 从下一个未完成章节继续
如果当前作品已经采用 layered_outline不要在修改时退化成短篇式 outline 思路。
### Writer 大纲反馈
收到 `[系统] Writer 在第 N 章写作中发现大纲偏离` 消息后:
1. 评估反馈是否合理(角色变得更有魅力?支线更有趣?大纲走向不对?)
2. 如果认为值得采纳,调用对应级别的规划师进行增量修改
3. 如果认为不需要调整,忽略并继续
4. 不要因为 Writer 的一次反馈就大幅推翻已有规划
## 恢复指示
- 收到"从第 N 章继续写作"的指示:跳过第一阶段,直接从第 N 章开始逐章写作
- 收到"第 N 章正在进行中,已完成 M 个场景"的指示:调用 writer 从场景 M+1 继续该章写作
- 收到"有 N 章待重写"的指示:逐章调用 writer 重写/打磨受影响章节,**全部完成后**才能继续写新章节
- 收到"上次审阅中断"的指示:重新调用 editor 进行全局审阅
- 收到从第 N 章继续写作的指示:跳过第一阶段,直接从第 N 章开始逐章写作
- 收到第 N 章正在进行中的指示:调用 writer 继续完成该章writer 可用 read_chapter 读取已有草稿)
- 收到有 N 章待重写的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
- 收到上次审阅中断的指示:重新调用 editor 进行全局审阅
## 注意事项
## 长篇模式(分层大纲)
- 不要自己写正文,正文由 writer 完成
- 不要自己创建设定,设定由 architect 完成
- 不要自己做审阅,审阅由 editor 完成
- 你的职责是调度和决策,不是创作
- 章节完成/全书终止的判断由宿主程序通过系统消息控制
- 重写章节时writer 的流程与新写相同,旧文件会自动覆盖
当系统消息包含“弧结束”或“卷结束”信号时,执行以下工作流:
### 弧结束处理
收到 `[系统] 第 V 卷第 A 弧结束` 消息后:
1. 调用 editor 进行弧级评审
2. 调用 editor 生成弧摘要和角色快照
3. 继续写下一弧的章节
### 卷结束处理
收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后:
1. 先完成弧结束处理
2. 额外调用 editor 生成卷摘要
3. 继续写下一卷的章节

View File

@@ -1,75 +1,125 @@
你是小说全局审阅者。你负责发现跨章和全局结构问题,不直接修改正文
你是小说全局审阅者。你负责阅读原文,从结构和审美两个层面发现问题
## 你的工具
- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系)
- **novel_context**: 获取小说的完整状态(设定、大纲、角色、时间线、伏笔、关系、状态变化
- **read_chapter**: 读取章节原文(你必须读原文才能审阅,不能只看摘要)
- **save_review**: 保存审阅结果
- **save_arc_summary**: 保存弧摘要和角色快照(长篇模式)
- **save_volume_summary**: 保存卷摘要(长篇模式)
## 工作流程
### 1. 获取上下文
调用 novel_context(chapter=最新章节号),获取全部状态数据。
### 2. 六维结构化审
### 2. 阅读原文
**必须**调用 read_chapter 读取要审阅的章节原文。不能只看摘要就下结论。
对于全局审阅,至少读最近 3-5 章的原文。
逐维度检查,每个维度必须给出结论(通过/存在问题)和具体问题列表:
### 3. 七维结构化审阅
#### 维度一:设定一致性
- 事件发生顺序是否与时间线矛盾
- 时间跨度是否自洽
逐维度检查,每个维度必须给出**评分0-100**和结论pass/warning/fail
#### 维度一设定一致性consistency
- 事件顺序是否与时间线矛盾
- 世界规则边界是否被违反
- 角色属性(能力、外貌、身份)是否前后矛盾
- 角色属性是否前后矛盾
- 角色状态描述是否与 state_changes 记录一致
- 注意角色别名,同一人不同称呼不要误判
#### 维度二:人设一致性
- 角色行为是否符合性格设定和弧线
#### 维度二:人设一致性character
- 角色行为是否符合性格设定和弧线
- 对话风格是否与角色身份匹配
- 角色动机是否合理连贯
#### 维度三:节奏平衡
- 是否连续多章同一类型(纯打斗、纯对话、纯描写)
- 主线是否持续推进,有无原地踏步
- 情感节奏是否有张有弛
- 如果有 strand_history 数据,检查 quest/fire/constellation 三线分布是否失衡
#### 维度三:节奏平衡pacing
- 是否连续多章同一类型
- 主线是否持续推进
- strand_history / hook_history 分布是否失衡
#### 维度四:叙事连贯
- 场景之间过渡是否自然
#### 维度四:叙事连贯continuity
- 场景过渡是否自然
- 因果逻辑是否通顺
- 信息传递是否一致角色A不应知道只有角色B知道的事
- 信息传递是否一致
#### 维度五:伏笔健康
- 是否有超过 5 章未推进的伏笔(遗忘风险)
#### 维度五:伏笔健康foreshadow
- 是否有超过 5 章未推进的伏笔
- 新伏笔是否有回收方向
- 已回收伏笔的解决是否令人满意
#### 维度六:钩子质量
#### 维度六:钩子质量hook
- 章末钩子是否有足够吸引力
- 如果有 hook_history 数据,检查是否连续使用同一类型钩子
- 是否连续使用同一类型钩子
- 钩子是否与主线推进方向一致
### 3. 输出审阅
#### 维度七审美品质aesthetic— 新增
审阅原文的文学品质,**必须引用原文**来证明问题:
- **画面感**:描写是否有具象画面,还是流于抽象概述?
引用缺乏画面感的段落,给出改进方向
- **对话区分度**:不同角色说话是否能区分?
引用说话方式雷同的对话,指出问题
- **AI 痕迹**:是否有"不禁""竟然""仿佛"等滥用词、排比三连、四字成语堆砌?
引用具体句子
- **情感打动力**:是否有让读者心跳加速或产生共鸣的段落?
如果整章平淡如水,指出最该加强的位置
### 4. 输出审阅
调用 save_review给出
- issues发现的具体问题列表每个问题包含
- type问题维度consistency/character/pacing/continuity/foreshadow/hook
- severityerror 或 warning
- description具体问题描述
- **dimensions**:七个维度的评分
- dimension维度名consistency/character/pacing/continuity/foreshadow/hook/aesthetic
- score0-100 分
- verdictpass≥80/ warning60-79/ fail<60
- comment简要结论aesthetic 维度必须引用原文
- **issues**发现的具体问题列表
- type问题维度
- severitycritical / error / warning
- description具体问题描述aesthetic 类问题必须引用原文
- suggestion修改建议
- verdict审阅结论
- `accept`:所有维度通过或仅有 warning 级问题,可以继续写
- `polish`:存在细节问题,建议对特定章节做打磨
- `rewrite`:存在 error 级结构性问题,建议重写特定章节
- summary审阅总结200字以内按维度概括
- affected_chapters需要重写或打磨的章节号列表verdict 为 polish/rewrite 时必填)
- **verdict**审阅结论accept/polish/rewrite
- **summary**审阅总结200字以内
- **affected_chapters**需要修改的章节号列表
### severity 分级标准
| 级别 | 定义 | 示例 |
|------|------|------|
| **critical** | 逻辑硬伤必须修复 | 角色已死再次出场违反世界规则核心边界 |
| **error** | 明显矛盾或品质问题 | 角色行为严重不符人设整章 AI 味浓重 |
| **warning** | 轻微瑕疵 | 细节不够精确个别句子可打磨 |
### 判定标准
- 任一维度出现 error 级问题 → verdict 至少为 polish
- 多个维度出现 error 级问题 → verdict 应为 rewrite
- 只有 warning 级问题 → verdict 为 accept
- 没有发现问题 → verdict 为 accept
- 存在 critical verdict 必须为 rewrite
- critical 但有 error verdict 至少为 polish
- 只有 warning 或无问题 accept
## 弧级评审模式(长篇)
当任务提到"弧级评审"
- scope 设为 "arc"
- 额外关注弧内起承转合弧目标达成与前续弧衔接
- 完成审阅后调用 save_arc_summary 保存弧摘要和角色快照
### save_arc_summary 参数
- volume/arc卷号弧号
- title弧标题
- summary弧摘要500字以内
- key_events弧内关键事件
- character_snapshots主要角色当前状态快照
## 卷级评审模式(长篇)
当任务提到"卷摘要"调用 save_volume_summary
## 注意事项
- 不要自己修改正文
- 不要输出空洞的表扬只关注问题
- severity=error 的问题必须修复severity=warning 的可以后续处理
- 如果没有发现问题verdict 应为 accept
- critical 绝不放过
- **审美维度的问题必须引用原文**不接受空泛的"文笔还需提升"

View File

@@ -1,81 +1,81 @@
你是小说场景写作者。你负责逐场景地完成一章的创作
你是小说作者。你负责自主完成一章的构思、写作、自审和提交
## 你的工具
- **novel_context**: 获取当前章节的创作上下文
- **plan_chapter**: 创建章节写作规划
- **write_scene**: 写入单个场景
- **polish_chapter**: 保存打磨后的完整章节正文
- **check_consistency**: 检查章节与全局状态的一致性
- **novel_context**: 获取当前章节的创作上下文(设定、前情、角色、伏笔、时间线)
- **read_chapter**: 回读任意章节原文、草稿,或提取角色对话片段
- **plan_chapter**: 保存你的章节构思
- **draft_chapter**: 写入章节正文(整章或续写)
- **check_consistency**: 加载状态数据,供你对照检查一致性
- **commit_chapter**: 提交完成的章节
## 写作流水线
## 你的自主权
严格按以下顺序执行,不可跳步
你可以按任何顺序使用工具,只要最终提交一章高质量的正文。以下是建议流程,但不是强制流程
### 1. 获取上下文
调用 novel_context(chapter=N) 获取:
- 故事前提、大纲、角色档案
- 前几章摘要
- 时间线、伏笔账本、人物关系(用于保持一致性)
- 写作参考资料
### 建议流程
### 2. 规划章节
调用 plan_chapter基于大纲拆分为 3-5 个场景,明确每个场景的目标、视角和地点。
1. **读上下文** — 调用 novel_context(chapter=N) 了解前情、大纲、角色、伏笔
2. **回读前文** — 调用 read_chapter 读前一章结尾(找回语气和节奏),读关键角色的对话片段(保持声音一致)
3. **构思** — 在脑中(或 plan_chapter梳理本章的目标、冲突、情绪弧线、钩子
4. **写作** — 调用 draft_chapter 写入整章正文
5. **自审** — 回读自己的草稿read_chapter source=draft对照 check_consistency 的状态数据,检查一致性和质量
6. **修改** — 如果不满意,再次调用 draft_chapter(mode=write) 覆盖
7. **提交** — 调用 commit_chapter
### 3. 逐场景写作
对每个场景依次调用 write_scene。
你可以跳过任何步骤,也可以重复任何步骤。关键是:**写出好的正文**。
**场景写作要求**
- 每个场景 800-1500 字
- 第一个场景的前 20% 必须出现冲突或悬念
- 以具体的动作、对话或感官描写开场,不要用抽象描述
- 对话要体现人物性格,避免说教式对白
## 写作标准
### 开头致命
- 前 20% 必须出现冲突或悬念
- 以动作、对话或感官描写开场,不用抽象描述
- 绝对避免:天气开场、日常流程、回顾上章、缓慢铺垫
### 对话真实
- 每句对话必须有目的:推动情节、揭示人物、制造冲突
- 不同角色说话方式不同(用 read_chapter 提取的对话片段找回角色声音)
- 有潜台词和动作穿插,不说教
### 描写具象
- 用五感描写替代抽象概述
- 用身体反应替代情绪标签(不写"他很愤怒",写"他握紧拳头,指节发白"
- 用细节和动作推动情节,不用概述和总结
- 场景之间自然过渡
### 4. 打磨章节
将所有场景合并,进行整体打磨,然后调用 polish_chapter 保存:
- **去 AI 味**:不用"不禁"、"竟然"、"仿佛"等滥用词,不用排比三连,控制形容词密度
- **对话自然化**:体现人物性格差异,加入潜台词和动作穿插
- **细节具象化**:用五感描写替代抽象概述
- **节奏调整**:关键转折放慢,过渡段落紧凑
### 去 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
## 注意事项
- 严格场景级写作,一次只写一个场景
- 不要整章一起写然后拆分
### 节奏
- 关键转折放慢,过渡段落紧凑
- 章内有紧张-缓解-新紧张的呼吸感
- 章末必须有悬念钩子
- 保持与前几章的连贯性
## 字数要求
- 每章 3000-5000 字
- 字数不够时用具体细节扩展,不用水话填充
- 注意时间线连贯和伏笔管理
## 重写/打磨模式
当任务中包含"重写"或"打磨"指令时:
- 用 read_chapter 读取原文和审阅意见
- 重点修正审阅指出的问题
- 整章重写后 draft_chapter(mode=write) 覆盖
- commit_chapter 会自动修正字数统计
## 大纲反馈
如果写作过程中发现某个角色比预期更有魅力、某条支线比主线更有趣、或大纲的走向不太对,你可以在 commit_chapter 的 feedback 字段中反馈。系统会将你的建议转达给 Coordinator 评估。
## 提交要求
**你必须在完成写作后调用 commit_chapter这是你的核心职责。没有 commit 就等于没有完成任何工作。** draft_chapter 只是保存草稿commit_chapter 才是正式提交。
commit_chapter 时提供:
- summary: 本章内容摘要200字以内
- characters: 本章出场角色名列表(使用正式名)
- key_events: 本章关键事件列表
- timeline_events: 时间线事件
- foreshadow_updates: 伏笔操作plant/advance/resolve
- relationship_changes: 人物关系变化
- state_changes: 角色/实体状态变化
- hook_type / dominant_strand: 钩子类型和主导叙事线
- feedback: 对大纲的反馈(可选)

View File

@@ -3,6 +3,7 @@
## 主角
### [角色一姓名]
- **别名/称号**:(如"废物少年"、"炎哥"、"不灭战神"等,正文中可能用到的各种称呼)
- **年龄/职业**
- **外貌特征**
- **性格核心**

View File

@@ -0,0 +1,86 @@
# 通用差异化设计参考
这份参考用于避免同题材作品自动滑向“最高频模板”。
## 当用户只给出一个大题材词时,不能直接套最常见范式
例如用户说:
- 都市
- 奇幻
- 修仙
- 悬疑
- 言情
- 科幻
这不等于“照该题材最常见的开局写”。你必须先补足差异化维度。
## 差异化的五个维度
### 1. 主角维度
- 出身是否过于常见
- 初始优势/劣势是否过于常见
- 主角最强驱动力是什么
- 主角最大的盲区是什么
### 2. 冲突维度
- 主冲突是否只是该题材默认矛盾
- 有没有第二层冲突改变读者预期
- 冲突是否会在中期转型
### 3. 世界维度
- 世界规则是否真的改变角色行为
- 社会结构、资源结构、权力结构是否能持续制造问题
- 世界是否存在非主角视角也合理运转的逻辑
### 4. 关系维度
- 主要关系是否只有“队友/恋人/敌人”三个静态功能
- 是否存在长期互相塑造、互相伤害、互相利用、互相成全的关系
- 关系线是否会反向推动主线
### 5. 节奏维度
- 爽点是否单一重复
- 是否规划了不同阶段的阅读驱动力
- 前期吸引力和中后期吸引力是否一致,还是有自然升级
## 常见同质化信号
出现越多,越说明作品在滑向通用模板:
- 最常见的主角出身设定
- 最常见的“被看不起”起手
- 最常见的导师/宗门/学院/豪门/案件开场
- 最常见的反派动机
- 最常见的阶段升级节奏
- 最常见的“发现秘密”型钩子反复出现
## 规划时必须主动给自己设限
在同题材下,至少给出 2-3 条反模板约束。例如:
- 不使用最常见的开局身份
- 不使用最常见的金手指/能力来源
- 不使用最常见的中期升级路径
- 不让主要关系线停留在单一功能
- 不让终局只是“打败更大的敌人”
## 差异化不是猎奇,而是重新分配重心
有效的差异化通常来自:
- 更换主角真正关心的东西
- 更换长期冲突的来源
- 更换世界规则的压力点
- 更换关系线在故事中的功能
- 更换中期之后的推进方式
## 输出前自问
- 如果把角色名和设定名抹掉,这个故事还像同题材里另外十本书吗?
- 如果只看前 10 章,读者能说出这本书“独特在哪”吗?
- 如果写到 50 章后,作品的推进方式会不会和前 10 章完全重复?

View File

@@ -0,0 +1,105 @@
# 通用长篇规划参考
这份参考用于“适合长篇连载”的题材,不限定具体品类。
## 长篇不是把短篇拉长
长篇的核心不是章节更多,而是具备长期展开能力。判断一部作品能否写长,关键看它是否具备以下“故事引擎”:
- **目标引擎**:主角会不断追求新的阶段目标
- **世界引擎**:世界规则、势力格局、资源结构可以持续制造新问题
- **关系引擎**:主要人物关系会持续演化,而不是定型后停滞
- **身份引擎**:主角的位置、身份、阵营、责任会变化
- **代价引擎**:每次成长都带来新的约束、损失或风险
如果这几个引擎都很弱,再多章节也只会变成重复灌水。
## 长篇推荐规划顺序
### 1. 作品卖点
先明确:
- 这本书最吸引读者的承诺是什么
- 它和同题材常见写法最不同的点是什么
- 读者为什么愿意跟随主角走到中后期
### 2. 长期冲突
不要只有一个“终极反派”。长篇更适合多阶段冲突:
- 近程冲突:当前生存、当前任务、当前阶段目标
- 中程冲突:势力博弈、关系重组、身份变化
- 远程冲突:世界真相、时代命题、终局选择
### 3. 卷级设计
每一卷至少要有一个明确功能,常见功能包括:
- 立足
- 扩张
- 试错
- 反噬
- 失去
- 转向
- 收束
- 终局
每卷不只升级强度,还要升级问题类型。
### 4. 弧级设计
每一弧都应该像“一个可独立成立的小故事”:
- 有明确目标
- 有明确阻力
- 有阶段转折
- 有结果与代价
### 5. 章节设计
章节不是平均分配事件,而是为弧服务:
- 关键推进章
- 关系变化章
- 代价兑现章
- 误判与反噬章
- 转折章
- 收束与引出下弧章
## 避免长篇同质化
### 错误做法
- 每一卷都只是“换地图 + 换敌人”
- 每次升级都只是“主角更强了”
- 中期仍然重复前期的爽点结构
- 配角只在需要时出现,没有独立动机
- 世界规则只在设定里写,剧情中不产生压力
### 正确做法
- 升级“冲突类型”,不只升级“敌人强度”
- 升级“选择代价”,不只升级“资源规模”
- 升级“关系复杂度”,不只升级“出场人数”
- 升级“命题”,不只升级“舞台大小”
## 中期转向必须提前规划
很多作品前 20 章能写50 章后就开始重复,根因是没有中期转向。
在规划时必须提前想清楚:
- 第一次结构转向发生在什么时候
- 为什么前期方法在中期失效
- 主角到中期后必须学会什么新的思维方式
- 中后期的核心吸引力与前期有什么不同
## 长篇通用检查清单
- 这本书是否具备至少 3 个阶段性主矛盾?
- 前 3 卷是否各自承担不同功能?
- 主角的“得到”和“失去”是否同步增长?
- 主要配角是否会改变主线,而不是只被主角改变?
- 世界规则是否真的限制了剧情决策?
- 中期转向后,作品是否仍然成立?

View File

@@ -1,47 +1,119 @@
# [小说名称] 大纲
# 大纲规划模板
## 基本信息
- **题材**[悬疑/奇幻/言情/科幻等]
- **预计章节数**[10-20] 章
- **目标字数**:每章 3000-5000 字,总计 [X] 万字
- **核心冲突**[主角想要什么?什么阻止了他?]
本模板的作用不是把所有作品都压成固定长度,而是帮助先判断作品级别,再选择大纲粒度。
## TODO List
## 第一步:先判断作品长度级别
### 待创作
- [ ] 第[X]章:[章节标题] - [核心事件]
### 短篇 / 单卷故事
### 进行
- [ ] 第[X]章:[章节标题] - [核心事件]
- 适用:单冲突、单目标、角色少、结局集
- 参考长度8-25 章
- 建议格式:扁平 `outline`
### 已完成
- [x] 第[X]章:[章节标题] - [核心事件][字数]字)
- [x] 第[X]章:[章节标题] - [核心事件][字数]字)
### 中篇 / 多阶段故事
## 章节规划
- 适用:有阶段升级、数条支线、人物关系会变化
- 参考长度25-60 章
- 建议格式:扁平 `outline` 或轻量分层
| 章节 | 标题 | 核心事件 | 悬念钩子 | 字数 | 状态 |
|-----|------|---------|---------|------|------|
| 第1章 | | | | | 待创作 |
| 第2章 | | | | | 待创作 |
### 长篇连载 / 网文型故事
## 全书悬念线
- **主线悬念**[核心谜题]
- **支线悬念**[其他悬念]
- **终极揭秘**[最终答案]
- 适用:题材天然具备持续升级空间、长期关系张力、多个阶段目标、可扩展世界、长期谜团或长期成长线
- 参考长度80-200+ 章
- 建议格式:分层 `layered_outline`
## 字数统计
- 已完成章节数:[0] 章
- 累计字数:[0] 字
- 完成进度:[0]%
## 第二步:判断是否必须使用分层大纲
---
只要满足下面任意 2 条,就优先使用 `layered_outline`
## 章节摘要
- 世界观需要逐步展开,而不是一次性讲完
- 主角成长不是一次跃迁,而是多阶段升级
- 人物关系会在多个阶段持续变化
- 中期和后期存在不同类型的主矛盾
- 需要多次地图/势力/身份/目标切换
- 题材明显更像连载型商业小说,而不是单卷故事
###[X]章:[章节标题]
**摘要**[300-500字概括本章核心内容、重要情节、人物变化、悬念揭示等]
## 第三步:长篇时不要直接做“全书章节流水账”
---
长篇规划顺序建议是:
(后续章节摘要依次追加)
1. 作品卖点与差异化
2. 长期故事引擎
3. 卷级主题与升级
4. 弧级目标与阶段转折
5. 章节级事件与钩子
错误做法:
- 先写 20 章梗概,再强行拉长
- 每卷都重复“遇敌-变强-换地图”
- 只有主线升级,没有关系升级
- 前期把所有大秘密透支完,中后期只能重复套路
## 扁平大纲模板(短/中篇)
```json
[
{
"chapter": 1,
"title": "章节标题",
"core_event": "本章核心事件",
"hook": "章末钩子",
"scenes": ["场景1", "场景2", "场景3"]
}
]
```
## 分层大纲模板(长篇)
```json
[
{
"index": 1,
"title": "第一卷标题",
"theme": "这一卷新增的核心矛盾/主题",
"arcs": [
{
"index": 1,
"title": "第一弧标题",
"goal": "这一弧的局部目标、局部阻力和阶段转折",
"chapters": [
{
"chapter": 1,
"title": "章节标题",
"core_event": "核心事件",
"hook": "章末钩子",
"scenes": ["场景1", "场景2", "场景3"]
}
]
}
]
}
]
```
## 长篇卷级检查清单
每一卷都要回答:
- 这一卷新增了什么世界信息?
- 这一卷升级了什么核心矛盾?
- 这一卷让主角得到什么,也失去什么?
- 这一卷如何改变主要人物关系?
- 这一卷结束后,故事为什么必须进入下一卷?
## 长篇弧级检查清单
每一弧都要回答:
- 这条弧的明确目标是什么?
- 阻力来自谁、什么规则、什么代价?
- 转折点是什么?
- 这条弧结束后,哪些状态发生了不可逆变化?
## 章节级检查清单
- 每章必须服务于所在弧的目标
- 每章必须包含一个不可删除的事件推进
- 钩子要多样化,不要全靠“发现秘密”一种模式
- 前期章节不能只是在“介绍世界”,必须同步推进人物和冲突

BIN
scripts/sample.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -28,6 +28,45 @@ func (s *Store) LoadCharacters() ([]domain.Character, error) {
return chars, nil
}
// SaveCharacterSnapshots 保存角色状态快照到 meta/snapshots/v{vol}a{arc}.json。
func (s *Store) SaveCharacterSnapshots(volume, arc int, snapshots []domain.CharacterSnapshot) error {
return s.writeJSON(fmt.Sprintf("meta/snapshots/v%02da%02d.json", volume, arc), snapshots)
}
// LoadSnapshots 读取指定卷弧的角色快照。
func (s *Store) LoadSnapshots(volume, arc int) ([]domain.CharacterSnapshot, error) {
var snapshots []domain.CharacterSnapshot
if err := s.readJSON(fmt.Sprintf("meta/snapshots/v%02da%02d.json", volume, arc), &snapshots); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return snapshots, nil
}
// LoadLatestSnapshots 加载最近一次角色快照(按卷弧倒序查找)。
// 从分层大纲获取实际卷弧数量,避免盲扫。
func (s *Store) LoadLatestSnapshots() ([]domain.CharacterSnapshot, error) {
volumes, _ := s.LoadLayeredOutline()
if len(volumes) == 0 {
return nil, nil
}
for vi := len(volumes) - 1; vi >= 0; vi-- {
v := volumes[vi]
for ai := len(v.Arcs) - 1; ai >= 0; ai-- {
snaps, err := s.LoadSnapshots(v.Index, v.Arcs[ai].Index)
if err != nil {
return nil, err
}
if len(snaps) > 0 {
return snaps, nil
}
}
}
return nil, nil
}
func renderCharacters(chars []domain.Character) string {
var b strings.Builder
b.WriteString("# 角色档案\n\n")

View File

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

View File

@@ -56,6 +56,168 @@ func (s *Store) GetChapterOutline(chapter int) (*domain.OutlineEntry, error) {
return nil, fmt.Errorf("chapter %d not found in outline", chapter)
}
// SaveLayeredOutline 保存分层大纲(长篇模式)。
// 同时保存 layered_outline.json机器读和 layered_outline.md人读
func (s *Store) SaveLayeredOutline(volumes []domain.VolumeOutline) error {
if err := s.writeJSON("layered_outline.json", volumes); err != nil {
return err
}
return s.writeMarkdown("layered_outline.md", renderLayeredOutline(volumes))
}
// LoadLayeredOutline 读取分层大纲。
func (s *Store) LoadLayeredOutline() ([]domain.VolumeOutline, error) {
var volumes []domain.VolumeOutline
if err := s.readJSON("layered_outline.json", &volumes); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return volumes, nil
}
// ClearLayeredOutline 清理分层大纲文件,供从长篇降级为普通大纲时使用。
func (s *Store) ClearLayeredOutline() error {
return s.withWriteLock(func() error {
if err := s.removeFileUnlocked("layered_outline.json"); err != nil {
return err
}
return s.removeFileUnlocked("layered_outline.md")
})
}
// GetChapterFromLayered 从分层大纲中按全局章节号查找。
func (s *Store) GetChapterFromLayered(chapter int) (*domain.OutlineEntry, error) {
volumes, err := s.LoadLayeredOutline()
if err != nil {
return nil, err
}
ch := 1
for _, v := range volumes {
for _, a := range v.Arcs {
for i := range a.Chapters {
if ch == chapter {
e := a.Chapters[i]
e.Chapter = ch
return &e, nil
}
ch++
}
}
}
return nil, fmt.Errorf("chapter %d not found in layered outline", chapter)
}
// LocateChapter 根据全局章节号定位所在的卷和弧。
func (s *Store) LocateChapter(chapter int) (volume, arc int, err error) {
volumes, err := s.LoadLayeredOutline()
if err != nil {
return 0, 0, err
}
ch := 1
for _, v := range volumes {
for _, a := range v.Arcs {
for range a.Chapters {
if ch == chapter {
return v.Index, a.Index, nil
}
ch++
}
}
}
return 0, 0, fmt.Errorf("chapter %d not found in layered outline", chapter)
}
// ArcBoundary 弧边界信息。
type ArcBoundary struct {
IsArcEnd bool // 是否为弧内最后一章
IsVolumeEnd bool // 是否同时为卷内最后一章
Volume int // 当前章所在卷
Arc int // 当前章所在弧
NextVolume int // 下一章所在卷0 = 全书结束)
NextArc int // 下一章所在弧0 = 全书结束)
}
// CheckArcBoundary 检查某章是否为弧/卷的最后一章。
// 非分层大纲或未找到章节时返回 nil。
func (s *Store) CheckArcBoundary(chapter int) (*ArcBoundary, error) {
volumes, err := s.LoadLayeredOutline()
if err != nil || len(volumes) == 0 {
return nil, err
}
type chapterPos struct {
volume, arc, indexInArc, arcLen int
isLastArc bool
}
// 构建全局章节号 → 位置映射
ch := 1
var cur *chapterPos
var nextVol, nextArc int
for _, v := range volumes {
for ai, a := range v.Arcs {
for ci := range a.Chapters {
if ch == chapter {
cur = &chapterPos{
volume: v.Index,
arc: a.Index,
indexInArc: ci,
arcLen: len(a.Chapters),
isLastArc: ai == len(v.Arcs)-1,
}
} else if cur != nil && nextVol == 0 {
// 紧跟 cur 的下一章
nextVol = v.Index
nextArc = a.Index
}
ch++
}
}
}
if cur == nil {
return nil, nil
}
b := &ArcBoundary{
Volume: cur.volume,
Arc: cur.arc,
NextVolume: nextVol,
NextArc: nextArc,
}
if cur.indexInArc == cur.arcLen-1 {
b.IsArcEnd = true
if cur.isLastArc {
b.IsVolumeEnd = true
}
}
return b, nil
}
func renderLayeredOutline(volumes []domain.VolumeOutline) string {
var b strings.Builder
b.WriteString("# 分层大纲\n\n")
ch := 1
for _, v := range volumes {
fmt.Fprintf(&b, "## 第 %d 卷:%s\n\n", v.Index, v.Title)
fmt.Fprintf(&b, "**主题**%s\n\n", v.Theme)
for _, a := range v.Arcs {
fmt.Fprintf(&b, "### 第 %d 弧:%s\n\n", a.Index, a.Title)
fmt.Fprintf(&b, "**目标**%s\n\n", a.Goal)
for _, e := range a.Chapters {
fmt.Fprintf(&b, "#### 第 %d 章:%s\n\n", ch, e.Title)
fmt.Fprintf(&b, "**核心事件**%s\n\n", e.CoreEvent)
if e.Hook != "" {
fmt.Fprintf(&b, "**钩子**%s\n\n", e.Hook)
}
ch++
}
}
}
return b.String()
}
func renderOutline(entries []domain.OutlineEntry) string {
var b strings.Builder
b.WriteString("# 大纲\n\n")

View File

@@ -10,8 +10,14 @@ import (
// LoadProgress 读取 meta/progress.json。不存在时返回 nil。
func (s *Store) LoadProgress() (*domain.Progress, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.loadProgressUnlocked()
}
func (s *Store) loadProgressUnlocked() (*domain.Progress, error) {
var p domain.Progress
if err := s.readJSON("meta/progress.json", &p); err != nil {
if err := s.readJSONUnlocked("meta/progress.json", &p); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
@@ -22,7 +28,13 @@ func (s *Store) LoadProgress() (*domain.Progress, error) {
// SaveProgress 保存进度到 meta/progress.json。
func (s *Store) SaveProgress(p *domain.Progress) error {
return s.writeJSON("meta/progress.json", p)
s.mu.Lock()
defer s.mu.Unlock()
return s.saveProgressUnlocked(p)
}
func (s *Store) saveProgressUnlocked(p *domain.Progress) error {
return s.writeJSONUnlocked("meta/progress.json", p)
}
// InitProgress 创建初始进度。
@@ -36,84 +48,90 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error {
// SetTotalChapters 根据大纲长度设定总章节数。
func (s *Store) SetTotalChapters(n int) error {
p, err := s.LoadProgress()
if err != nil {
return err
}
if p == nil {
p = &domain.Progress{}
}
p.TotalChapters = n
return s.SaveProgress(p)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
p = &domain.Progress{}
}
p.TotalChapters = n
return s.saveProgressUnlocked(p)
})
}
// 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)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
p = &domain.Progress{}
}
p.Phase = phase
return s.saveProgressUnlocked(p)
})
}
// MarkChapterComplete 标记章节完成,原子性更新进度。
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
// hookType 和 dominantStrand 用于节奏追踪,可为空。
func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) 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.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return fmt.Errorf("progress not initialized, call InitProgress first")
}
if p.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
// 节奏追踪:按章节顺序填充 history确保索引对齐
if dominantStrand != "" {
for len(p.StrandHistory) < chapter-1 {
p.StrandHistory = append(p.StrandHistory, "")
// 节奏追踪:按章节顺序填充 history确保索引对齐
if dominantStrand != "" {
for len(p.StrandHistory) < chapter-1 {
p.StrandHistory = append(p.StrandHistory, "")
}
if len(p.StrandHistory) < chapter {
p.StrandHistory = append(p.StrandHistory, dominantStrand)
} else {
p.StrandHistory[chapter-1] = dominantStrand
}
}
if len(p.StrandHistory) < chapter {
p.StrandHistory = append(p.StrandHistory, dominantStrand)
} else {
p.StrandHistory[chapter-1] = dominantStrand
if hookType != "" {
for len(p.HookHistory) < chapter-1 {
p.HookHistory = append(p.HookHistory, "")
}
if len(p.HookHistory) < chapter {
p.HookHistory = append(p.HookHistory, hookType)
} else {
p.HookHistory[chapter-1] = hookType
}
}
}
if hookType != "" {
for len(p.HookHistory) < chapter-1 {
p.HookHistory = append(p.HookHistory, "")
}
if len(p.HookHistory) < chapter {
p.HookHistory = append(p.HookHistory, hookType)
} else {
p.HookHistory[chapter-1] = hookType
}
}
return s.SaveProgress(p)
return s.saveProgressUnlocked(p)
})
}
// MarkComplete 标记全书创作完成。
@@ -139,39 +157,20 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
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 清除场景级进度状态(章节提交后调用)。
// 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)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return nil
}
p.InProgressChapter = 0
p.CompletedScenes = nil
return s.saveProgressUnlocked(p)
})
}
// ClearLastCommit 清除 commit 信号文件,防止重复消费。
@@ -179,70 +178,109 @@ func (s *Store) ClearLastCommit() error {
return s.removeFile("meta/last_commit.json")
}
// UpdateVolumeArc 更新当前卷弧位置。
func (s *Store) UpdateVolumeArc(volume, arc int) error {
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return nil
}
p.CurrentVolume = volume
p.CurrentArc = arc
return s.saveProgressUnlocked(p)
})
}
// SetLayered 设置分层模式标志。
func (s *Store) SetLayered(layered bool) error {
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return nil
}
p.Layered = layered
return s.saveProgressUnlocked(p)
})
}
// 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)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return nil
}
p.Flow = flow
return s.saveProgressUnlocked(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)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return nil
}
p.PendingRewrites = chapters
p.RewriteReason = reason
return s.saveProgressUnlocked(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)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
}
p.PendingRewrites = remaining
if len(remaining) == 0 {
p.Flow = domain.FlowWriting
p.RewriteReason = ""
}
return s.SaveProgress(p)
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.saveProgressUnlocked(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)
return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil {
return err
}
if p == nil {
return nil
}
p.PendingRewrites = nil
p.RewriteReason = ""
p.Flow = domain.FlowWriting
return s.saveProgressUnlocked(p)
})
}
// ValidateChapterCommit 校验当前章节是否允许提交。

View File

@@ -10,13 +10,21 @@ import (
// SaveRunMeta 保存运行元信息到 meta/run.json。
func (s *Store) SaveRunMeta(meta domain.RunMeta) error {
return s.writeJSON("meta/run.json", meta)
s.mu.Lock()
defer s.mu.Unlock()
return s.saveRunMetaUnlocked(meta)
}
// LoadRunMeta 读取运行元信息。
func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.loadRunMetaUnlocked()
}
func (s *Store) loadRunMetaUnlocked() (*domain.RunMeta, error) {
var meta domain.RunMeta
if err := s.readJSON("meta/run.json", &meta); err != nil {
if err := s.readJSONUnlocked("meta/run.json", &meta); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
@@ -25,58 +33,90 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
return &meta, nil
}
func (s *Store) saveRunMetaUnlocked(meta domain.RunMeta) error {
return s.writeJSONUnlocked("meta/run.json", meta)
}
// 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)
func (s *Store) InitRunMeta(style, provider, model string) error {
return s.withWriteLock(func() error {
existing, err := s.loadRunMetaUnlocked()
if err != nil {
return err
}
meta := domain.RunMeta{
StartedAt: time.Now().Format(time.RFC3339),
Provider: provider,
Style: style,
Model: model,
}
if existing != nil {
meta.SteerHistory = existing.SteerHistory
meta.PendingSteer = existing.PendingSteer
meta.PlanningTier = existing.PlanningTier
}
return s.saveRunMetaUnlocked(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)
return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil {
return err
}
if meta == nil {
meta = &domain.RunMeta{}
}
meta.SteerHistory = append(meta.SteerHistory, entry)
return s.saveRunMetaUnlocked(*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)
return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil {
return err
}
if meta == nil {
meta = &domain.RunMeta{}
}
meta.PendingSteer = input
return s.saveRunMetaUnlocked(*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)
return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil {
return err
}
if meta == nil || meta.PendingSteer == "" {
return nil
}
meta.PendingSteer = ""
return s.saveRunMetaUnlocked(*meta)
})
}
// SetPlanningTier 记录当前作品采用的规划级别。
func (s *Store) SetPlanningTier(tier domain.PlanningTier) error {
return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil {
return err
}
if meta == nil {
meta = &domain.RunMeta{}
}
meta.PlanningTier = tier
return s.saveRunMetaUnlocked(*meta)
})
}
// SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。

View File

@@ -1,7 +1,9 @@
package state
import (
"fmt"
"os"
"sync"
"testing"
"github.com/voocel/ainovel-cli/domain"
@@ -13,6 +15,7 @@ func TestSaveAndLoadRunMeta(t *testing.T) {
meta := domain.RunMeta{
StartedAt: "2026-03-07T10:00:00+08:00",
Provider: "openrouter",
Style: "fantasy",
Model: "gpt-4o",
}
@@ -27,6 +30,9 @@ func TestSaveAndLoadRunMeta(t *testing.T) {
if loaded.Style != "fantasy" {
t.Errorf("style mismatch: %s", loaded.Style)
}
if loaded.Provider != "openrouter" {
t.Errorf("provider mismatch: %s", loaded.Provider)
}
if loaded.Model != "gpt-4o" {
t.Errorf("model mismatch: %s", loaded.Model)
}
@@ -73,6 +79,52 @@ func TestAppendSteerEntry(t *testing.T) {
}
}
func TestAppendSteerEntryConcurrent(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
const workers = 32
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
entry := domain.SteerEntry{
Input: fmt.Sprintf("steer-%02d", i),
Timestamp: fmt.Sprintf("ts-%02d", i),
}
if err := store.AppendSteerEntry(entry); err != nil {
t.Errorf("AppendSteerEntry(%d): %v", i, err)
}
}(i)
}
close(start)
wg.Wait()
meta, err := store.LoadRunMeta()
if err != nil {
t.Fatalf("LoadRunMeta: %v", err)
}
if meta == nil {
t.Fatal("expected run meta to exist")
}
if len(meta.SteerHistory) != workers {
t.Fatalf("expected %d steer entries, got %d", workers, len(meta.SteerHistory))
}
seen := make(map[string]struct{}, workers)
for _, entry := range meta.SteerHistory {
seen[entry.Input] = struct{}{}
}
if len(seen) != workers {
t.Fatalf("expected %d unique steer entries, got %d", workers, len(seen))
}
}
func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
@@ -80,6 +132,7 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
// 先保存 RunMeta
_ = store.SaveRunMeta(domain.RunMeta{
StartedAt: "2026-03-07T10:00:00+08:00",
Provider: "openrouter",
Style: "suspense",
Model: "gpt-4o",
})
@@ -91,6 +144,9 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
if meta.Style != "suspense" {
t.Errorf("style should be preserved, got %s", meta.Style)
}
if meta.Provider != "openrouter" {
t.Errorf("provider should be preserved, got %s", meta.Provider)
}
if meta.Model != "gpt-4o" {
t.Errorf("model should be preserved, got %s", meta.Model)
}
@@ -106,6 +162,7 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) {
// 先建立带历史的 RunMeta
_ = store.SaveRunMeta(domain.RunMeta{
StartedAt: "old",
Provider: "openai",
Style: "fantasy",
Model: "old-model",
SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}},
@@ -113,12 +170,15 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) {
})
// InitRunMeta 应保留 SteerHistory 和 PendingSteer
_ = store.InitRunMeta("suspense", "new-model")
_ = store.InitRunMeta("suspense", "openrouter", "new-model")
meta, _ := store.LoadRunMeta()
if meta.Style != "suspense" {
t.Errorf("style should be updated, got %s", meta.Style)
}
if meta.Provider != "openrouter" {
t.Errorf("provider should be updated, got %s", meta.Provider)
}
if meta.Model != "new-model" {
t.Errorf("model should be updated, got %s", meta.Model)
}
@@ -153,6 +213,26 @@ func TestSetAndClearPendingSteer(t *testing.T) {
}
}
func TestSetPlanningTier(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
if err := store.SetPlanningTier(domain.PlanningTierLong); err != nil {
t.Fatalf("SetPlanningTier: %v", err)
}
meta, err := store.LoadRunMeta()
if err != nil {
t.Fatalf("LoadRunMeta: %v", err)
}
if meta == nil {
t.Fatal("expected run meta to exist")
}
if meta.PlanningTier != domain.PlanningTierLong {
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierLong, meta.PlanningTier)
}
}
func TestClearPendingSteer_Noop(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)

42
state/state_changes.go Normal file
View File

@@ -0,0 +1,42 @@
package state
import (
"os"
"github.com/voocel/ainovel-cli/domain"
)
// AppendStateChanges 追加角色状态变化到 meta/state_changes.json。
func (s *Store) AppendStateChanges(changes []domain.StateChange) error {
existing, _ := s.LoadStateChanges()
existing = append(existing, changes...)
return s.writeJSON("meta/state_changes.json", existing)
}
// LoadStateChanges 读取全部状态变化记录。
func (s *Store) LoadStateChanges() ([]domain.StateChange, error) {
var changes []domain.StateChange
if err := s.readJSON("meta/state_changes.json", &changes); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return changes, nil
}
// LoadRecentStateChanges 加载指定章节之前最近 count 章的状态变化。
func (s *Store) LoadRecentStateChanges(currentChapter, count int) ([]domain.StateChange, error) {
all, err := s.LoadStateChanges()
if err != nil {
return nil, err
}
start := max(currentChapter-count, 1)
var result []domain.StateChange
for _, c := range all {
if c.Chapter >= start && c.Chapter < currentChapter {
result = append(result, c)
}
}
return result, nil
}

View File

@@ -5,11 +5,13 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
)
// Store 封装小说输出目录,提供所有状态读写操作。
type Store struct {
dir string
mu sync.RWMutex
}
// NewStore 创建状态管理器dir 为小说输出根目录。
@@ -36,19 +38,61 @@ func (s *Store) path(rel string) string {
}
func (s *Store) readFile(rel string) ([]byte, error) {
return os.ReadFile(s.path(rel))
s.mu.RLock()
defer s.mu.RUnlock()
return s.readFileUnlocked(rel)
}
func (s *Store) writeFile(rel string, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeFileUnlocked(rel, data)
}
func (s *Store) readFileUnlocked(rel string) ([]byte, error) {
return os.ReadFile(s.path(rel))
}
func (s *Store) writeFileUnlocked(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)
tmp, err := os.CreateTemp(filepath.Dir(p), filepath.Base(p)+".tmp-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() {
_ = os.Remove(tmpPath)
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(0o644); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, p)
}
func (s *Store) readJSON(rel string, v any) error {
data, err := s.readFile(rel)
s.mu.RLock()
defer s.mu.RUnlock()
return s.readJSONUnlocked(rel, v)
}
func (s *Store) readJSONUnlocked(rel string, v any) error {
data, err := s.readFileUnlocked(rel)
if err != nil {
return err
}
@@ -56,21 +100,41 @@ func (s *Store) readJSON(rel string, v any) error {
}
func (s *Store) writeJSON(rel string, v any) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeJSONUnlocked(rel, v)
}
func (s *Store) writeJSONUnlocked(rel string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return s.writeFile(rel, data)
return s.writeFileUnlocked(rel, data)
}
func (s *Store) writeMarkdown(rel string, content string) error {
return s.writeFile(rel, []byte(content))
s.mu.Lock()
defer s.mu.Unlock()
return s.writeFileUnlocked(rel, []byte(content))
}
func (s *Store) removeFile(rel string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.removeFileUnlocked(rel)
}
func (s *Store) removeFileUnlocked(rel string) error {
err := os.Remove(s.path(rel))
if os.IsNotExist(err) {
return nil
}
return err
}
func (s *Store) withWriteLock(fn func() error) error {
s.mu.Lock()
defer s.mu.Unlock()
return fn()
}

View File

@@ -39,3 +39,98 @@ func (s *Store) LoadRecentSummaries(current, count int) ([]domain.ChapterSummary
}
return result, nil
}
// LoadAllSummaries 加载 current 章之前的所有摘要(短篇全量模式)。
func (s *Store) LoadAllSummaries(current int) ([]domain.ChapterSummary, error) {
return s.LoadRecentSummaries(current, current)
}
// SaveArcSummary 保存弧级摘要到 summaries/arc-v{vol}a{arc}.json。
func (s *Store) SaveArcSummary(sum domain.ArcSummary) error {
return s.writeJSON(fmt.Sprintf("summaries/arc-v%02da%02d.json", sum.Volume, sum.Arc), sum)
}
// LoadArcSummary 读取指定弧的摘要。
func (s *Store) LoadArcSummary(volume, arc int) (*domain.ArcSummary, error) {
var sum domain.ArcSummary
if err := s.readJSON(fmt.Sprintf("summaries/arc-v%02da%02d.json", volume, arc), &sum); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &sum, nil
}
// LoadArcSummaries 加载一卷内所有已有弧摘要。
// 从分层大纲获取实际弧数,无分层大纲时扫描到首个缺失为止。
func (s *Store) LoadArcSummaries(volume int) ([]domain.ArcSummary, error) {
maxArc := s.arcCountForVolume(volume)
var result []domain.ArcSummary
for arc := 1; arc <= maxArc; arc++ {
sum, err := s.LoadArcSummary(volume, arc)
if err != nil {
return nil, err
}
if sum != nil {
result = append(result, *sum)
}
}
return result, nil
}
// SaveVolumeSummary 保存卷级摘要到 summaries/vol-v{vol}.json。
func (s *Store) SaveVolumeSummary(sum domain.VolumeSummary) error {
return s.writeJSON(fmt.Sprintf("summaries/vol-v%02d.json", sum.Volume), sum)
}
// LoadVolumeSummary 读取指定卷的摘要。
func (s *Store) LoadVolumeSummary(volume int) (*domain.VolumeSummary, error) {
var sum domain.VolumeSummary
if err := s.readJSON(fmt.Sprintf("summaries/vol-v%02d.json", volume), &sum); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &sum, nil
}
// LoadAllVolumeSummaries 加载所有已有卷摘要。
// 从分层大纲获取实际卷数,无分层大纲时扫描到首个缺失为止。
func (s *Store) LoadAllVolumeSummaries() ([]domain.VolumeSummary, error) {
maxVol := s.volumeCount()
var result []domain.VolumeSummary
for vol := 1; vol <= maxVol; vol++ {
sum, err := s.LoadVolumeSummary(vol)
if err != nil {
return nil, err
}
if sum != nil {
result = append(result, *sum)
}
}
return result, nil
}
// volumeCount 从分层大纲获取卷数,无大纲时返回安全上限。
func (s *Store) volumeCount() int {
volumes, err := s.LoadLayeredOutline()
if err == nil && len(volumes) > 0 {
return len(volumes)
}
return 20
}
// arcCountForVolume 从分层大纲获取指定卷的弧数,无大纲时返回安全上限。
func (s *Store) arcCountForVolume(volume int) int {
volumes, err := s.LoadLayeredOutline()
if err == nil {
for _, v := range volumes {
if v.Index == volume {
return len(v.Arcs)
}
}
}
return 20
}

View File

@@ -51,10 +51,10 @@ func (t *AskUserTool) SetHandler(h AskUserHandler) {
t.mu.Unlock()
}
func (t *AskUserTool) Name() string { return "ask_user" }
func (t *AskUserTool) Label() string { return "询问用户" }
func (t *AskUserTool) Name() string { return "ask_user" }
func (t *AskUserTool) Label() string { return "询问用户" }
func (t *AskUserTool) Description() string {
return "向用户提出结构化问题,用于需要用户确认方向、澄清需求或做出选择时。用户可以从预设选项中选择,也可自由输入。"
return "当需求信息不足、且缺失信息会明显影响规划方向时,向用户提出 1-4 个结构化问题。每个问题必须包含 header、question 和 2-4 个选项;用户可选预设项,也可自由补充。返回结果是可直接阅读的中文摘要,格式类似:用户回答:[篇幅] 长篇;[重心] 剧情升级(补充:不要后宫)。只有在无法稳定判断篇幅、主线重心、关键约束或明确偏好时才使用;不要把能自行合理推断的问题都抛给用户。"
}
func (t *AskUserTool) Schema() map[string]any {

View File

@@ -6,12 +6,11 @@ import (
"fmt"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
// CheckConsistencyTool 对照状态文件检查章节一致性
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理
// CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断
// 纯 IO 工具:只负责加载数据,不注入指令
type CheckConsistencyTool struct {
store *state.Store
}
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
func (t *CheckConsistencyTool) Description() string {
return "检查章节一致性。返回章节内容、全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项"
return "加载章节内容和对照数据(世界规则、伏笔、关系、别名、最近摘要),供你检查一致性"
}
func (t *CheckConsistencyTool) Label() string { return "一致性检查" }
@@ -45,7 +44,7 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
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)
@@ -56,70 +55,30 @@ func (t *CheckConsistencyTool) Execute(_ context.Context, args json.RawMessage)
result["content"] = content
result["word_count"] = wordCount
// 加载全部状态数据供 LLM 对照
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 {
result["timeline"] = timeline
// 对照数据:保留全局性的一致性检查数据,避免重复加载 novel_context 已有的窗口数据
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
result["world_rules"] = rules
}
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 {
if foreshadow, _ := t.store.LoadActiveForeshadow(); 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))
aliasMap := make(map[string]string)
for _, c := range chars {
for _, alias := range c.Aliases {
aliasMap[alias] = c.Name
}
}
if len(boundaries) > 0 {
result["world_rules_boundaries"] = boundaries
if len(aliasMap) > 0 {
result["alias_map"] = aliasMap
}
}
// 加载前两章摘要
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
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
@@ -11,7 +12,6 @@ import (
)
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
type CommitChapterTool struct {
store *state.Store
}
@@ -22,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
func (t *CommitChapterTool) Name() string { return "commit_chapter" }
func (t *CommitChapterTool) Description() string {
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号"
return "提交章节终稿。加载草稿正文,保存为终稿,同时更新时间线、伏笔、关系、角色状态。返回结构化信号"
}
func (t *CommitChapterTool) Label() string { return "提交章节" }
@@ -33,7 +33,7 @@ func (t *CommitChapterTool) Schema() map[string]any {
schema.Property("characters", schema.Array("涉及角色", schema.String(""))),
)
foreshadowSchema := schema.Object(
schema.Property("id", schema.String("伏笔 ID(新埋设时自定义,推进/回收时使用已有 ID")).Required(),
schema.Property("id", schema.String("伏笔 ID")).Required(),
schema.Property("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(),
schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")),
)
@@ -42,6 +42,17 @@ func (t *CommitChapterTool) Schema() map[string]any {
schema.Property("character_b", schema.String("角色 B")).Required(),
schema.Property("relation", schema.String("当前关系描述")).Required(),
)
stateChangeSchema := schema.Object(
schema.Property("entity", schema.String("角色名或实体名")).Required(),
schema.Property("field", schema.String("变化属性")).Required(),
schema.Property("old_value", schema.String("变化前的值")),
schema.Property("new_value", schema.String("变化后的值")).Required(),
schema.Property("reason", schema.String("变化原因")),
)
feedbackSchema := schema.Object(
schema.Property("deviation", schema.String("偏离大纲的描述")).Required(),
schema.Property("suggestion", schema.String("对后续大纲的调整建议")).Required(),
)
return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("summary", schema.String("本章内容摘要200字以内")).Required(),
@@ -50,8 +61,10 @@ func (t *CommitChapterTool) Schema() map[string]any {
schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)),
schema.Property("state_changes", schema.Array("角色/实体状态变化", stateChangeSchema)),
schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")),
schema.Property("feedback", feedbackSchema),
)
}
@@ -64,8 +77,10 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
TimelineEvents []domain.TimelineEvent `json:"timeline_events"`
ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"`
RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"`
StateChanges []domain.StateChange `json:"state_changes"`
HookType string `json:"hook_type"`
DominantStrand string `json:"dominant_strand"`
Feedback *domain.OutlineFeedback `json:"feedback"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
@@ -77,7 +92,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
return nil, err
}
// 1. 加载章节正文polished 优先,否则 merge scenes
// 1. 加载章节正文
content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil {
return nil, fmt.Errorf("load chapter content: %w", err)
@@ -124,6 +139,14 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
return nil, fmt.Errorf("update relationships: %w", err)
}
}
if len(a.StateChanges) > 0 {
for i := range a.StateChanges {
a.StateChanges[i].Chapter = a.Chapter
}
if err := t.store.AppendStateChanges(a.StateChanges); err != nil {
return nil, fmt.Errorf("append state changes: %w", err)
}
}
// 5. 更新进度
if err := t.store.MarkChapterComplete(a.Chapter, wordCount, a.HookType, a.DominantStrand); err != nil {
@@ -139,33 +162,54 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js
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)
// 6b. 长篇模式:弧级边界检测
var arcEnd, volumeEnd bool
var vol, arc int
if progress != nil && progress.Layered {
boundary, bErr := t.store.CheckArcBoundary(a.Chapter)
if bErr != nil {
log.Printf("[commit] 弧边界检测失败chapter=%d: %v", a.Chapter, bErr)
} else if boundary != nil {
arcEnd = boundary.IsArcEnd
volumeEnd = boundary.IsVolumeEnd
vol = boundary.Volume
arc = boundary.Arc
_ = t.store.UpdateVolumeArc(vol, arc)
}
}
// 8. 构造结构化信号
var reviewRequired bool
var reviewReason string
if progress != nil && progress.Layered {
reviewRequired, reviewReason = domain.ShouldArcReview(arcEnd, volumeEnd, vol, arc)
} else {
reviewRequired, reviewReason = domain.ShouldReview(completedCount)
}
// 7. 构造结构化信号
result := domain.CommitResult{
Chapter: a.Chapter,
Committed: true,
WordCount: wordCount,
SceneCount: sceneCount,
NextChapter: a.Chapter + 1,
ReviewRequired: reviewRequired,
ReviewReason: reviewReason,
HookType: a.HookType,
DominantStrand: a.DominantStrand,
Feedback: a.Feedback,
ArcEnd: arcEnd,
VolumeEnd: volumeEnd,
Volume: vol,
Arc: arc,
}
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失)
// 8. 写入信号文件
if err := t.store.SaveLastCommit(result); err != nil {
return nil, fmt.Errorf("save commit signal: %w", err)
}
// 10. 清除场景级进度(章节已提交)
// 9. 清除进度中间状态
if err := t.store.ClearInProgress(); err != nil {
return nil, fmt.Errorf("clear in-progress: %w", err)
}

View File

@@ -25,8 +25,8 @@ func TestCommitChapterRejectsNonPendingRewrite(t *testing.T) {
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)
if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewCommitChapterTool(store)
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
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)
if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewCommitChapterTool(store)

82
tools/draft_chapter.go Normal file
View File

@@ -0,0 +1,82 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"unicode/utf8"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/state"
)
// DraftChapterTool 写入整章草稿,替代旧的 write_scene + polish_chapter 流水线。
// Agent 自主决定一次写完还是分批续写。
type DraftChapterTool struct {
store *state.Store
}
func NewDraftChapterTool(store *state.Store) *DraftChapterTool {
return &DraftChapterTool{store: store}
}
func (t *DraftChapterTool) Name() string { return "draft_chapter" }
func (t *DraftChapterTool) Description() string {
return "写入章节正文。mode=write 覆盖写入整章mode=append 追加到现有草稿(续写/修改)"
}
func (t *DraftChapterTool) Label() string { return "写入章节" }
func (t *DraftChapterTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("content", schema.String("章节正文")).Required(),
schema.Property("mode", schema.Enum("写入模式", "write", "append")),
)
}
func (t *DraftChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
Content string `json:"content"`
Mode string `json:"mode"`
}
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")
}
switch a.Mode {
case "append":
if err := t.store.AppendDraft(a.Chapter, a.Content); err != nil {
return nil, fmt.Errorf("append draft: %w", err)
}
// 读取合并后的完整内容计算字数
full, err := t.store.LoadDraft(a.Chapter)
if err != nil {
return nil, fmt.Errorf("load draft after append: %w", err)
}
return json.Marshal(map[string]any{
"written": true,
"chapter": a.Chapter,
"mode": "append",
"word_count": utf8.RuneCountInString(full),
"next_step": "自审后调用 commit_chapter 提交",
})
default: // write
if err := t.store.SaveDraft(a.Chapter, a.Content); err != nil {
return nil, fmt.Errorf("save draft: %w", err)
}
return json.Marshal(map[string]any{
"written": true,
"chapter": a.Chapter,
"mode": "write",
"word_count": utf8.RuneCountInString(a.Content),
"next_step": "自审后调用 commit_chapter 提交",
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/voocel/agentcore/schema"
@@ -25,7 +26,9 @@ type References struct {
ContentExpansion string
DialogueWriting string
// V2
StyleReference string // 风格补充参考(可为空)
StyleReference string // 风格补充参考(可为空)
LongformPlanning string // 通用长篇规划参考
Differentiation string // 通用差异化设计参考
}
// ContextTool 组装当前章节所需上下文。
@@ -60,49 +63,136 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
}
result := make(map[string]any)
var warnings []string
seenWarnings := make(map[string]struct{})
warn := func(scope string, err error) {
if err == nil || os.IsNotExist(err) {
return
}
msg := fmt.Sprintf("%s 读取失败: %v", scope, err)
if _, ok := seenWarnings[msg]; ok {
return
}
seenWarnings[msg] = struct{}{}
warnings = append(warnings, msg)
}
// 加载基础设定
if premise, err := t.store.LoadPremise(); err == nil && premise != "" {
result["premise"] = premise
} else {
warn("premise", err)
}
if outline, err := t.store.LoadOutline(); err == nil && outline != nil {
result["outline"] = outline
} else {
warn("outline", err)
}
if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 {
result["world_rules"] = rules
} else {
warn("world_rules", err)
}
if a.Chapter > 0 {
// 角色按 Tier 过滤core/important 始终返回secondary/decorative 按出场匹配
t.loadFilteredCharacters(result, a.Chapter)
// 根据总章节数计算上下文策略
profile := domain.NewContextProfile(0)
progress, err := t.store.LoadProgress()
warn("progress", err)
runMeta, err := t.store.LoadRunMeta()
warn("run_meta", err)
if runMeta != nil && runMeta.PlanningTier != "" {
result["planning_tier"] = runMeta.PlanningTier
}
if progress != nil && progress.TotalChapters > 0 {
profile = domain.NewContextProfile(progress.TotalChapters)
}
// Layered 以 Progress 的显式标志为准,而非章节数推断
if progress == nil || !progress.Layered {
profile.Layered = false
}
// 角色加载Layered 模式优先用快照,回退到原始设定
if profile.Layered {
t.loadLayeredCharacters(result, a.Chapter, warn)
} else {
t.loadFilteredCharacters(result, a.Chapter, warn)
}
// 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
} else {
warn("current_chapter_outline", err)
}
// V3: 状态数据分级加载
// timeline只取最近 5 章的事件(避免后期全量膨胀)
if timeline, err := t.store.LoadRecentTimeline(a.Chapter, 5); err == nil && len(timeline) > 0 {
result["timeline"] = timeline
// 摘要加载:分层 vs 扁平窗口
if profile.Layered {
t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow, warn)
} else {
if summaries, err := t.store.LoadRecentSummaries(a.Chapter, profile.SummaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
}
}
// foreshadow只取未回收条目已回收的对后续写作无意义
// 时间线:窗口加载
if timeline, err := t.store.LoadRecentTimeline(a.Chapter, profile.TimelineWindow); err == nil && len(timeline) > 0 {
result["timeline"] = timeline
} else {
warn("timeline", err)
}
// foreshadow只取未回收条目
if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow
} else {
warn("foreshadow_ledger", err)
}
// relationships保持全量pair-key 去重,数据量天然可控)
if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 {
result["relationship_state"] = relationships
} else {
warn("relationship_state", err)
}
// 状态变化:最近 2 章
if changes, err := t.store.LoadRecentStateChanges(a.Chapter, 2); err == nil && len(changes) > 0 {
result["recent_state_changes"] = changes
} else {
warn("recent_state_changes", err)
}
// V2: 加载场景级恢复状态 + 节奏追踪
if progress, err := t.store.LoadProgress(); err == nil && progress != nil {
// Layered 模式:注入当前卷弧位置 + 弧目标/卷主题
if profile.Layered && progress != nil {
pos := map[string]any{
"volume": progress.CurrentVolume,
"arc": progress.CurrentArc,
}
if volumes, err := t.store.LoadLayeredOutline(); err == nil {
for _, v := range volumes {
if v.Index == progress.CurrentVolume {
pos["volume_title"] = v.Title
pos["volume_theme"] = v.Theme
for _, arc := range v.Arcs {
if arc.Index == progress.CurrentArc {
pos["arc_title"] = arc.Title
pos["arc_goal"] = arc.Goal
break
}
}
break
}
}
} else {
warn("layered_outline", err)
}
result["position"] = pos
}
// 加载进度状态和节奏追踪
if progress != nil {
checkpoint := map[string]any{
"in_progress_chapter": progress.InProgressChapter,
"completed_scenes": progress.CompletedScenes,
}
if len(progress.StrandHistory) > 0 {
checkpoint["strand_history"] = progress.StrandHistory
@@ -112,35 +202,230 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
}
result["checkpoint"] = checkpoint
}
// V2: 加载已有的章节规划(支持场景恢复跳过已完成场景)
// 加载已有的章节构思
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
result["chapter_plan"] = plan
} else {
warn("chapter_plan", err)
}
// V3: 写作参考资料分阶段加载
// 前章尾部:嵌入前一章末尾 ~800 字Writer 无需额外调用 read_chapter 获取衔接上文
if a.Chapter > 1 {
if prevText, err := t.store.LoadChapterText(a.Chapter - 1); err == nil && prevText != "" {
runes := []rune(prevText)
if len(runes) > 800 {
runes = runes[len(runes)-800:]
}
result["previous_tail"] = string(runes)
}
}
// 风格锚点:从前文提取代表性段落
if anchors := t.store.ExtractStyleAnchors(3); len(anchors) > 0 {
result["style_anchors"] = anchors
}
// 角色声纹:提取出场角色的对话原文片段
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil && entry != nil {
var voiceSamples []map[string]any
chars, _ := t.store.LoadCharacters()
for _, c := range chars {
// 只为 core/important 角色提取声纹
if c.Tier == "secondary" || c.Tier == "decorative" {
continue
}
samples := t.store.ExtractDialogue(c.Name, c.Aliases, 3)
if len(samples) > 0 {
voiceSamples = append(voiceSamples, map[string]any{
"character": c.Name,
"samples": samples,
})
}
if len(voiceSamples) >= 5 {
break
}
}
if len(voiceSamples) > 0 {
result["voice_samples"] = voiceSamples
}
}
// 写作参考资料分阶段加载
result["references"] = t.writerReferences(a.Chapter)
} else {
runMeta, err := t.store.LoadRunMeta()
warn("run_meta", err)
if runMeta != nil && runMeta.PlanningTier != "" {
result["planning_tier"] = runMeta.PlanningTier
}
// Architect 模式:全量角色 + 模板
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
result["characters"] = chars
} else {
warn("characters", err)
}
// Architect 模式下也加载分层大纲(弧级规划需要看全貌)
if layered, err := t.store.LoadLayeredOutline(); err == nil && len(layered) > 0 {
result["layered_outline"] = layered
} else {
warn("layered_outline", err)
}
// 加载已有的弧摘要(弧级规划时需要参考前续弧的内容)
if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 {
result["volume_summaries"] = volSummaries
} else {
warn("volume_summaries", err)
}
result["references"] = t.architectReferences()
// 基础设定完备性检查
result["foundation_status"] = t.foundationStatus()
}
if len(warnings) > 0 {
result["_warnings"] = warnings
}
// 优先级预算:总大小超过阈值时自动裁剪低优先级数据
if a.Chapter > 0 {
trimByBudget(result, 100*1024) // 100KB 预算
}
result["_loading_summary"] = buildLoadingSummary(result, a.Chapter)
return json.Marshal(result)
}
// buildLoadingSummary 从已组装的 result 中统计各项数据量,生成一行可读摘要。
func buildLoadingSummary(result map[string]any, chapter int) string {
var parts []string
if chapter > 0 {
parts = append(parts, fmt.Sprintf("ch=%d", chapter))
} else {
parts = append(parts, "architect")
}
if tier, ok := result["planning_tier"].(domain.PlanningTier); ok && tier != "" {
parts = append(parts, fmt.Sprintf("tier=%s", tier))
}
// 卷弧位置
if pos, ok := result["position"].(map[string]any); ok {
parts = append(parts, fmt.Sprintf("V%dA%d", pos["volume"], pos["arc"]))
}
var items []string
countSlice := func(key string) int {
if v, ok := result[key]; ok {
if s, ok := v.([]domain.Character); ok {
return len(s)
}
// 通用 slice 反射
return sliceLen(v)
}
return 0
}
// 角色
if n := countSlice("character_snapshots"); n > 0 {
items = append(items, fmt.Sprintf("角色:%d(快照)", n))
} else if n := countSlice("characters"); n > 0 {
items = append(items, fmt.Sprintf("角色:%d", n))
}
// 分层摘要
if n := countSlice("volume_summaries"); n > 0 {
items = append(items, fmt.Sprintf("卷摘要:%d", n))
}
if n := countSlice("arc_summaries"); n > 0 {
items = append(items, fmt.Sprintf("弧摘要:%d", n))
}
if n := countSlice("recent_summaries"); n > 0 {
items = append(items, fmt.Sprintf("章摘要:%d", n))
}
// 分层大纲
if n := countSlice("layered_outline"); n > 0 {
items = append(items, fmt.Sprintf("分层大纲:%d卷", n))
}
// 状态数据
if n := countSlice("timeline"); n > 0 {
items = append(items, fmt.Sprintf("时间线:%d", n))
}
if n := countSlice("foreshadow_ledger"); n > 0 {
items = append(items, fmt.Sprintf("伏笔:%d", n))
}
if n := countSlice("relationship_state"); n > 0 {
items = append(items, fmt.Sprintf("关系:%d", n))
}
if n := countSlice("recent_state_changes"); n > 0 {
items = append(items, fmt.Sprintf("状态变化:%d", n))
}
if _, ok := result["previous_tail"]; ok {
items = append(items, "前章尾部:ok")
}
// 参考资料
if refs, ok := result["references"].(map[string]string); ok && len(refs) > 0 {
items = append(items, fmt.Sprintf("参考:%d项", len(refs)))
}
if warnings, ok := result["_warnings"].([]string); ok && len(warnings) > 0 {
items = append(items, fmt.Sprintf("告警:%d", len(warnings)))
}
if trimmed, ok := result["_trimmed"].([]string); ok && len(trimmed) > 0 {
items = append(items, fmt.Sprintf("裁剪:%s", strings.Join(trimmed, ",")))
}
if len(items) > 0 {
parts = append(parts, strings.Join(items, " "))
}
return strings.Join(parts, " | ")
}
// sliceLen 对 any 类型尝试取 slice 长度。
func sliceLen(v any) int {
switch s := v.(type) {
case []domain.ChapterSummary:
return len(s)
case []domain.ArcSummary:
return len(s)
case []domain.VolumeSummary:
return len(s)
case []domain.CharacterSnapshot:
return len(s)
case []domain.TimelineEvent:
return len(s)
case []domain.ForeshadowEntry:
return len(s)
case []domain.RelationshipEntry:
return len(s)
case []domain.StateChange:
return len(s)
case []domain.VolumeOutline:
return len(s)
case []domain.Character:
return len(s)
default:
return 0
}
}
// loadFilteredCharacters 按 Tier 和场景出场过滤角色。
// core/important 始终返回secondary/decorative 只在当前章节大纲提及时返回。
func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int) {
func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int, warn func(string, error)) {
chars, err := t.store.LoadCharacters()
if err != nil || len(chars) == 0 {
if err != nil {
warn("characters", err)
return
}
if len(chars) == 0 {
return
}
// 获取当前章节大纲的场景描述,用于匹配次要角色
entry, err := t.store.GetChapterOutline(chapter)
if err != nil {
warn("current_chapter_outline", err)
result["characters"] = chars
return
}
@@ -150,7 +435,7 @@ func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int)
for _, c := range chars {
switch c.Tier {
case "secondary", "decorative":
if strings.Contains(sceneText, c.Name) {
if matchCharacter(sceneText, c) {
filtered = append(filtered, c)
}
default: // core, important, 或未设置
@@ -160,6 +445,77 @@ func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int)
result["characters"] = filtered
}
// matchCharacter 检查场景文本中是否包含角色的正式名或任一别名。
func matchCharacter(text string, c domain.Character) bool {
if strings.Contains(text, c.Name) {
return true
}
for _, alias := range c.Aliases {
if strings.Contains(text, alias) {
return true
}
}
return false
}
// loadLayeredSummaries 分层摘要加载:卷摘要 + 当前卷弧摘要 + 弧内章摘要。
func (t *ContextTool) loadLayeredSummaries(result map[string]any, chapter, summaryWindow int, warn func(string, error)) {
vol, arc, err := t.store.LocateChapter(chapter)
if err != nil {
warn("layered_outline_position", err)
// 回退到扁平模式
if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
}
return
}
// 1. 已完成卷的卷摘要
if volSummaries, err := t.store.LoadAllVolumeSummaries(); err == nil && len(volSummaries) > 0 {
result["volume_summaries"] = volSummaries
} else {
warn("volume_summaries", err)
}
// 2. 当前卷内已完成弧的弧摘要(不含当前弧)
if arcSummaries, err := t.store.LoadArcSummaries(vol); err == nil && len(arcSummaries) > 0 {
var prior []domain.ArcSummary
for _, s := range arcSummaries {
if s.Arc < arc {
prior = append(prior, s)
}
}
if len(prior) > 0 {
result["arc_summaries"] = prior
}
} else {
warn("arc_summaries", err)
}
// 3. 当前弧内最近 N 章的章摘要
if summaries, err := t.store.LoadRecentSummaries(chapter, summaryWindow); err == nil && len(summaries) > 0 {
result["recent_summaries"] = summaries
} else {
warn("recent_summaries", err)
}
}
// loadLayeredCharacters Layered 模式下的角色加载:优先用最近快照,回退到原始设定 + Tier 过滤。
func (t *ContextTool) loadLayeredCharacters(result map[string]any, chapter int, warn func(string, error)) {
snapshots, err := t.store.LoadLatestSnapshots()
if err == nil && len(snapshots) > 0 {
result["character_snapshots"] = snapshots
// 同时保留原始设定中的 core/important 角色(快照可能不含新登场角色)
t.loadFilteredCharacters(result, chapter, warn)
return
}
warn("character_snapshots", err)
// 无快照时回退到原始设定
t.loadFilteredCharacters(result, chapter, warn)
}
// writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。
func (t *ContextTool) writerReferences(chapter int) map[string]string {
refs := map[string]string{}
@@ -168,15 +524,17 @@ func (t *ContextTool) writerReferences(chapter int) map[string]string {
refs[k] = v
}
}
// 始终加载的核心参考
add("chapter_guide", t.refs.ChapterGuide)
// 渐进式加载:始终保留核心参考,前 3 章额外加载完整写作指南
add("consistency", t.refs.Consistency)
add("hook_techniques", t.refs.HookTechniques)
add("quality_checklist", t.refs.QualityChecklist)
add("consistency", t.refs.Consistency)
add("dialogue_writing", t.refs.DialogueWriting)
add("style_reference", t.refs.StyleReference)
if chapter <= 3 {
add("chapter_guide", t.refs.ChapterGuide)
add("dialogue_writing", t.refs.DialogueWriting)
add("style_reference", t.refs.StyleReference)
}
// 仅首章加载的补充参考(后续章节不再需要)
// 仅首章加载的补充参考
if chapter <= 1 {
add("chapter_template", t.refs.ChapterTemplate)
add("content_expansion", t.refs.ContentExpansion)
@@ -193,9 +551,35 @@ func (t *ContextTool) architectReferences() map[string]string {
}
add("outline_template", t.refs.OutlineTemplate)
add("character_template", t.refs.CharacterTemplate)
add("longform_planning", t.refs.LongformPlanning)
add("differentiation", t.refs.Differentiation)
add("style_reference", t.refs.StyleReference)
return refs
}
// foundationStatus 检查基础设定的完备性,返回缺失项列表。
func (t *ContextTool) foundationStatus() map[string]any {
status := map[string]any{"ready": true}
var missing []string
if p, _ := t.store.LoadPremise(); p == "" {
missing = append(missing, "premise")
}
if o, _ := t.store.LoadOutline(); len(o) == 0 {
missing = append(missing, "outline")
}
if c, _ := t.store.LoadCharacters(); len(c) == 0 {
missing = append(missing, "characters")
}
if r, _ := t.store.LoadWorldRules(); len(r) == 0 {
missing = append(missing, "world_rules")
}
if len(missing) > 0 {
status["ready"] = false
status["missing"] = missing
}
return status
}
// ContextSummary 返回当前状态的简要摘要(供日志使用)。
func (t *ContextTool) ContextSummary() string {
var parts []string
@@ -213,3 +597,43 @@ func (t *ContextTool) ContextSummary() string {
}
return strings.Join(parts, ", ")
}
// trimByBudget 按优先级裁剪 result使 JSON 总大小不超过 budget 字节。
// 优先级从低到高references < voice_samples < style_anchors < previous_tail < timeline
// < recent_state_changes < foreshadow_ledger < relationship_state < 其余(不裁剪)
// 裁剪的 key 会记录到 result["_trimmed"] 供日志排查。
func trimByBudget(result map[string]any, budget int) {
// 先测量当前大小
data, err := json.Marshal(result)
if err != nil || len(data) <= budget {
return
}
// 按优先级从低到高列出可裁剪的 key
trimOrder := []string{
"references",
"voice_samples",
"style_anchors",
"previous_tail",
"timeline",
"recent_state_changes",
"foreshadow_ledger",
"relationship_state",
}
var trimmed []string
for _, key := range trimOrder {
if _, ok := result[key]; !ok {
continue
}
delete(result, key)
trimmed = append(trimmed, key)
data, err = json.Marshal(result)
if err != nil || len(data) <= budget {
break
}
}
if len(trimmed) > 0 {
result["_trimmed"] = trimmed
}
}

View File

@@ -0,0 +1,67 @@
package tools
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/voocel/ainovel-cli/state"
)
func TestContextToolReportsWarningsForCorruptedState(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "outline.json"), []byte("{invalid"), 0o644); err != nil {
t.Fatalf("write outline.json: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "meta", "progress.json"), []byte("{invalid"), 0o644); err != nil {
t.Fatalf("write progress.json: %v", err)
}
tool := NewContextTool(store, References{}, "default")
args, err := json.Marshal(map[string]any{"chapter": 2})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Warnings []string `json:"_warnings"`
Summary string `json:"_loading_summary"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if len(payload.Warnings) == 0 {
t.Fatal("expected context warnings for corrupted files")
}
if !containsWarning(payload.Warnings, "outline") {
t.Fatalf("expected outline warning, got %v", payload.Warnings)
}
if !containsWarning(payload.Warnings, "progress") {
t.Fatalf("expected progress warning, got %v", payload.Warnings)
}
if !strings.Contains(payload.Summary, "告警:") {
t.Fatalf("expected loading summary to contain warning count, got %q", payload.Summary)
}
}
func containsWarning(warnings []string, key string) bool {
for _, warning := range warnings {
if strings.Contains(warning, key) {
return true
}
}
return false
}

View File

@@ -10,7 +10,7 @@ import (
"github.com/voocel/ainovel-cli/state"
)
// PlanChapterTool 生成章节规划
// PlanChapterTool 保存章节构思Agent 自主决定规划粒度
type PlanChapterTool struct {
store *state.Store
}
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
func (t *PlanChapterTool) Name() string { return "plan_chapter" }
func (t *PlanChapterTool) Description() string {
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用"
return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
}
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("情绪曲线")),
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
)
}
@@ -51,17 +45,13 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
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),
"planned": true,
"chapter": plan.Chapter,
})
}

View File

@@ -1,59 +0,0 @@
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),
})
}

116
tools/read_chapter.go Normal file
View File

@@ -0,0 +1,116 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/state"
)
// ReadChapterTool 读取章节原文,让 Agent 能回读自己和前文的文字。
type ReadChapterTool struct {
store *state.Store
}
func NewReadChapterTool(store *state.Store) *ReadChapterTool {
return &ReadChapterTool{store: store}
}
func (t *ReadChapterTool) Name() string { return "read_chapter" }
func (t *ReadChapterTool) Description() string { return "读取章节原文。可读终稿、草稿,或提取角色对话片段" }
func (t *ReadChapterTool) Label() string { return "读取章节" }
func (t *ReadChapterTool) Schema() map[string]any {
return schema.Object(
schema.Property("chapter", schema.Int("章节号(读单章时必填)")),
schema.Property("from", schema.Int("起始章节号(读范围时使用)")),
schema.Property("to", schema.Int("结束章节号(读范围时使用)")),
schema.Property("source", schema.Enum("来源", "final", "draft")).Required(),
schema.Property("character", schema.String("角色名(提取对话片段时使用)")),
schema.Property("max_runes", schema.Int("每章最大字符数(范围读取时截取,默认 2000")),
)
}
func (t *ReadChapterTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Chapter int `json:"chapter"`
From int `json:"from"`
To int `json:"to"`
Source string `json:"source"`
Character string `json:"character"`
MaxRunes int `json:"max_runes"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
// 模式 1提取角色对话
if a.Character != "" {
chars, _ := t.store.LoadCharacters()
var aliases []string
for _, c := range chars {
if c.Name == a.Character {
aliases = c.Aliases
break
}
}
samples := t.store.ExtractDialogue(a.Character, aliases, 8)
return json.Marshal(map[string]any{
"character": a.Character,
"samples": samples,
})
}
// 模式 2范围读取
if a.From > 0 && a.To > 0 {
maxRunes := a.MaxRunes
if maxRunes <= 0 {
maxRunes = 2000
}
texts, err := t.store.LoadChapterRange(a.From, a.To, maxRunes)
if err != nil {
return nil, fmt.Errorf("load chapter range: %w", err)
}
return json.Marshal(map[string]any{
"chapters": texts,
"from": a.From,
"to": a.To,
})
}
// 模式 3单章读取
if a.Chapter <= 0 {
return nil, fmt.Errorf("chapter is required")
}
var content string
var err error
switch a.Source {
case "draft":
content, err = t.store.LoadDraft(a.Chapter)
default: // final
content, err = t.store.LoadChapterText(a.Chapter)
if (err == nil && content == "") {
// 回退到草稿
content, err = t.store.LoadDraft(a.Chapter)
}
}
if err != nil {
return nil, fmt.Errorf("read chapter %d: %w", a.Chapter, err)
}
if content == "" {
return json.Marshal(map[string]any{
"chapter": a.Chapter,
"content": "",
"note": "章节不存在",
})
}
return json.Marshal(map[string]any{
"chapter": a.Chapter,
"content": content,
"word_count": len([]rune(content)),
})
}

215
tools/read_draft_test.go Normal file
View File

@@ -0,0 +1,215 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
func TestReadChapterFinal(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveFinalChapter(1, "第一章的终稿正文。"); err != nil {
t.Fatalf("SaveFinalChapter: %v", err)
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"chapter": 1, "source": "final"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Chapter int `json:"chapter"`
Content string `json:"content"`
WordCount int `json:"word_count"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Content == "" {
t.Fatal("expected non-empty content")
}
if payload.WordCount == 0 {
t.Fatal("expected non-zero word count")
}
}
func TestReadChapterDraft(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveDraft(3, "第三章的草稿内容。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"chapter": 3, "source": "draft"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Content string `json:"content"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Content == "" {
t.Fatal("expected draft content")
}
}
func TestReadChapterDialogue(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveCharacters([]domain.Character{
{Name: "张三", Aliases: []string{"老张"}},
}); err != nil {
t.Fatalf("SaveCharacters: %v", err)
}
if err := store.SaveFinalChapter(1, "张三站起身来。\u201c我不同意这个方案\u201d张三冷冷地说。"); err != nil {
t.Fatalf("SaveFinalChapter: %v", err)
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"source": "final", "character": "张三"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Character string `json:"character"`
Samples []string `json:"samples"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Character != "张三" {
t.Fatalf("expected character 张三, got %s", payload.Character)
}
}
func TestReadChapterRange(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
for i := 1; i <= 3; i++ {
if err := store.SaveFinalChapter(i, "这是一段正文内容。"); err != nil {
t.Fatalf("SaveFinalChapter(%d): %v", i, err)
}
}
tool := NewReadChapterTool(store)
args, _ := json.Marshal(map[string]any{"from": 1, "to": 3, "source": "final"})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Chapters map[string]string `json:"chapters"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if len(payload.Chapters) != 3 {
t.Fatalf("expected 3 chapters, got %d", len(payload.Chapters))
}
}
func TestDraftChapterWrite(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
tool := NewDraftChapterTool(store)
args, _ := json.Marshal(map[string]any{
"chapter": 1,
"content": "这是整章的正文内容,一次写完。",
"mode": "write",
})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Written bool `json:"written"`
WordCount int `json:"word_count"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if !payload.Written {
t.Fatal("expected written=true")
}
if payload.WordCount == 0 {
t.Fatal("expected non-zero word count")
}
// 验证能读回来
content, err := store.LoadDraft(1)
if err != nil {
t.Fatalf("LoadDraft: %v", err)
}
if content == "" {
t.Fatal("expected non-empty draft")
}
}
func TestDraftChapterAppend(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
if err := store.SaveDraft(2, "前半部分。"); err != nil {
t.Fatalf("SaveDraft: %v", err)
}
tool := NewDraftChapterTool(store)
args, _ := json.Marshal(map[string]any{
"chapter": 2,
"content": "后半部分。",
"mode": "append",
})
result, err := tool.Execute(context.Background(), args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var payload struct {
Mode string `json:"mode"`
WordCount int `json:"word_count"`
}
if err := json.Unmarshal(result, &payload); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if payload.Mode != "append" {
t.Fatalf("expected mode=append, got %s", payload.Mode)
}
content, _ := store.LoadDraft(2)
if content == "" || content == "前半部分。" {
t.Fatal("expected appended content")
}
}

86
tools/save_arc_summary.go Normal file
View 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"
)
// SaveArcSummaryTool 保存弧级摘要和角色快照Editor 在弧结束时调用。
type SaveArcSummaryTool struct {
store *state.Store
}
func NewSaveArcSummaryTool(store *state.Store) *SaveArcSummaryTool {
return &SaveArcSummaryTool{store: store}
}
func (t *SaveArcSummaryTool) Name() string { return "save_arc_summary" }
func (t *SaveArcSummaryTool) Description() string { return "保存弧级摘要和角色状态快照(长篇模式,弧结束时调用)" }
func (t *SaveArcSummaryTool) Label() string { return "保存弧摘要" }
func (t *SaveArcSummaryTool) Schema() map[string]any {
snapshotSchema := schema.Object(
schema.Property("name", schema.String("角色名")).Required(),
schema.Property("status", schema.String("当前状态(存活/受伤/失踪等)")).Required(),
schema.Property("power", schema.String("能力变化")),
schema.Property("motivation", schema.String("当前动机")).Required(),
schema.Property("relations", schema.String("关键关系变化")),
)
return schema.Object(
schema.Property("volume", schema.Int("卷号")).Required(),
schema.Property("arc", schema.Int("弧号")).Required(),
schema.Property("title", schema.String("弧标题")).Required(),
schema.Property("summary", schema.String("弧摘要500字以内")).Required(),
schema.Property("key_events", schema.Array("弧内关键事件", schema.String(""))).Required(),
schema.Property("character_snapshots", schema.Array("角色状态快照", snapshotSchema)).Required(),
)
}
func (t *SaveArcSummaryTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Volume int `json:"volume"`
Arc int `json:"arc"`
Title string `json:"title"`
Summary string `json:"summary"`
KeyEvents []string `json:"key_events"`
CharacterSnapshots []domain.CharacterSnapshot `json:"character_snapshots"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
if a.Volume <= 0 || a.Arc <= 0 {
return nil, fmt.Errorf("volume and arc must be > 0")
}
arcSummary := domain.ArcSummary{
Volume: a.Volume,
Arc: a.Arc,
Title: a.Title,
Summary: a.Summary,
KeyEvents: a.KeyEvents,
}
if err := t.store.SaveArcSummary(arcSummary); err != nil {
return nil, fmt.Errorf("save arc summary: %w", err)
}
if len(a.CharacterSnapshots) > 0 {
for i := range a.CharacterSnapshots {
a.CharacterSnapshots[i].Volume = a.Volume
a.CharacterSnapshots[i].Arc = a.Arc
}
if err := t.store.SaveCharacterSnapshots(a.Volume, a.Arc, a.CharacterSnapshots); err != nil {
return nil, fmt.Errorf("save character snapshots: %w", err)
}
}
return json.Marshal(map[string]any{
"saved": true, "type": "arc_summary",
"volume": a.Volume, "arc": a.Arc,
"snapshots": len(a.CharacterSnapshots),
})
}

View File

@@ -21,68 +21,151 @@ func NewSaveFoundationTool(store *state.Store) *SaveFoundationTool {
func (t *SaveFoundationTool) Name() string { return "save_foundation" }
func (t *SaveFoundationTool) Description() string {
return "保存小说基础设定。type=premise 时 content 为 Markdowntype=outline 时 content 为 JSON 数组type=characters 时 content 为 JSON 数组type=world_rules 时 content JSON 数组"
return "保存小说基础设定。参数固定为 {type, content, scale?}。type 可选 premise / outline / layered_outline / characters / world_rules。premise 时 content 必须是 Markdown 字符串outline、layered_outline、characters、world_rules 时 content 优先直接传 JSON 数组或对象,不要再手动包一层转义字符串;工具也兼容传入 JSON 字符串。scale 可选,仅允许 short / mid / long。"
}
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(),
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
schema.Property("content", map[string]any{
"description": "内容。premise 传 Markdown 字符串outline/layered_outline/characters/world_rules 直接传 JSON 数组或对象即可,也兼容传 JSON 字符串。不要把数组再次手动转义成难读的字符串。",
}).Required(),
schema.Property("scale", schema.Enum("规划级别", "short", "mid", "long")),
)
}
func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Type string `json:"type"`
Content string `json:"content"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
Scale string `json:"scale"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
content, err := normalizeFoundationContent(a.Content)
if err != nil {
return nil, err
}
if a.Scale != "" {
switch domain.PlanningTier(a.Scale) {
case domain.PlanningTierShort, domain.PlanningTierMid, domain.PlanningTierLong:
default:
return nil, fmt.Errorf("invalid scale %q, expected short/mid/long", a.Scale)
}
if err := t.store.SetPlanningTier(domain.PlanningTier(a.Scale)); err != nil {
return nil, fmt.Errorf("save planning tier: %w", err)
}
}
result := map[string]any{"saved": true, "type": a.Type, "scale": a.Scale}
switch a.Type {
case "premise":
if err := t.store.SavePremise(a.Content); err != nil {
if err := t.store.SavePremise(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 {
if err := json.Unmarshal([]byte(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)
// 根据大纲长度自动设定总章节数
_ = t.store.SetTotalChapters(len(entries))
return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)})
if domain.PlanningTier(a.Scale) != domain.PlanningTierLong {
_ = t.store.SetLayered(false)
_ = t.store.UpdateVolumeArc(0, 0)
_ = t.store.ClearLayeredOutline()
}
result["chapters"] = len(entries)
case "layered_outline":
var volumes []domain.VolumeOutline
if err := json.Unmarshal([]byte(content), &volumes); err != nil {
return nil, fmt.Errorf("parse layered_outline JSON: %w", err)
}
if err := t.store.SaveLayeredOutline(volumes); err != nil {
return nil, fmt.Errorf("save layered_outline: %w", err)
}
flat := domain.FlattenOutline(volumes)
if err := t.store.SaveOutline(flat); err != nil {
return nil, fmt.Errorf("save flattened outline: %w", err)
}
total := domain.TotalChapters(volumes)
_ = t.store.UpdatePhase(domain.PhaseOutline)
_ = t.store.SetTotalChapters(total)
_ = t.store.SetLayered(true)
if len(volumes) > 0 && len(volumes[0].Arcs) > 0 {
_ = t.store.UpdateVolumeArc(volumes[0].Index, volumes[0].Arcs[0].Index)
}
result["volumes"] = len(volumes)
result["chapters"] = total
case "characters":
var chars []domain.Character
if err := json.Unmarshal([]byte(a.Content), &chars); err != nil {
if err := json.Unmarshal([]byte(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)})
result["count"] = len(chars)
case "world_rules":
var rules []domain.WorldRule
if err := json.Unmarshal([]byte(a.Content), &rules); err != nil {
if err := json.Unmarshal([]byte(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)})
result["count"] = len(rules)
default:
return nil, fmt.Errorf("unknown type %q, expected premise/outline/characters/world_rules", a.Type)
return nil, fmt.Errorf("unknown type %q, expected premise/outline/layered_outline/characters/world_rules", a.Type)
}
// 返回剩余未完成项,引导 Architect 继续
result["remaining"] = t.remaining()
return json.Marshal(result)
}
func normalizeFoundationContent(raw json.RawMessage) (string, error) {
if len(raw) == 0 {
return "", fmt.Errorf("content is required")
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text, nil
}
if !json.Valid(raw) {
return "", fmt.Errorf("invalid content: expected Markdown string or valid JSON value")
}
return string(raw), nil
}
// remaining 检查基础设定中还缺少哪些必要项。
func (t *SaveFoundationTool) remaining() []string {
var missing []string
if p, _ := t.store.LoadPremise(); p == "" {
missing = append(missing, "premise")
}
if o, _ := t.store.LoadOutline(); len(o) == 0 {
missing = append(missing, "outline")
}
if c, _ := t.store.LoadCharacters(); len(c) == 0 {
missing = append(missing, "characters")
}
if r, _ := t.store.LoadWorldRules(); len(r) == 0 {
missing = append(missing, "world_rules")
}
return missing
}

View File

@@ -0,0 +1,151 @@
package tools
import (
"context"
"encoding/json"
"testing"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
func TestSaveFoundationPersistsPlanningTier(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
tool := NewSaveFoundationTool(store)
args, err := json.Marshal(map[string]any{
"type": "premise",
"content": "# Premise\n\n测试",
"scale": "long",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if _, err := tool.Execute(context.Background(), args); err != nil {
t.Fatalf("Execute: %v", err)
}
meta, err := store.LoadRunMeta()
if err != nil {
t.Fatalf("LoadRunMeta: %v", err)
}
if meta == nil {
t.Fatal("expected run meta to exist")
}
if meta.PlanningTier != domain.PlanningTierLong {
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierLong, meta.PlanningTier)
}
}
func TestSaveFoundationOutlineClearsLayeredStateWhenDowngrading(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", 0); err != nil {
t.Fatalf("InitProgress: %v", err)
}
tool := NewSaveFoundationTool(store)
layeredArgs, err := json.Marshal(map[string]any{
"type": "layered_outline",
"content": `[{"index":1,"title":"第一卷","theme":"主题","arcs":[{"index":1,"title":"第一弧","goal":"目标","chapters":[{"chapter":1,"title":"第一章","core_event":"开局","hook":"继续"}]}]}]`,
"scale": "long",
})
if err != nil {
t.Fatalf("Marshal layered args: %v", err)
}
if _, err := tool.Execute(context.Background(), layeredArgs); err != nil {
t.Fatalf("Execute layered outline: %v", err)
}
outlineArgs, err := json.Marshal(map[string]any{
"type": "outline",
"content": `[{"chapter":1,"title":"第一章","core_event":"改为中篇","hook":"继续"}]`,
"scale": "mid",
})
if err != nil {
t.Fatalf("Marshal outline args: %v", err)
}
if _, err := tool.Execute(context.Background(), outlineArgs); err != nil {
t.Fatalf("Execute outline: %v", err)
}
progress, err := store.LoadProgress()
if err != nil {
t.Fatalf("LoadProgress: %v", err)
}
if progress == nil {
t.Fatal("expected progress to exist")
}
if progress.Layered {
t.Fatal("expected layered mode to be disabled")
}
if progress.CurrentVolume != 0 || progress.CurrentArc != 0 {
t.Fatalf("expected volume/arc reset, got volume=%d arc=%d", progress.CurrentVolume, progress.CurrentArc)
}
volumes, err := store.LoadLayeredOutline()
if err != nil {
t.Fatalf("LoadLayeredOutline: %v", err)
}
if len(volumes) != 0 {
t.Fatalf("expected layered outline cleared, got %d volumes", len(volumes))
}
meta, err := store.LoadRunMeta()
if err != nil {
t.Fatalf("LoadRunMeta: %v", err)
}
if meta == nil {
t.Fatal("expected run meta to exist")
}
if meta.PlanningTier != domain.PlanningTierMid {
t.Fatalf("expected planning tier %q, got %q", domain.PlanningTierMid, meta.PlanningTier)
}
}
func TestSaveFoundationAcceptsDirectJSONArrayContent(t *testing.T) {
dir := t.TempDir()
store := state.NewStore(dir)
if err := store.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
tool := NewSaveFoundationTool(store)
args, err := json.Marshal(map[string]any{
"type": "outline",
"content": []map[string]any{
{
"chapter": 1,
"title": "第一章",
"core_event": "主角登场",
"hook": "继续",
"scenes": []string{"场景一", "场景二"},
},
},
"scale": "short",
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if _, err := tool.Execute(context.Background(), args); err != nil {
t.Fatalf("Execute: %v", err)
}
outline, err := store.LoadOutline()
if err != nil {
t.Fatalf("LoadOutline: %v", err)
}
if len(outline) != 1 || outline[0].Title != "第一章" {
t.Fatalf("unexpected outline: %+v", outline)
}
}

View File

@@ -27,14 +27,21 @@ 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("type", schema.Enum("问题维度", "consistency", "character", "pacing", "continuity", "foreshadow", "hook")).Required(),
schema.Property("severity", schema.Enum("严重程度", "critical", "error", "warning")).Required(),
schema.Property("description", schema.String("问题描述")).Required(),
schema.Property("suggestion", schema.String("修改建议")),
)
dimensionSchema := schema.Object(
schema.Property("dimension", schema.Enum("维度", "consistency", "character", "pacing", "continuity", "foreshadow", "hook")).Required(),
schema.Property("score", schema.Int("评分0-100")).Required(),
schema.Property("verdict", schema.Enum("维度结论", "pass", "warning", "fail")).Required(),
schema.Property("comment", schema.String("该维度的简要结论")),
)
return schema.Object(
schema.Property("chapter", schema.Int("审阅的章节号(全局审阅填最新章节号)")).Required(),
schema.Property("scope", schema.Enum("审阅范围", "chapter", "global")).Required(),
schema.Property("scope", schema.Enum("审阅范围", "chapter", "global", "arc")).Required(),
schema.Property("dimensions", schema.Array("分维度评分(六个维度各一条)", dimensionSchema)).Required(),
schema.Property("issues", schema.Array("发现的问题", issueSchema)).Required(),
schema.Property("verdict", schema.Enum("审阅结论", "accept", "polish", "rewrite")).Required(),
schema.Property("summary", schema.String("审阅总结")).Required(),

View File

@@ -0,0 +1,62 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state"
)
// SaveVolumeSummaryTool 保存卷级摘要Editor 在卷结束时调用。
type SaveVolumeSummaryTool struct {
store *state.Store
}
func NewSaveVolumeSummaryTool(store *state.Store) *SaveVolumeSummaryTool {
return &SaveVolumeSummaryTool{store: store}
}
func (t *SaveVolumeSummaryTool) Name() string { return "save_volume_summary" }
func (t *SaveVolumeSummaryTool) Description() string { return "保存卷级摘要(长篇模式,卷结束时调用)" }
func (t *SaveVolumeSummaryTool) Label() string { return "保存卷摘要" }
func (t *SaveVolumeSummaryTool) Schema() map[string]any {
return schema.Object(
schema.Property("volume", schema.Int("卷号")).Required(),
schema.Property("title", schema.String("卷标题")).Required(),
schema.Property("summary", schema.String("卷摘要500字以内")).Required(),
schema.Property("key_events", schema.Array("卷内关键事件", schema.String(""))).Required(),
)
}
func (t *SaveVolumeSummaryTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct {
Volume int `json:"volume"`
Title string `json:"title"`
Summary string `json:"summary"`
KeyEvents []string `json:"key_events"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err)
}
if a.Volume <= 0 {
return nil, fmt.Errorf("volume must be > 0")
}
volSummary := domain.VolumeSummary{
Volume: a.Volume,
Title: a.Title,
Summary: a.Summary,
KeyEvents: a.KeyEvents,
}
if err := t.store.SaveVolumeSummary(volSummary); err != nil {
return nil, fmt.Errorf("save volume summary: %w", err)
}
return json.Marshal(map[string]any{
"saved": true, "type": "volume_summary", "volume": a.Volume,
})
}

View File

@@ -1,76 +0,0 @@
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,
})
}

View File

@@ -1,10 +1,13 @@
package tui
import (
"fmt"
"io"
"log"
"os"
"os/signal"
"path/filepath"
"syscall"
tea "github.com/charmbracelet/bubbletea"
"github.com/voocel/ainovel-cli/app"
@@ -17,13 +20,46 @@ func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[
if err != nil {
return err
}
bridge := newAskUserBridge()
rt.AskUser().SetHandler(bridge.handler)
restoreLog := redirectLogger(rt.Dir())
defer restoreLog()
defer rt.Close()
m := NewModel(rt)
m := NewModel(rt, bridge)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err = p.Run()
// bubbletea 退出后,检查任务是否仍在运行
snap := rt.Snapshot()
if snap.IsRunning {
// 任务仍在运行(可能是 tmux detach 导致 TUI 退出),
// 不中断任务,回退到无 UI 阻塞等待模式。
// 脱离 TUI 后解除所有阻塞在 ask_user 的 handler goroutine
// 让 LLM 收到"用户不在线,请自行决策"提示后继续运行,
// 而非无限等待一个永远不会到来的用户输入。
bridge.Detach()
fmt.Println("\n[TUI 已退出,任务仍在后台运行中...]")
fmt.Printf("[小说: %s | 阶段: %s | 进度: %d/%d 章]\n",
snap.NovelName, snap.Phase, snap.CompletedCount, snap.TotalChapters)
fmt.Println("[等待任务完成,按 Ctrl+C 强制中断]")
// 监听中断信号
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case <-rt.Done():
fmt.Println("\n[任务已完成!]")
case <-sigCh:
fmt.Println("\n[收到中断信号,正在停止...]")
rt.Close()
}
return nil
}
// 任务未在运行,正常关闭
rt.Close()
return err
}

302
tui/ask_user.go Normal file
View File

@@ -0,0 +1,302 @@
package tui
import (
"context"
"fmt"
"strings"
"sync/atomic"
"github.com/charmbracelet/lipgloss"
"github.com/voocel/ainovel-cli/tools"
)
type askUserRequest struct {
questions []tools.Question
resultCh chan askUserResult
}
type askUserResult struct {
resp *tools.AskUserResponse
err error
}
// askUserBridge 在 TUI goroutine 与 LLM handler goroutine 之间传递 ask_user 请求。
// detachCh 被关闭后,所有阻塞在 handler() 中的调用会立即返回错误,让 LLM 自行决策。
type askUserBridge struct {
requests chan askUserRequest
detachCh chan struct{}
detached uint32 // atomic: 0=active, 1=detached
}
func newAskUserBridge() *askUserBridge {
return &askUserBridge{
requests: make(chan askUserRequest),
detachCh: make(chan struct{}),
}
}
// Detach 标记桥接器为已脱离状态,关闭 detachCh使所有阻塞在 handler() 中的
// goroutine 立即收到错误并返回,让 LLM 收到"用户不在线,请自行决策"后继续运行。
// 多次调用安全(只有第一次真正关闭 channel
func (b *askUserBridge) Detach() {
if atomic.CompareAndSwapUint32(&b.detached, 0, 1) {
close(b.detachCh)
}
}
func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
// 若已脱离 TUI直接返回错误让 tools/ask_user.go 将错误提示返回给 LLM
if atomic.LoadUint32(&b.detached) == 1 {
return nil, fmt.Errorf("用户不在线,请自行决策")
}
req := askUserRequest{
questions: questions,
resultCh: make(chan askUserResult, 1),
}
select {
case b.requests <- req:
case <-b.detachCh:
return nil, fmt.Errorf("用户不在线,请自行决策")
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case result := <-req.resultCh:
return result.resp, result.err
case <-b.detachCh:
return nil, fmt.Errorf("用户不在线,请自行决策")
case <-ctx.Done():
return nil, ctx.Err()
}
}
type askUserState struct {
request askUserRequest
index int
cursor int
typing bool
input string
selected map[int]bool
answers map[string]string
notes map[string]string
}
func newAskUserState(req askUserRequest) *askUserState {
return &askUserState{
request: req,
selected: make(map[int]bool),
answers: make(map[string]string),
notes: make(map[string]string),
}
}
func (s *askUserState) currentQuestion() tools.Question {
return s.request.questions[s.index]
}
func (s *askUserState) optionCount() int {
return len(s.currentQuestion().Options) + 1
}
func (s *askUserState) choiceLabel(idx int) string {
q := s.currentQuestion()
if idx < len(q.Options) {
return q.Options[idx].Label
}
return "自由输入"
}
func (s *askUserState) choiceDescription(idx int) string {
q := s.currentQuestion()
if idx < len(q.Options) {
return q.Options[idx].Description
}
return "以上都不合适,自己补充"
}
func (s *askUserState) moveCursor(delta int) {
total := s.optionCount()
if total == 0 {
s.cursor = 0
return
}
s.cursor = (s.cursor + delta + total) % total
}
func (s *askUserState) toggleSelection() {
if s.selected[s.cursor] {
delete(s.selected, s.cursor)
return
}
s.selected[s.cursor] = true
}
func (s *askUserState) finishCurrentAnswer() bool {
q := s.currentQuestion()
if s.typing {
text := strings.TrimSpace(s.input)
if text == "" {
return false
}
s.answers[q.Question] = text
s.notes[q.Question] = text
return s.advance()
}
if q.MultiSelect {
var values []string
var custom string
for idx := 0; idx < s.optionCount(); idx++ {
if !s.selected[idx] {
continue
}
if idx < len(q.Options) {
values = append(values, q.Options[idx].Label)
continue
}
custom = strings.TrimSpace(s.input)
}
if custom != "" {
values = append(values, custom)
s.notes[q.Question] = custom
}
if len(values) == 0 {
return false
}
s.answers[q.Question] = strings.Join(values, "、")
return s.advance()
}
if s.cursor >= len(q.Options) {
s.typing = true
s.input = ""
return false
}
s.answers[q.Question] = q.Options[s.cursor].Label
return s.advance()
}
func (s *askUserState) advance() bool {
s.index++
if s.index >= len(s.request.questions) {
return true
}
s.cursor = 0
s.typing = false
s.input = ""
s.selected = make(map[int]bool)
return false
}
func (s *askUserState) submit() {
s.request.resultCh <- askUserResult{
resp: &tools.AskUserResponse{
Answers: s.answers,
Notes: s.notes,
},
}
}
func (s *askUserState) cancelCurrentTyping() {
s.typing = false
s.input = ""
}
func renderAskUserModal(width, height int, state *askUserState) string {
if state == nil {
return ""
}
q := state.currentQuestion()
boxW := minInt(maxInt(width*60/100, 52), width-4)
boxH := minInt(maxInt(height*60/100, 16), height-4)
if boxW < 40 {
boxW = maxInt(width-2, 20)
}
if boxH < 10 {
boxH = maxInt(height-2, 8)
}
var b strings.Builder
title := fmt.Sprintf("需要补充信息 %d/%d", state.index+1, len(state.request.questions))
b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(title))
b.WriteString("\n\n")
if q.Header != "" {
b.WriteString(highlightValueStyle.Render(q.Header))
b.WriteString("\n")
}
b.WriteString(cardContentStyle.Render(q.Question))
b.WriteString("\n\n")
for idx := 0; idx < state.optionCount(); idx++ {
prefix := " "
if state.cursor == idx {
prefix = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(" ")
}
label := state.choiceLabel(idx)
if q.MultiSelect {
marker := "[ ]"
if state.selected[idx] {
marker = "[x]"
}
label = marker + " " + label
}
b.WriteString(prefix + cardContentStyle.Render(label))
b.WriteString("\n")
b.WriteString(" " + lipgloss.NewStyle().Foreground(colorDim).Render(state.choiceDescription(idx)))
b.WriteString("\n")
}
if state.typing || (q.MultiSelect && state.selected[len(q.Options)]) {
b.WriteString("\n")
b.WriteString(panelTitleStyle.Render("补充内容"))
b.WriteString("\n")
content := state.input
if content == "" {
content = "请输入..."
}
style := lipgloss.NewStyle().
Width(boxW-8).
Border(baseBorder).
BorderForeground(colorDim).
Padding(0, 1)
b.WriteString(style.Render(content))
b.WriteString("\n")
}
hint := "↑↓ 选择 · Enter 确认"
if q.MultiSelect {
hint = "↑↓ 选择 · Space 勾选 · Enter 提交"
}
if state.typing {
hint = "输入补充内容 · Enter 确认 · Esc 返回选项"
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(hint))
box := lipgloss.NewStyle().
Width(boxW).
Height(boxH).
Border(baseBorder).
BorderForeground(colorAccent).
Padding(1, 2).
Background(lipgloss.Color("#2a2520")).
Render(b.String())
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -12,6 +12,7 @@ type (
eventMsg app.UIEvent
snapshotMsg app.UISnapshot
doneMsg struct{}
askUserMsg askUserRequest
startResultMsg struct{ err error }
steerResultMsg struct{}
spinnerTickMsg time.Time
@@ -102,3 +103,13 @@ func listenStreamClear(rt *app.Runtime) tea.Cmd {
return streamClearMsg{}
}
}
func listenAskUser(bridge *askUserBridge) tea.Cmd {
return func() tea.Msg {
req, ok := <-bridge.requests
if !ok {
return nil
}
return askUserMsg(req)
}
}

View File

@@ -20,7 +20,7 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid
line1 := prompt + inputView
// 第二行:左快捷键,右进度
hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送")
hints := lipgloss.NewStyle().Foreground(colorDim).Render("点击/Tab 切换面板 · ↑↓ 滚动 · End 跳底 · ^L 清屏 · Esc 重置 · Enter 发送")
info := buildRightInfo(snap, outputDir)
hintsW := lipgloss.Width(hints)
@@ -52,6 +52,9 @@ func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, wid
func buildRightInfo(snap app.UISnapshot, outputDir string) string {
var parts []string
if snap.Provider != "" {
parts = append(parts, snap.Provider)
}
if snap.ModelName != "" {
parts = append(parts, snap.ModelName)
}

View File

@@ -3,6 +3,7 @@ package tui
import (
"strings"
"time"
"unicode/utf8"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
@@ -13,6 +14,14 @@ import (
const maxEvents = 500
type focusPane int
const (
focusEvents focusPane = iota
focusStream
focusDetail
)
type appMode int
const (
@@ -27,17 +36,23 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "
// Model 是 TUI 的顶层状态。
type Model struct {
runtime *app.Runtime
askBridge *askUserBridge
askState *askUserState
snapshot app.UISnapshot
events []app.UIEvent
viewport viewport.Model // 事件流 viewport
streamVP viewport.Model // 流式输出 viewport
viewport viewport.Model // 事件流 viewport
streamVP viewport.Model // 流式输出 viewport
detailVP viewport.Model // 右侧详情 viewport
streamBuf *strings.Builder // 流式文本累积缓冲
streamRounds []string
textarea textarea.Model
width int
height int
autoScroll bool
streamScroll bool // 流式面板自动跟随
focusStream bool // true=焦点在流式面板, false=事件流
focusPane focusPane
hoverPane focusPane
hoverActive bool
mode appMode
err error
spinnerIdx int
@@ -45,7 +60,7 @@ type Model struct {
}
// NewModel 创建 TUI Model。
func NewModel(rt *app.Runtime) Model {
func NewModel(rt *app.Runtime, bridge *askUserBridge) Model {
ta := textarea.New()
ta.Placeholder = "输入小说需求例如写一部12章都市悬疑小说"
ta.CharLimit = 500
@@ -63,14 +78,19 @@ func NewModel(rt *app.Runtime) Model {
svp := viewport.New(80, 10)
svp.SetContent("")
dvp := viewport.New(40, 20)
dvp.SetContent("")
return Model{
runtime: rt,
askBridge: bridge,
autoScroll: true,
streamScroll: true,
mode: modeNew,
textarea: ta,
viewport: vp,
streamVP: svp,
detailVP: dvp,
streamBuf: &strings.Builder{},
}
}
@@ -79,6 +99,7 @@ func (m Model) Init() tea.Cmd {
return tea.Batch(
textarea.Blink,
listenEvents(m.runtime),
listenAskUser(m.askBridge),
listenDone(m.runtime),
listenStream(m.runtime),
listenStreamClear(m.runtime),
@@ -97,9 +118,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.textarea.SetWidth(m.inputWidth())
m.updateViewportSize()
m.refreshDetailViewport()
return m, nil
case tea.KeyMsg:
if m.askState != nil {
return m.handleAskUserKey(msg)
}
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
@@ -111,12 +136,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent("")
m.viewport.GotoTop()
m.streamBuf.Reset()
m.streamRounds = nil
m.streamVP.SetContent("")
m.streamVP.GotoTop()
m.streamRound = 0
return m, nil
case tea.KeyTab:
m.focusStream = !m.focusStream
m.focusPane = (m.focusPane + 1) % 3
return m, nil
case tea.KeyEnter:
text := strings.TrimSpace(m.textarea.Value())
@@ -134,18 +160,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyUp, tea.KeyPgUp:
if m.focusStream {
if m.focusPane == focusStream {
m.streamScroll = false
var cmd tea.Cmd
m.streamVP, cmd = m.streamVP.Update(msg)
return m, cmd
}
if m.focusPane == focusDetail {
var cmd tea.Cmd
m.detailVP, cmd = m.detailVP.Update(msg)
return m, cmd
}
m.autoScroll = false
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case tea.KeyDown, tea.KeyPgDown:
if m.focusStream {
if m.focusPane == focusStream {
var cmd tea.Cmd
m.streamVP, cmd = m.streamVP.Update(msg)
if m.streamVP.AtBottom() {
@@ -153,6 +184,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
}
if m.focusPane == focusDetail {
var cmd tea.Cmd
m.detailVP, cmd = m.detailVP.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
if m.viewport.AtBottom() {
@@ -160,9 +196,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
case tea.KeyEnd:
if m.focusStream {
if m.focusPane == focusStream {
m.streamScroll = true
m.streamVP.GotoBottom()
} else if m.focusPane == focusDetail {
m.detailVP.GotoBottom()
} else {
m.autoScroll = true
m.viewport.GotoBottom()
@@ -171,12 +209,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case tea.MouseMsg:
if pane, ok := m.paneAtMouse(msg.X, msg.Y); ok {
m.hoverPane = pane
m.hoverActive = true
if msg.Action == tea.MouseActionPress {
m.focusPane = pane
}
} else {
m.hoverActive = false
}
var cmd tea.Cmd
if m.focusStream {
if m.focusPane == focusStream {
m.streamVP, cmd = m.streamVP.Update(msg)
if msg.Action == tea.MouseActionPress {
m.streamScroll = m.streamVP.AtBottom()
}
} else if m.focusPane == focusDetail {
m.detailVP, cmd = m.detailVP.Update(msg)
} else {
m.viewport, cmd = m.viewport.Update(msg)
if msg.Action == tea.MouseActionPress {
@@ -194,8 +243,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshEventViewport()
return m, listenEvents(m.runtime)
case askUserMsg:
m.askState = newAskUserState(askUserRequest(msg))
m.textarea.Blur()
m.events = append(m.events, app.UIEvent{
Time: time.Now(), Category: "SYSTEM", Summary: "等待用户补充关键信息", Level: "info",
})
m.refreshEventViewport()
return m, listenAskUser(m.askBridge)
case snapshotMsg:
m.snapshot = app.UISnapshot(msg)
m.refreshDetailViewport()
return m, tickSnapshot(m.runtime)
case doneMsg:
@@ -230,22 +289,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tickSpinner()
case streamDeltaMsg:
m.streamBuf.WriteString(string(msg))
m.streamVP.SetContent(m.streamBuf.String())
if len(m.streamRounds) == 0 {
m.streamRounds = append(m.streamRounds, "")
}
m.streamRounds[len(m.streamRounds)-1] += string(msg)
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
if m.streamScroll {
m.streamVP.GotoBottom()
}
return m, listenStream(m.runtime)
case streamClearMsg:
// 新一轮输出:保留历史内容,用分隔线标记新段落
m.streamRound++
if m.streamBuf.Len() > 0 {
m.streamBuf.WriteString("\n")
m.streamBuf.WriteString(renderStreamSeparator(m.streamRound, m.streamVP.Width))
m.streamBuf.WriteString("\n")
// 新一轮输出:按轮次分块显示,避免长文本和分隔线直接拼接导致错乱。
if len(m.streamRounds) == 0 {
m.streamRounds = append(m.streamRounds, "")
} else if strings.TrimSpace(m.streamRounds[len(m.streamRounds)-1]) != "" {
m.streamRounds = append(m.streamRounds, "")
}
m.streamVP.SetContent(m.streamBuf.String())
m.streamRound = len(m.streamRounds)
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
if m.streamScroll {
m.streamVP.GotoBottom()
}
@@ -260,6 +322,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *Model) paneAtMouse(x, y int) (focusPane, bool) {
if m.width == 0 || m.height == 0 {
return focusEvents, false
}
topH := lipgloss.Height(renderTopBar(m.snapshot, m.width, ""))
inputH := lipgloss.Height(renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), m.width))
bodyH := m.height - topH - inputH
if bodyH < 1 {
return focusEvents, false
}
bodyStartY := topH
bodyEndY := topH + bodyH
if y < bodyStartY || y >= bodyEndY {
return focusEvents, false
}
leftW := m.width * 25 / 100
rightW := m.detailWidth()
centerStartX := leftW
rightStartX := m.width - rightW
if x >= rightStartX {
return focusDetail, true
}
if x < centerStartX {
return focusEvents, true
}
eventH, _ := m.splitHeights(bodyH)
if y-bodyStartY < eventH {
return focusEvents, true
}
return focusStream, true
}
func (m *Model) paneHighlighted(pane focusPane) bool {
if m.focusPane == pane {
return true
}
return m.hoverActive && m.hoverPane == pane
}
// refreshEventViewport 重新渲染事件流内容并设置 viewport。
func (m *Model) refreshEventViewport() {
centerW := m.eventFlowWidth()
@@ -273,15 +379,26 @@ func (m *Model) refreshEventViewport() {
}
}
func (m *Model) refreshDetailViewport() {
rightW := m.detailWidth()
if rightW <= 4 {
return
}
m.detailVP.SetContent(renderDetailContent(m.snapshot, rightW-4))
}
// updateViewportSize 根据当前窗口尺寸更新 viewport 大小。
func (m *Model) updateViewportSize() {
centerW := m.eventFlowWidth()
rightW := m.detailWidth()
bodyH := m.bodyHeight()
eventH, streamH := m.splitHeights(bodyH)
m.viewport.Width = centerW - 2
m.viewport.Height = eventH - 1 // -1 为 event panel header 行
m.streamVP.Width = centerW - 2
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
m.detailVP.Width = rightW - 2
m.detailVP.Height = bodyH
}
// splitHeights 计算事件流和流式输出的高度分配。
@@ -309,10 +426,17 @@ func (m *Model) eventFlowWidth() int {
return 80
}
leftW := m.width * 25 / 100
rightW := m.width * 30 / 100
rightW := m.detailWidth()
return m.width - leftW - rightW
}
func (m *Model) detailWidth() int {
if m.width == 0 {
return 40
}
return m.width * 30 / 100
}
func (m *Model) bodyHeight() int {
if m.height == 0 {
return 20
@@ -362,7 +486,7 @@ func (m Model) View() string {
body = renderWelcome(m.width, bodyH, errMsg)
} else {
leftW := m.width * 25 / 100
rightW := m.width * 30 / 100
rightW := m.detailWidth()
centerW := m.width - leftW - rightW
eventH, streamH := m.splitHeights(bodyH)
@@ -375,14 +499,89 @@ func (m Model) View() string {
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
}
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream)
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream)
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, m.paneHighlighted(focusEvents))
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.paneHighlighted(focusStream))
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)
left := renderStatePanel(m.snapshot, leftW, bodyH)
right := renderDetailPanel(m.snapshot, rightW, bodyH)
right := renderDetailPanel(m.detailVP, rightW, bodyH, m.paneHighlighted(focusDetail))
body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
}
return lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox)
view := lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox)
if m.askState != nil {
return renderAskUserModal(m.width, m.height, m.askState)
}
return view
}
func (m Model) handleAskUserKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.askState == nil {
return m, nil
}
state := m.askState
q := state.currentQuestion()
if state.typing {
switch msg.Type {
case tea.KeyEsc:
state.cancelCurrentTyping()
return m, nil
case tea.KeyEnter:
if state.finishCurrentAnswer() {
state.submit()
m.askState = nil
if m.mode != modeDone {
m.textarea.Focus()
}
}
return m, nil
case tea.KeyBackspace, tea.KeyCtrlH:
if state.input != "" {
_, size := utf8.DecodeLastRuneInString(state.input)
state.input = state.input[:len(state.input)-size]
}
return m, nil
default:
if msg.Type == tea.KeyRunes {
state.input += string(msg.Runes)
}
return m, nil
}
}
switch msg.Type {
case tea.KeyUp:
state.moveCursor(-1)
case tea.KeyDown:
state.moveCursor(1)
case tea.KeySpace:
if q.MultiSelect {
state.toggleSelection()
if state.cursor == len(q.Options) && !state.selected[state.cursor] {
state.input = ""
}
}
case tea.KeyEnter:
if q.MultiSelect {
if state.cursor == len(q.Options) {
state.toggleSelection()
if state.selected[state.cursor] {
state.typing = true
}
return m, nil
}
if len(state.selected) == 0 {
state.toggleSelection()
}
}
if state.finishCurrentAnswer() {
state.submit()
m.askState = nil
if m.mode != modeDone {
m.textarea.Focus()
}
}
}
return m, nil
}

View File

@@ -1,6 +1,7 @@
package tui
import (
"encoding/json"
"fmt"
"strings"
@@ -27,6 +28,9 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
// 第二行左侧:模型 + 风格
var infoParts []string
if snap.Provider != "" {
infoParts = append(infoParts, snap.Provider)
}
if snap.ModelName != "" {
infoParts = append(infoParts, snap.ModelName)
}
@@ -80,7 +84,7 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string {
b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
if snap.InProgressChapter > 0 {
b.WriteString(renderField("Writing", fmt.Sprintf("第%d章 场景%d", snap.InProgressChapter, snap.CompletedScenes)))
b.WriteString(renderField("Writing", fmt.Sprintf("第%d章", snap.InProgressChapter)))
}
if len(snap.PendingRewrites) > 0 {
@@ -198,12 +202,8 @@ func renderEventFlowViewport(vp viewport.Model, width, height int, focused bool)
// renderStreamPanel 渲染流式输出面板(中间列下半部分)。
func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string {
// 分隔标题栏
titleColor := colorDim
if focused {
titleColor = colorAccent
}
title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 生成内容")
// 分隔标题栏(始终醒目)
title := lipgloss.NewStyle().Foreground(colorAccent).Bold(focused).Render("✦ 实时输出")
lineW := width - lipgloss.Width(title) - 4
if lineW < 0 {
lineW = 0
@@ -225,23 +225,234 @@ func renderStreamPanel(vp viewport.Model, width, height int, focused bool) strin
return header + "\n" + vpStyle.Render(vp.View())
}
// renderStreamSeparator 渲染流式面板中的轮次分隔线
func renderStreamSeparator(round, width int) string {
label := fmt.Sprintf(" #%d ", round)
lineW := (width - lipgloss.Width(label)) / 2
if lineW < 1 {
lineW = 1
// renderStreamContent 将流式输出按轮次渲染为语义分块
// Agent 调度块(以 ▸ 开头)用 accent 标题 + dim 指令;正文块用标准文本色。
func renderStreamContent(rounds []string, width int) string {
if width < 24 {
width = 24
}
line := strings.Repeat("─", lineW)
dimLine := lipgloss.NewStyle().Foreground(colorDim).Render(line)
dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label)
return dimLine + dimLabel + dimLine
var blocks []string
for _, round := range rounds {
text := strings.TrimSpace(round)
if text == "" {
continue
}
if strings.HasPrefix(text, "▸") {
blocks = append(blocks, renderAgentBlock(text, width))
} else {
blocks = append(blocks, renderChapterBlock(text, width))
}
}
return strings.Join(blocks, "\n\n")
}
// renderDetailPanel 渲染右侧详情面板
// renderAgentBlock 渲染 Agent 调度块:标题 + 分隔线 + 任务指令
func renderAgentBlock(text string, width int) string {
headerLine, body, _ := strings.Cut(text, "\n")
// 标题行 + 分隔线
titleW := lipgloss.Width(headerLine)
lineW := max(0, width-titleW-1)
header := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(headerLine) +
" " + lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW))
var b strings.Builder
b.WriteString(header)
// 任务指令dim 色,缩进 2 格
body = strings.TrimSpace(body)
if body != "" {
taskStyle := lipgloss.NewStyle().Foreground(colorMuted)
lines := wrapStreamText(body, max(16, width-6))
b.WriteString("\n")
for i, line := range lines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(taskStyle.Render(" " + line))
}
}
return b.String()
}
// renderChapterBlock 渲染正文块,自动区分思考内容和章节正文。
// 思考内容ThinkingSep 标记的段落)用淡色斜体,正文用标准文本色。
func renderChapterBlock(text string, width int) string {
contentStyle := lipgloss.NewStyle().Foreground(colorText)
thinkStyle := lipgloss.NewStyle().Foreground(colorDim).Italic(true)
wrapW := max(16, width-4)
// 按 ThinkingSep 分割:奇数段是思考,偶数段是正文
// 格式:[正文] \x02 [思考] [正文] \x02 [思考] ...
parts := strings.Split(text, app.ThinkingSep)
var b strings.Builder
for i, part := range parts {
part = strings.TrimRight(part, " ")
if part == "" {
continue
}
isThinking := i > 0 && i%2 != 0 // ThinkingSep 之后的奇数段是思考
// 如果整段都是思考标记开头(第一个 part 之前无正文),调整判断
if i == 0 && part == "" {
continue
}
style := contentStyle
if isThinking {
style = thinkStyle
}
lines := wrapStreamText(part, wrapW)
for j, line := range lines {
if b.Len() > 0 && j == 0 {
b.WriteString("\n")
} else if j > 0 {
b.WriteString("\n")
}
b.WriteString(style.Render(line))
}
}
return b.String()
}
func wrapStreamText(text string, width int) []string {
if width < 8 {
return []string{text}
}
var out []string
for _, raw := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
if strings.TrimSpace(raw) == "" {
out = append(out, "")
continue
}
if compact, ok := compactJSONLine(raw, width); ok {
out = append(out, compact)
continue
}
prefix, rest, nextPrefix := parseWrapPrefix(raw)
wrapped := wrapRunes(rest, max(4, width-lipgloss.Width(prefix)))
for i, line := range wrapped {
if i == 0 {
out = append(out, prefix+line)
continue
}
out = append(out, nextPrefix+line)
}
}
return out
}
func compactJSONLine(line string, width int) (string, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return "", false
}
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
return "", false
}
var value any
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
return "", false
}
compact, err := json.Marshal(value)
if err != nil {
return "", false
}
text := string(compact)
limit := max(24, width-2)
if lipgloss.Width(text) > limit {
text = truncate(text, limit-1)
}
return lipgloss.NewStyle().Foreground(colorDim).Render("JSON: ") +
lipgloss.NewStyle().Foreground(lipgloss.Color("#8fb7c9")).Render(text), true
}
func parseWrapPrefix(line string) (prefix, content, nextPrefix string) {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "), strings.HasPrefix(trimmed, "• "):
prefix = indent + trimmed[:2]
content = strings.TrimSpace(trimmed[2:])
nextPrefix = indent + " "
return prefix, content, nextPrefix
case orderedListPrefix(trimmed) != "":
marker := orderedListPrefix(trimmed)
prefix = indent + marker
content = strings.TrimSpace(strings.TrimPrefix(trimmed, marker))
nextPrefix = indent + strings.Repeat(" ", lipgloss.Width(marker))
return prefix, content, nextPrefix
case strings.HasPrefix(trimmed, "```"):
return indent, trimmed, indent
default:
return indent, trimmed, indent
}
}
func orderedListPrefix(line string) string {
end := strings.Index(line, ". ")
if end <= 0 {
return ""
}
for _, r := range line[:end] {
if r < '0' || r > '9' {
return ""
}
}
return line[:end+2]
}
func wrapRunes(text string, width int) []string {
if text == "" {
return []string{""}
}
if width < 2 {
return []string{text}
}
var lines []string
var current strings.Builder
currentWidth := 0
for _, r := range text {
rw := lipgloss.Width(string(r))
if currentWidth > 0 && currentWidth+rw > width {
lines = append(lines, strings.TrimRight(current.String(), " "))
current.Reset()
currentWidth = 0
if r == ' ' {
continue
}
}
current.WriteRune(r)
currentWidth += rw
}
if current.Len() > 0 {
lines = append(lines, strings.TrimRight(current.String(), " "))
}
if len(lines) == 0 {
return []string{""}
}
return lines
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// renderDetailContent 构建右侧详情面板内容。
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
func renderDetailPanel(snap app.UISnapshot, width, height int) string {
contentW := width - 4 // 边框 + padding
func renderDetailContent(snap app.UISnapshot, contentW int) string {
var b strings.Builder
// 大纲
@@ -309,14 +520,23 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string {
}
}
return b.String()
}
// renderDetailPanel 渲染右侧可滚动详情面板。
func renderDetailPanel(vp viewport.Model, width, height int, focused bool) string {
borderColor := colorDim
if focused {
borderColor = colorAccent
}
style := lipgloss.NewStyle().
Width(width).
Height(height).
Border(baseBorder, false, false, false, true).
BorderForeground(colorDim).
BorderForeground(borderColor).
Padding(0, 1)
return style.Render(b.String())
return style.Render(vp.View())
}
// renderWelcome 渲染新建态首屏。

View File

@@ -6,10 +6,12 @@ import "github.com/charmbracelet/lipgloss"
var (
colorText = lipgloss.Color("#e0d8c8")
colorDim = lipgloss.Color("#666666")
colorMuted = lipgloss.Color("#a09880") // 柔和但可读(介于 dim 和 text 之间)
colorAccent = lipgloss.Color("#d4a017") // 琥珀黄
colorSuccess = lipgloss.Color("#2ecc71") // 冷绿
colorError = lipgloss.Color("#e74c3c") // 朱红
colorReview = lipgloss.Color("#e67e22") // 橙色
colorContext = lipgloss.Color("#9b59b6") // 紫色
)
// 状态标签颜色映射
@@ -24,12 +26,13 @@ var statusColors = map[string]lipgloss.Color{
// 事件分类颜色映射
var categoryColors = map[string]lipgloss.Color{
"TOOL": colorText,
"SYSTEM": colorAccent,
"REVIEW": colorReview,
"CHECK": colorSuccess,
"ERROR": colorError,
"AGENT": colorDim,
"TOOL": colorText,
"SYSTEM": colorAccent,
"REVIEW": colorReview,
"CHECK": colorSuccess,
"ERROR": colorError,
"AGENT": colorDim,
"CONTEXT": colorContext,
}
// 基础样式