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

View File

@@ -15,12 +15,15 @@ type Config struct {
APIKey string // API Key APIKey string // API Key
BaseURL string // API Base URL可选 BaseURL string // API Base URL可选
Style string // 写作风格default/suspense/fantasy/romance Style string // 写作风格default/suspense/fantasy/romance
ContextWindow int // 模型上下文窗口大小token默认 128000
} }
// Prompts 嵌入的提示词。 // Prompts 嵌入的提示词。
type Prompts struct { type Prompts struct {
Coordinator string Coordinator string
Architect string ArchitectShort string
ArchitectMid string
ArchitectLong string
Writer string Writer string
Editor string Editor string
} }
@@ -36,7 +39,7 @@ func (c *Config) Validate() error {
// ValidateBase 校验基础配置TUI 模式下 Prompt 由用户输入,不在此检查)。 // ValidateBase 校验基础配置TUI 模式下 Prompt 由用户输入,不在此检查)。
func (c *Config) ValidateBase() error { func (c *Config) ValidateBase() error {
if c.APIKey == "" { 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 { switch c.Provider {
case "openai", "anthropic", "gemini", "openrouter": case "openai", "anthropic", "gemini", "openrouter":
@@ -70,4 +73,7 @@ func (c *Config) FillDefaults() {
if c.Style == "" { if c.Style == "" {
c.Style = "default" 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/domain"
"github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/state"
"github.com/voocel/ainovel-cli/tools" "github.com/voocel/ainovel-cli/tools"
"github.com/voocel/litellm"
) )
// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。 // 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 { if err := cfg.Validate(); err != nil {
return err return err
} }
log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir)
// 1. 初始化状态 // 1. 初始化状态
store := state.NewStore(cfg.OutputDir) 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) askUser.SetHandler(cliAskUserHandler)
// 4. 确定性控制面:事件监听 + FollowUp 注入 // 4. 确定性控制面:事件监听 + FollowUp 注入
registerSubscription(coordinator, store, nil, nil, nil) registerSubscription(coordinator, store, cfg.Provider, nil, nil, nil)
// 5. 初始化运行元信息(保留已有 SteerHistory // 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) 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) return fmt.Errorf("init progress: %w", err)
} }
log.Printf("新建模式:%s", cfg.NovelName) 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 { if err := coordinator.Prompt(promptText); err != nil {
return fmt.Errorf("prompt: %w", err) 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 转发。 // 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) { coordinator.Subscribe(func(ev agentcore.Event) {
switch ev.Type { switch ev.Type {
case agentcore.EventToolExecStart: case agentcore.EventToolExecStart:
@@ -128,46 +138,98 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
// 区分流式 delta 和进度摘要 // 区分流式 delta 和进度摘要
if delta, ok := parseStreamDelta(ev); ok { if delta, ok := parseStreamDelta(ev); ok {
if onDelta != nil { if onDelta != nil {
onDelta(delta) if text := subFilter.Feed(delta); text != "" {
onDelta(text)
}
} }
return return
} }
summary := parseProgressSummary(ev) summary := parseProgressSummary(ev)
if summary == "" {
return
}
if summary == lastProgressSummary {
return
}
lastProgressSummary = summary
log.Printf("[progress] %s", summary) log.Printf("[progress] %s", summary)
if emit != nil { if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"}) emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"})
} }
case agentcore.EventMessageStart: case agentcore.EventMessageStart:
// 新一轮 LLM 输出开始,清空流式缓冲 // 新一轮 LLM 输出开始,重置提取器 + 清空流式缓冲
agentExt.Reset()
taskExt.Reset()
subFilter.Reset()
if onClear != nil { if onClear != nil {
onClear() onClear()
} }
case agentcore.EventMessageUpdate: case agentcore.EventMessageUpdate:
// Coordinator 自身思考时的流式 token // Coordinator 的流式 token:先提取 agent 名称做标题,再提取 task 内容
if ev.Delta != "" && onDelta != nil { 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: case agentcore.EventToolExecEnd:
lastProgressSummary = ""
if ev.IsError { if ev.IsError {
detail := extractToolErrorText(ev.Result)
if detail != "" {
log.Printf("[tool:error] %s → %s", ev.Tool, detail)
} else {
log.Printf("[tool:error] %s", ev.Tool) log.Printf("[tool:error] %s", ev.Tool)
}
if emit != nil { 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 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)) log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200))
if emit != nil { if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".done", Level: "info"}) 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: case agentcore.EventMessageEnd:
if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant { if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant {
text := truncateLog(ev.Message.TextContent(), 300) text := truncateLog(ev.Message.TextContent(), 300)
@@ -178,14 +240,30 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit
} }
case agentcore.EventError: case agentcore.EventError:
log.Printf("[error] %v", ev.Err) log.Printf("[error][provider=%s] %v", provider, ev.Err)
if emit != nil { 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 共用)。 // submitSteer 提交用户干预CLI 和 Runtime 共用)。
func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) { func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) {
log.Printf("[steer] 用户干预: %s", text) 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 { if err := store.SetFlow(domain.FlowSteering); err != nil {
log.Printf("[warn] 设置流程状态失败: %v", err) 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( coordinator.Steer(agentcore.UserMsg(fmt.Sprintf(
"[用户干预] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。", text))) "%s", message)))
} }
// recoveryResult 恢复链的判断结果。 // recoveryResult 恢复链的判断结果。
@@ -215,18 +302,42 @@ type recoveryResult struct {
// determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。 // determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。
// 章节总数完全来自 Progress.TotalChapters由大纲自动设定不再由外部传入。 // 章节总数完全来自 Progress.TotalChapters由大纲自动设定不再由外部传入。
func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recoveryResult { func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recoveryResult {
if progress == nil { if progress == nil || progress.Phase == domain.PhaseInit {
return recoveryResult{IsNew: true} 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 { if progress.InProgressChapter > 0 {
ch := progress.InProgressChapter ch := progress.InProgressChapter
scenes := len(progress.CompletedScenes)
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", "第 %d 章正在进行中,已有部分草稿。请调用 writer 继续完成该章(可用 read_chapter 读取已有草稿)。总共需要写 %d 章。",
ch, scenes, scenes+1, progress.TotalChapters), ch, progress.TotalChapters)),
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes), Label: fmt.Sprintf("恢复:第 %d 章进行中", ch),
} }
} }
@@ -236,18 +347,18 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
verb = "打磨" verb = "打磨"
} }
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"有 %d 章待%s受影响章节%v。原因%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。", "有 %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), Label: fmt.Sprintf("%s恢复%d 章待处理 %v", verb, len(progress.PendingRewrites), progress.PendingRewrites),
} }
} }
if progress.Flow == domain.FlowReviewing { if progress.Flow == domain.FlowReviewing {
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。", "上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。",
len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters), len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters)),
Label: "审阅恢复:上次审阅中断", Label: "审阅恢复:上次审阅中断",
} }
} }
@@ -255,9 +366,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" { if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" {
next := progress.NextChapter() next := progress.NextChapter()
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围决定是否需要修改设定或重写已有章节。", "从第 %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 恢复:上次干预未完成,重新注入", Label: "Steer 恢复:上次干预未完成,重新注入",
} }
} }
@@ -265,9 +376,9 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
if progress.IsResumable() { if progress.IsResumable() {
next := progress.NextChapter() next := progress.NextChapter()
return recoveryResult{ return recoveryResult{
PromptText: fmt.Sprintf( PromptText: withGuidance(fmt.Sprintf(
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。", "从第 %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 字)", Label: fmt.Sprintf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)",
next, len(progress.CompletedChapters), progress.TotalWordCount), next, len(progress.CompletedChapters), progress.TotalWordCount),
} }
@@ -276,27 +387,80 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recov
return recoveryResult{IsNew: true} 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 调用完成后读取文件系统信号,注入确定性任务。 // 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() result, err := store.LoadLastCommit()
if err != nil || result == nil { if err != nil || result == nil {
return return false
} }
if err := store.ClearLastCommit(); err != nil { if err := store.ClearLastCommit(); err != nil {
log.Printf("[host] 清除 commit 信号失败: %v", err) log.Printf("[host] 清除 commit 信号失败: %v", err)
} }
log.Printf("[host] 章节提交信号:第 %d 章,%d 字%d 个场景", log.Printf("[host] 章节提交信号:第 %d 章,%d 字",
result.Chapter, result.WordCount, result.SceneCount) result.Chapter, result.WordCount)
if emit != nil { if emit != nil {
emit(UIEvent{ emit(UIEvent{
Time: time.Now(), Time: time.Now(),
Category: "SYSTEM", 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", 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正在重写/打磨流程中 // 确定性判断 0正在重写/打磨流程中
progress, _ := store.LoadProgress() progress, _ := store.LoadProgress()
if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) { 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( coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 当前处于重写流程,但提交了非队列章节(第 %d 章)。请先完成待重写章节 %v 后再继续新章节。", "[系统] 当前处于重写流程,但提交了非队列章节(第 %d 章)。请先完成待重写章节 %v 后再继续新章节。",
result.Chapter, progress.PendingRewrites))) result.Chapter, progress.PendingRewrites)))
return return true
} }
if err := store.CompleteRewrite(result.Chapter); err != nil { if err := store.CompleteRewrite(result.Chapter); err != nil {
log.Printf("[host] 完成重写标记失败: %v", err) 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) log.Printf("[host] 还有 %d 章待处理:%v", len(updated.PendingRewrites), updated.PendingRewrites)
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) 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 由大纲自动设定) // 确定性判断 1全书完成TotalChapters 由大纲自动设定)
@@ -344,7 +566,7 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。", "[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。",
totalChapters))) totalChapters)))
return return true
} }
// 确定性判断 2需要全局审阅 // 确定性判断 2需要全局审阅
@@ -362,6 +584,37 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit e
} }
clearHandledSteer(store) clearHandledSteer(store)
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) 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 完成后读取审阅信号。 // 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] 清除审阅信号失败: %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 := "" chaptersInfo := ""
if len(review.AffectedChapters) > 0 { if len(review.AffectedChapters) > 0 {
@@ -464,12 +725,21 @@ func parseProgressSummary(ev agentcore.Event) string {
Tool string `json:"tool"` Tool string `json:"tool"`
Turn int `json:"turn"` Turn int `json:"turn"`
Error bool `json:"error"` Error bool `json:"error"`
Message string `json:"message"`
Thinking string `json:"thinking"`
} }
if err := json.Unmarshal(ev.Result, &data); err != nil { if err := json.Unmarshal(ev.Result, &data); err != nil {
return truncateLog(string(ev.Result), 60) return truncateLog(string(ev.Result), 60)
} }
// subagent 的 thinking 更新属于高频内部推理,不适合刷到事件流面板。
if data.Thinking != "" && data.Tool == "" {
return ""
}
if data.Tool != "" { if data.Tool != "" {
if data.Error { 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 (error)", data.Agent, data.Tool)
} }
return fmt.Sprintf("%s → %s", 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) 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 { func truncateLog(s string, maxRunes int) string {
runes := []rune(s) runes := []rune(s)
if len(runes) <= maxRunes { if len(runes) <= maxRunes {
@@ -540,11 +911,25 @@ func createModel(cfg Config) (agentcore.ChatModel, error) {
return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...) return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...)
case "gemini": case "gemini":
return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...) 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...) 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 模式下的交互式选择器,上下键选择,回车确认。 // cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。
func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) { func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
resp := &tools.AskUserResponse{ resp := &tools.AskUserResponse{

View File

@@ -1,8 +1,11 @@
package app package app
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/voocel/agentcore"
"github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state" "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) 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 渲染所需的聚合状态快照。 // UISnapshot 是 TUI 渲染所需的聚合状态快照。
type UISnapshot struct { type UISnapshot struct {
Provider string
NovelName string NovelName string
ModelName string ModelName string
Style string Style string
@@ -36,7 +37,6 @@ type UISnapshot struct {
CompletedCount int CompletedCount int
TotalWordCount int TotalWordCount int
InProgressChapter int InProgressChapter int
CompletedScenes int
PendingRewrites []int PendingRewrites []int
RewriteReason string RewriteReason string
PendingSteer 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 { if err := cfg.ValidateBase(); err != nil {
return nil, err 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) store := state.NewStore(cfg.OutputDir)
if err := store.Init(); err != nil { if err := store.Init(); err != nil {
@@ -119,10 +120,10 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s
} }
// 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发 // 注册事件订阅:确定性控制 + 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) log.Printf("[warn] 初始化运行元信息失败: %v", err)
} }
@@ -196,7 +197,10 @@ func (rt *Runtime) Start(prompt string) error {
return fmt.Errorf("init progress: %w", err) 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 { if err := rt.coordinator.Prompt(promptText); err != nil {
return fmt.Errorf("prompt: %w", err) return fmt.Errorf("prompt: %w", err)
} }
@@ -249,6 +253,7 @@ func (rt *Runtime) Steer(text string) {
func (rt *Runtime) Snapshot() UISnapshot { func (rt *Runtime) Snapshot() UISnapshot {
snap := UISnapshot{ snap := UISnapshot{
NovelName: rt.cfg.NovelName, NovelName: rt.cfg.NovelName,
Provider: rt.cfg.Provider,
ModelName: rt.cfg.ModelName, ModelName: rt.cfg.ModelName,
Style: rt.cfg.Style, Style: rt.cfg.Style,
} }
@@ -266,7 +271,6 @@ func (rt *Runtime) Snapshot() UISnapshot {
snap.CompletedCount = len(progress.CompletedChapters) snap.CompletedCount = len(progress.CompletedChapters)
snap.TotalWordCount = progress.TotalWordCount snap.TotalWordCount = progress.TotalWordCount
snap.InProgressChapter = progress.InProgressChapter snap.InProgressChapter = progress.InProgressChapter
snap.CompletedScenes = len(progress.CompletedScenes)
snap.PendingRewrites = progress.PendingRewrites snap.PendingRewrites = progress.PendingRewrites
snap.RewriteReason = progress.RewriteReason 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 ( import (
"fmt" "fmt"
"strings"
"unicode/utf8" "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 章触发一次)。 // ReviewInterval 全局审阅间隔(每 N 章触发一次)。
const ReviewInterval = 5 const ReviewInterval = 5
// ShouldReview 根据已完成章节数判断是否需要全局审阅。 // ShouldReview 根据已完成章节数判断是否需要全局审阅(短篇/中篇模式)
func ShouldReview(completedCount int) (bool, string) { func ShouldReview(completedCount int) (bool, string) {
if completedCount > 0 && completedCount%ReviewInterval == 0 { if completedCount > 0 && completedCount%ReviewInterval == 0 {
return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount) return true, fmt.Sprintf("已完成 %d 章,触发全局审阅", completedCount)
} }
return false, "" 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 一致性问题。 // ConsistencyIssue 一致性问题。
type ConsistencyIssue struct { type ConsistencyIssue struct {
Type string `json:"type"` // timeline / foreshadow / relationship / character Type string `json:"type"` // consistency / character / pacing / continuity / foreshadow / hook
Severity string `json:"severity"` // error / warning Severity string `json:"severity"` // critical / error / warning
Description string `json:"description"` Description string `json:"description"`
Suggestion string `json:"suggestion,omitempty"` 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 的审阅条目。 // ReviewEntry Editor 的审阅条目。
type ReviewEntry struct { type ReviewEntry struct {
Chapter int `json:"chapter"` Chapter int `json:"chapter"`
Scope string `json:"scope"` // chapter / global Scope string `json:"scope"` // chapter / global / arc
Issues []ConsistencyIssue `json:"issues"` Issues []ConsistencyIssue `json:"issues"`
Dimensions []DimensionScore `json:"dimensions,omitempty"` // 分维度评分
Verdict string `json:"verdict"` // accept / polish / rewrite Verdict string `json:"verdict"` // accept / polish / rewrite
Summary string `json:"summary"` Summary string `json:"summary"`
AffectedChapters []int `json:"affected_chapters,omitempty"` // 需要重写/打磨的章节号 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" FlowSteering FlowState = "steering"
) )
// PlanningTier 表示作品规划的长度级别。
type PlanningTier string
const (
PlanningTierShort PlanningTier = "short"
PlanningTierMid PlanningTier = "mid"
PlanningTierLong PlanningTier = "long"
)
// Progress 进度追踪,持久化到 meta/progress.json。 // Progress 进度追踪,持久化到 meta/progress.json。
type Progress struct { type Progress struct {
NovelName string `json:"novel_name"` NovelName string `json:"novel_name"`
@@ -38,6 +47,10 @@ type Progress struct {
RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因
StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand
HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type 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 判断是否可以从断点恢复。 // IsResumable 判断是否可以从断点恢复。
@@ -59,11 +72,32 @@ func (p *Progress) NextChapter() int {
return max + 1 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。 // RunMeta 运行元信息,持久化到 meta/run.json。
type RunMeta struct { type RunMeta struct {
StartedAt string `json:"started_at"` StartedAt string `json:"started_at"`
Provider string `json:"provider,omitempty"`
Style string `json:"style"` Style string `json:"style"`
Model string `json:"model"` Model string `json:"model"`
PlanningTier PlanningTier `json:"planning_tier,omitempty"`
SteerHistory []SteerEntry `json:"steer_history,omitempty"` SteerHistory []SteerEntry `json:"steer_history,omitempty"`
PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入 PendingSteer string `json:"pending_steer,omitempty"` // 未完成的 Steer 指令,中断恢复时重新注入
} }

View File

@@ -18,6 +18,7 @@ type OutlineEntry struct {
// Character 角色档案。 // Character 角色档案。
type Character struct { type Character struct {
Name string `json:"name"` Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"` // 别名/称号/绰号(如"废物少年"、"炎哥"
Role string `json:"role"` Role string `json:"role"`
Description string `json:"description"` Description string `json:"description"`
Arc string `json:"arc"` Arc string `json:"arc"`
@@ -25,6 +26,49 @@ type Character struct {
Tier string `json:"tier,omitempty"` // core / important / secondary / decorative默认 important 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 世界观规则条目。 // WorldRule 世界观规则条目。
type WorldRule struct { type WorldRule struct {
Category string `json:"category"` // magic / technology / geography / society / other 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 package domain
// ChapterPlan 章节规划,写入 drafts/{ch}.plan.json // ChapterPlan 章节写作构思Writer 自主生成
// 不再强制场景拆分Agent 自己决定如何组织内容。
type ChapterPlan struct { type ChapterPlan struct {
Chapter int `json:"chapter"` Chapter int `json:"chapter"`
Title string `json:"title"` Title string `json:"title"`
Goal string `json:"goal"` Goal string `json:"goal"`
Conflict string `json:"conflict"` Conflict string `json:"conflict"`
Scenes []ScenePlan `json:"scenes"`
Hook string `json:"hook"` Hook string `json:"hook"`
EmotionArc string `json:"emotion_arc,omitempty"` EmotionArc string `json:"emotion_arc,omitempty"`
} Notes string `json:"notes,omitempty"` // Agent 的自由备忘
// ScenePlan 场景规划。
type ScenePlan struct {
Index int `json:"index"`
Summary string `json:"summary"`
POV string `json:"pov,omitempty"`
Location string `json:"location,omitempty"`
}
// SceneDraft 场景草稿。
type SceneDraft struct {
Chapter int `json:"chapter"`
Scene int `json:"scene"`
Content string `json:"content"`
WordCount int `json:"word_count"`
} }
// ChapterSummary 章节摘要,供后续章节的上下文窗口使用。 // ChapterSummary 章节摘要,供后续章节的上下文窗口使用。
@@ -35,16 +20,55 @@ type ChapterSummary struct {
KeyEvents []string `json:"key_events"` 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 工具的结构化返回值。 // CommitResult 是 commit_chapter 工具的结构化返回值。
// 宿主程序和 Coordinator 读取此信号做控制决策。 // 宿主程序和 Coordinator 读取此信号做控制决策。
type CommitResult struct { type CommitResult struct {
Chapter int `json:"chapter"` Chapter int `json:"chapter"`
Committed bool `json:"committed"` Committed bool `json:"committed"`
WordCount int `json:"word_count"` WordCount int `json:"word_count"`
SceneCount int `json:"scene_count"`
NextChapter int `json:"next_chapter"` NextChapter int `json:"next_chapter"`
ReviewRequired bool `json:"review_required"` ReviewRequired bool `json:"review_required"`
ReviewReason string `json:"review_reason,omitempty"` ReviewReason string `json:"review_reason,omitempty"`
HookType string `json:"hook_type,omitempty"` // 钩子类型crisis/mystery/desire/emotion/choice HookType string `json:"hook_type,omitempty"`
DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线quest/fire/constellation 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 module github.com/voocel/ainovel-cli
go 1.25.5 go 1.25
require ( require (
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 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 ( require (
@@ -28,8 +29,9 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // 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 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // 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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/litellm v1.6.2 h1:TJ1s7B7UqgV86O1EcuwQTZua0FK1tbOg0+oUsDmgmuA=
github.com/voocel/agentcore v1.5.1/go.mod h1:fjksENApgfL1QXbcJY8RUUU5Gl03YOYExFAZ040X/zU= github.com/voocel/litellm v1.6.2/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4=
github.com/voocel/litellm v1.6.0 h1:jc0Y7q+cp6QQcag3Mhmd6wMKkfzf7mXjXY0Uvj5VBQw= github.com/voocel/litellm v1.6.3 h1:FKHx+XQbXCZVvjnnMk2kuJ5dyXuXa8j5MVStWL7NaQs=
github.com/voocel/litellm v1.6.0/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=

207
main.go
View File

@@ -1,9 +1,12 @@
package main package main
import ( import (
"bufio"
"embed" "embed"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"strconv"
"strings" "strings"
"github.com/voocel/ainovel-cli/app" "github.com/voocel/ainovel-cli/app"
@@ -21,6 +24,12 @@ var referencesFS embed.FS
var stylesFS embed.FS var stylesFS embed.FS
func main() { func main() {
// 处理 start 子命令
if len(os.Args) > 1 && os.Args[1] == "start" {
handleStartCommand()
return
}
style := envOr("NOVEL_STYLE", "default") style := envOr("NOVEL_STYLE", "default")
refs := loadReferences(style) refs := loadReferences(style)
prompts := loadPrompts() prompts := loadPrompts()
@@ -62,11 +71,11 @@ func buildConfig(style string) app.Config {
} }
cfg := app.Config{ cfg := app.Config{
NovelName: "novel", NovelName: envOr("NOVEL_NAME", ""),
Provider: provider, Provider: provider,
APIKey: apiKey, APIKey: apiKey,
BaseURL: baseURL, BaseURL: baseURL,
ModelName: "stepfun/step-3.5-flash:free", ModelName: envOr("LLM_MODEL", "stepfun/step-3.5-flash:free"),
Style: style, Style: style,
} }
return cfg return cfg
@@ -76,9 +85,58 @@ func parsePrompt() string {
if len(os.Args) < 2 { if len(os.Args) < 2 {
return "" return ""
} }
if os.Args[1] == "-help" || os.Args[1] == "--help" || os.Args[1] == "-h" {
printHelp()
os.Exit(0)
}
return strings.Join(os.Args[1:], " ") 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 { func loadReferences(style string) tools.References {
refs := tools.References{ refs := tools.References{
ChapterGuide: mustRead(referencesFS, "references/chapter-guide.md"), ChapterGuide: mustRead(referencesFS, "references/chapter-guide.md"),
@@ -90,6 +148,8 @@ func loadReferences(style string) tools.References {
Consistency: mustRead(referencesFS, "references/consistency.md"), Consistency: mustRead(referencesFS, "references/consistency.md"),
ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"), ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"),
DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.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" { if style != "" && style != "default" {
path := "references/" + style + "/style-references.md" path := "references/" + style + "/style-references.md"
@@ -103,7 +163,9 @@ func loadReferences(style string) tools.References {
func loadPrompts() app.Prompts { func loadPrompts() app.Prompts {
return app.Prompts{ return app.Prompts{
Coordinator: mustRead(promptsFS, "prompts/coordinator.md"), Coordinator: mustRead(promptsFS, "prompts/coordinator.md"),
Architect: mustRead(promptsFS, "prompts/architect.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"), Writer: mustRead(promptsFS, "prompts/writer.md"),
Editor: mustRead(promptsFS, "prompts/editor.md"), Editor: mustRead(promptsFS, "prompts/editor.md"),
} }
@@ -143,3 +205,142 @@ func envOr(key, fallback string) string {
} }
return fallback 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**: 检查当前创作状态 - **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 ```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 处理: 收到 `[系统] Editor 审阅结论` 消息后,按 verdict 处理:
- **accept**: 继续写下一章 - **accept**: 继续写下一章
- **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨。每次调用: - **polish**: 按消息中的受影响章节列表,逐章调用 writer 打磨
```json - **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写
{"agent": "writer", "task": "打磨第 N 章。审阅意见:<summary>"}
```
- **rewrite**: 按消息中的受影响章节列表,逐章调用 writer 重写。每次调用:
```json
{"agent": "writer", "task": "重写第 N 章。重写原因:<summary>"}
```
重写完成后回到正常写作流程,继续写下一个未完成章节
**重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。中断退出后重启会自动恢复到重写状态。 **重要约束**:受影响章节必须全部重写/打磨完成后,才能继续写新章节。
### 系统消息 ### 系统消息
@@ -59,7 +118,7 @@ architect 完成后,用 novel_context 确认设定已保存。
- **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer - **全书完成**:收到 `[系统] 全部 N 章已写完` 后,输出全书总结并结束,不再调用 writer
- **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅 - **审阅提示**:收到 `[系统] review_required` 后,调用 editor 进行审阅
你必须遵守系统消息中的确定性指令(如"不要再调用 writer" 你必须遵守系统消息中的确定性指令。
### 第四阶段:完成 ### 第四阶段:完成
@@ -73,26 +132,41 @@ architect 完成后,用 novel_context 确认设定已保存。
收到 `[用户干预]` 消息后: 收到 `[用户干预]` 消息后:
1. **评估影响范围**:判断用户的修改要求影响哪些内容 1. 评估影响范围
2. **更新设定**(如需要):调用 architect 更新 premise、outline 或 characters 2. 如需更新设定,调用与当前作品长度级别一致的规划师进行增量修改
```json 3. 如需重写已完成章节,逐章调用 writer 重写
{"agent": "architect", "task": "用户要求修改:<干预内容>。请在现有设定基础上做增量修改,保持已完成章节的一致性。"} 4. 从下一个未完成章节继续
```
3. **重写章节**(如需要):如果已完成章节受到影响,逐章调用 writer 重写 如果当前作品已经采用 layered_outline不要在修改时退化成短篇式 outline 思路。
4. **继续写作**:从下一个未完成章节继续
### Writer 大纲反馈
收到 `[系统] Writer 在第 N 章写作中发现大纲偏离` 消息后:
1. 评估反馈是否合理(角色变得更有魅力?支线更有趣?大纲走向不对?)
2. 如果认为值得采纳,调用对应级别的规划师进行增量修改
3. 如果认为不需要调整,忽略并继续
4. 不要因为 Writer 的一次反馈就大幅推翻已有规划
## 恢复指示 ## 恢复指示
- 收到"从第 N 章继续写作"的指示:跳过第一阶段,直接从第 N 章开始逐章写作 - 收到从第 N 章继续写作的指示:跳过第一阶段,直接从第 N 章开始逐章写作
- 收到"第 N 章正在进行中,已完成 M 个场景"的指示:调用 writer 从场景 M+1 继续该章写作 - 收到第 N 章正在进行中的指示:调用 writer 继续完成该章writer 可用 read_chapter 读取已有草稿)
- 收到"有 N 章待重写"的指示:逐章调用 writer 重写/打磨受影响章节,**全部完成后**才能继续写新章节 - 收到有 N 章待重写的指示:逐章调用 writer 重写/打磨受影响章节,全部完成后才能继续写新章节
- 收到"上次审阅中断"的指示:重新调用 editor 进行全局审阅 - 收到上次审阅中断的指示:重新调用 editor 进行全局审阅
## 注意事项 ## 长篇模式(分层大纲)
- 不要自己写正文,正文由 writer 完成 当系统消息包含“弧结束”或“卷结束”信号时,执行以下工作流:
- 不要自己创建设定,设定由 architect 完成
- 不要自己做审阅,审阅由 editor 完成 ### 弧结束处理
- 你的职责是调度和决策,不是创作 收到 `[系统] 第 V 卷第 A 弧结束` 消息后:
- 章节完成/全书终止的判断由宿主程序通过系统消息控制 1. 调用 editor 进行弧级评审
- 重写章节时writer 的流程与新写相同,旧文件会自动覆盖 2. 调用 editor 生成弧摘要和角色快照
3. 继续写下一弧的章节
### 卷结束处理
收到 `[系统] 第 V 卷第 A 弧结束(卷结束)` 消息后:
1. 先完成弧结束处理
2. 额外调用 editor 生成卷摘要
3. 继续写下一卷的章节

View File

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

View File

@@ -1,81 +1,81 @@
你是小说场景写作者。你负责逐场景地完成一章的创作 你是小说作者。你负责自主完成一章的构思、写作、自审和提交
## 你的工具 ## 你的工具
- **novel_context**: 获取当前章节的创作上下文 - **novel_context**: 获取当前章节的创作上下文(设定、前情、角色、伏笔、时间线)
- **plan_chapter**: 创建章节写作规划 - **read_chapter**: 回读任意章节原文、草稿,或提取角色对话片段
- **write_scene**: 写入单个场景 - **plan_chapter**: 保存你的章节构思
- **polish_chapter**: 保存打磨后的完整章节正文 - **draft_chapter**: 写入章节正文(整章或续写)
- **check_consistency**: 检查章节与全局状态的一致性 - **check_consistency**: 加载状态数据,供你对照检查一致性
- **commit_chapter**: 提交完成的章节 - **commit_chapter**: 提交完成的章节
## 写作流水线 ## 你的自主权
严格按以下顺序执行,不可跳步 你可以按任何顺序使用工具,只要最终提交一章高质量的正文。以下是建议流程,但不是强制流程
### 1. 获取上下文 ### 建议流程
调用 novel_context(chapter=N) 获取:
- 故事前提、大纲、角色档案
- 前几章摘要
- 时间线、伏笔账本、人物关系(用于保持一致性)
- 写作参考资料
### 2. 规划章节 1. **读上下文** — 调用 novel_context(chapter=N) 了解前情、大纲、角色、伏笔
调用 plan_chapter基于大纲拆分为 3-5 个场景,明确每个场景的目标、视角和地点。 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. 打磨章节 ### 去 AI 味
将所有场景合并,进行整体打磨,然后调用 polish_chapter 保存: - 不用"不禁"、"竟然"、"仿佛"、"此外"、"然而"等滥用词
- **去 AI 味**:不用"不禁"、"竟然"、"仿佛"等滥用词,不用排比三连,控制形容词密度 - 不用排比三连、四字成语堆砌
- **对话自然化**:体现人物性格差异,加入潜台词和动作穿插 - 句式多样化,长短交错
- **细节具象化**:用五感描写替代抽象概述
- **节奏调整**:关键转折放慢,过渡段落紧凑
### 5. 一致性检查 ### 节奏
调用 check_consistency(chapter=N),检查是否有矛盾: - 关键转折放慢,过渡段落紧凑
- 如果发现 error 级别问题,回到第 3 步修正相关场景,重新打磨 - 章内有紧张-缓解-新紧张的呼吸感
- 如果只有 warning记录后继续
### 6. 提交章节
调用 commit_chapter提供
- summary: 本章内容摘要200字以内
- characters: 本章出场角色名列表
- key_events: 本章关键事件列表
- timeline_events: 本章发生的时间线事件
- foreshadow_updates: 伏笔操作plant 埋设 / advance 推进 / resolve 回收)
- relationship_changes: 人物关系变化
## 重写模式
当任务中包含"重写"或"打磨"指令时:
- 流水线与新写完全相同context → plan → write_scene × N → polish → consistency → commit
- 旧的 plan、scene、polished 文件会被自然覆盖
- commit_chapter 会自动修正字数统计
- 重点关注审阅意见中指出的问题,确保修正到位
## 场景恢复模式
当任务中提到"从场景 M 继续"时:
- 调用 novel_context 获取上下文
- 检查已有的 chapter plan 和已完成场景
- 跳过已完成的场景,从指定场景编号开始写作
- 后续流程不变:完成所有场景 → polish → consistency → commit
## 注意事项
- 严格场景级写作,一次只写一个场景
- 不要整章一起写然后拆分
- 章末必须有悬念钩子 - 章末必须有悬念钩子
- 保持与前几章的连贯性
## 字数要求
- 每章 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 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 { func renderCharacters(chars []domain.Character) string {
var b strings.Builder var b strings.Builder
b.WriteString("# 角色档案\n\n") b.WriteString("# 角色档案\n\n")

View File

@@ -3,21 +3,19 @@ package state
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath" "regexp"
"sort"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/domain"
) )
// SaveChapterPlan 保存章节规划到 drafts/{ch}.plan.json。 // SaveChapterPlan 保存章节构思到 drafts/{ch}.plan.json。
func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error { func (s *Store) SaveChapterPlan(plan domain.ChapterPlan) error {
return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan) return s.writeJSON(fmt.Sprintf("drafts/%02d.plan.json", plan.Chapter), plan)
} }
// LoadChapterPlan 读取章节规划 // LoadChapterPlan 读取章节构思
func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) { func (s *Store) LoadChapterPlan(chapter int) (*domain.ChapterPlan, error) {
var plan domain.ChapterPlan var plan domain.ChapterPlan
if err := s.readJSON(fmt.Sprintf("drafts/%02d.plan.json", chapter), &plan); err != nil { 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 return &plan, nil
} }
// SaveSceneDraft 保存场景草稿到 drafts/{ch}.scene-{n}.md。 // SaveDraft 保存整章草稿到 drafts/{ch}.draft.md。
func (s *Store) SaveSceneDraft(draft domain.SceneDraft) error { func (s *Store) SaveDraft(chapter int, content string) error {
rel := fmt.Sprintf("drafts/%02d.scene-%d.md", draft.Chapter, draft.Scene) return s.writeMarkdown(fmt.Sprintf("drafts/%02d.draft.md", chapter), content)
return s.writeMarkdown(rel, draft.Content)
} }
// LoadSceneDrafts 加载指定章节的所有场景草稿,按场景编号排序 // AppendDraft 追加内容到现有草稿(续写模式)
func (s *Store) LoadSceneDrafts(chapter int) ([]domain.SceneDraft, error) { func (s *Store) AppendDraft(chapter int, content string) error {
pattern := filepath.Join(s.dir, "drafts", fmt.Sprintf("%02d.scene-*.md", chapter)) rel := fmt.Sprintf("drafts/%02d.draft.md", chapter)
matches, err := filepath.Glob(pattern) existing, err := s.readFile(rel)
if err != nil { if err != nil && !os.IsNotExist(err) {
return nil, err return err
} }
sort.Strings(matches) var merged string
if len(existing) > 0 {
var drafts []domain.SceneDraft merged = string(existing) + "\n\n" + content
for _, m := range matches { } else {
base := filepath.Base(m) merged = content
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{ return s.writeMarkdown(rel, merged)
Chapter: chapter,
Scene: sceneNum,
Content: string(content),
WordCount: utf8.RuneCountInString(string(content)),
})
}
return drafts, nil
} }
// SavePolished 保存打磨后的章节正文到 drafts/{ch}.polished.md // LoadDraft 读取整章草稿
func (s *Store) SavePolished(chapter int, content string) error { func (s *Store) LoadDraft(chapter int) (string, error) {
return s.writeMarkdown(fmt.Sprintf("drafts/%02d.polished.md", chapter), content) data, err := s.readFile(fmt.Sprintf("drafts/%02d.draft.md", chapter))
}
// LoadPolished 读取打磨后的章节正文。不存在时返回空字符串。
func (s *Store) LoadPolished(chapter int) (string, error) {
data, err := s.readFile(fmt.Sprintf("drafts/%02d.polished.md", chapter))
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", nil return "", nil
} }
@@ -79,21 +60,16 @@ func (s *Store) LoadPolished(chapter int) (string, error) {
return string(data), nil return string(data), nil
} }
// LoadChapterContent 加载章节正文:优先 polished否则 merge scenes // LoadChapterContent 加载章节草稿正文及字数
func (s *Store) LoadChapterContent(chapter int) (string, int, error) { func (s *Store) LoadChapterContent(chapter int) (string, int, error) {
polished, err := s.LoadPolished(chapter) draft, err := s.LoadDraft(chapter)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
if polished != "" { if draft != "" {
return polished, utf8.RuneCountInString(polished), nil return draft, utf8.RuneCountInString(draft), nil
} }
drafts, err := s.LoadSceneDrafts(chapter) return "", 0, nil
if err != nil {
return "", 0, err
}
content, wc := domain.MergeScenes(drafts)
return content, wc, nil
} }
// SaveFinalChapter 保存最终章节正文到 chapters/{ch}.md。 // 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) return s.writeMarkdown(fmt.Sprintf("chapters/%02d.md", chapter), content)
} }
// parseSceneNum 从文件名如 "01.scene-2.md" 提取场景编号 // LoadChapterText 读取已提交的终稿原文
func parseSceneNum(filename string) int { func (s *Store) LoadChapterText(chapter int) (string, error) {
// 格式:{ch}.scene-{n}.md data, err := s.readFile(fmt.Sprintf("chapters/%02d.md", chapter))
parts := strings.Split(filename, "scene-") if os.IsNotExist(err) {
if len(parts) < 2 { return "", nil
return 0
} }
numStr := strings.TrimSuffix(parts[1], ".md") if err != nil {
n, _ := strconv.Atoi(numStr) return "", err
return n }
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) 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 { func renderOutline(entries []domain.OutlineEntry) string {
var b strings.Builder var b strings.Builder
b.WriteString("# 大纲\n\n") b.WriteString("# 大纲\n\n")

View File

@@ -10,8 +10,14 @@ import (
// LoadProgress 读取 meta/progress.json。不存在时返回 nil。 // LoadProgress 读取 meta/progress.json。不存在时返回 nil。
func (s *Store) LoadProgress() (*domain.Progress, error) { 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 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) { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
@@ -22,7 +28,13 @@ func (s *Store) LoadProgress() (*domain.Progress, error) {
// SaveProgress 保存进度到 meta/progress.json。 // SaveProgress 保存进度到 meta/progress.json。
func (s *Store) SaveProgress(p *domain.Progress) error { 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 创建初始进度。 // InitProgress 创建初始进度。
@@ -36,7 +48,8 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error {
// SetTotalChapters 根据大纲长度设定总章节数。 // SetTotalChapters 根据大纲长度设定总章节数。
func (s *Store) SetTotalChapters(n int) error { func (s *Store) SetTotalChapters(n int) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -44,12 +57,14 @@ func (s *Store) SetTotalChapters(n int) error {
p = &domain.Progress{} p = &domain.Progress{}
} }
p.TotalChapters = n p.TotalChapters = n
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// UpdatePhase 更新创作阶段。 // UpdatePhase 更新创作阶段。
func (s *Store) UpdatePhase(phase domain.Phase) error { func (s *Store) UpdatePhase(phase domain.Phase) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -57,14 +72,16 @@ func (s *Store) UpdatePhase(phase domain.Phase) error {
p = &domain.Progress{} p = &domain.Progress{}
} }
p.Phase = phase p.Phase = phase
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// MarkChapterComplete 标记章节完成,原子性更新进度。 // MarkChapterComplete 标记章节完成,原子性更新进度。
// 支持重写场景:如果章节已完成,先减去旧字数再加新字数。 // 支持重写场景:如果章节已完成,先减去旧字数再加新字数。
// hookType 和 dominantStrand 用于节奏追踪,可为空。 // hookType 和 dominantStrand 用于节奏追踪,可为空。
func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error { func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -113,7 +130,8 @@ func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantSt
} }
} }
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// MarkComplete 标记全书创作完成。 // MarkComplete 标记全书创作完成。
@@ -139,30 +157,10 @@ func (s *Store) LoadLastCommit() (*domain.CommitResult, error) {
return &r, nil return &r, nil
} }
// MarkSceneComplete 标记场景完成,用于场景级 checkpoint // ClearInProgress 清除进度中间状态(章节提交后调用)
// 切换到不同章节时自动清空旧的 CompletedScenes。
func (s *Store) MarkSceneComplete(chapter, scene int) error {
p, err := s.LoadProgress()
if err != nil {
return err
}
if p == nil {
return fmt.Errorf("progress not initialized, call InitProgress first")
}
// 章节切换:清空旧场景列表
if p.InProgressChapter != chapter {
p.CompletedScenes = nil
}
p.InProgressChapter = chapter
if !slices.Contains(p.CompletedScenes, scene) {
p.CompletedScenes = append(p.CompletedScenes, scene)
}
return s.SaveProgress(p)
}
// ClearInProgress 清除场景级进度状态(章节提交后调用)。
func (s *Store) ClearInProgress() error { func (s *Store) ClearInProgress() error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -171,7 +169,8 @@ func (s *Store) ClearInProgress() error {
} }
p.InProgressChapter = 0 p.InProgressChapter = 0
p.CompletedScenes = nil p.CompletedScenes = nil
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// ClearLastCommit 清除 commit 信号文件,防止重复消费。 // ClearLastCommit 清除 commit 信号文件,防止重复消费。
@@ -179,9 +178,41 @@ func (s *Store) ClearLastCommit() error {
return s.removeFile("meta/last_commit.json") 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 更新当前流程状态。 // SetFlow 更新当前流程状态。
func (s *Store) SetFlow(flow domain.FlowState) error { func (s *Store) SetFlow(flow domain.FlowState) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -189,12 +220,14 @@ func (s *Store) SetFlow(flow domain.FlowState) error {
return nil return nil
} }
p.Flow = flow p.Flow = flow
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// SetPendingRewrites 设置待重写章节队列和原因。 // SetPendingRewrites 设置待重写章节队列和原因。
func (s *Store) SetPendingRewrites(chapters []int, reason string) error { func (s *Store) SetPendingRewrites(chapters []int, reason string) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -203,13 +236,15 @@ func (s *Store) SetPendingRewrites(chapters []int, reason string) error {
} }
p.PendingRewrites = chapters p.PendingRewrites = chapters
p.RewriteReason = reason p.RewriteReason = reason
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// CompleteRewrite 从待重写队列中移除已完成的章节。 // CompleteRewrite 从待重写队列中移除已完成的章节。
// 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。 // 队列清空时自动将 Flow 重置为 writing 并清除 RewriteReason。
func (s *Store) CompleteRewrite(chapter int) error { func (s *Store) CompleteRewrite(chapter int) error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -227,12 +262,14 @@ func (s *Store) CompleteRewrite(chapter int) error {
p.Flow = domain.FlowWriting p.Flow = domain.FlowWriting
p.RewriteReason = "" p.RewriteReason = ""
} }
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// ClearPendingRewrites 强制清空重写队列。 // ClearPendingRewrites 强制清空重写队列。
func (s *Store) ClearPendingRewrites() error { func (s *Store) ClearPendingRewrites() error {
p, err := s.LoadProgress() return s.withWriteLock(func() error {
p, err := s.loadProgressUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -242,7 +279,8 @@ func (s *Store) ClearPendingRewrites() error {
p.PendingRewrites = nil p.PendingRewrites = nil
p.RewriteReason = "" p.RewriteReason = ""
p.Flow = domain.FlowWriting p.Flow = domain.FlowWriting
return s.SaveProgress(p) return s.saveProgressUnlocked(p)
})
} }
// ValidateChapterCommit 校验当前章节是否允许提交。 // ValidateChapterCommit 校验当前章节是否允许提交。

View File

@@ -10,13 +10,21 @@ import (
// SaveRunMeta 保存运行元信息到 meta/run.json。 // SaveRunMeta 保存运行元信息到 meta/run.json。
func (s *Store) SaveRunMeta(meta domain.RunMeta) error { 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 读取运行元信息。 // LoadRunMeta 读取运行元信息。
func (s *Store) LoadRunMeta() (*domain.RunMeta, error) { 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 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) { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
@@ -25,24 +33,36 @@ func (s *Store) LoadRunMeta() (*domain.RunMeta, error) {
return &meta, nil return &meta, nil
} }
func (s *Store) saveRunMetaUnlocked(meta domain.RunMeta) error {
return s.writeJSONUnlocked("meta/run.json", meta)
}
// InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。 // InitRunMeta 初始化或更新运行元信息,保留已有的 SteerHistory。
func (s *Store) InitRunMeta(style, model string) error { func (s *Store) InitRunMeta(style, provider, model string) error {
existing, _ := s.LoadRunMeta() return s.withWriteLock(func() error {
existing, err := s.loadRunMetaUnlocked()
if err != nil {
return err
}
meta := domain.RunMeta{ meta := domain.RunMeta{
StartedAt: time.Now().Format(time.RFC3339), StartedAt: time.Now().Format(time.RFC3339),
Provider: provider,
Style: style, Style: style,
Model: model, Model: model,
} }
if existing != nil { if existing != nil {
meta.SteerHistory = existing.SteerHistory meta.SteerHistory = existing.SteerHistory
meta.PendingSteer = existing.PendingSteer meta.PendingSteer = existing.PendingSteer
meta.PlanningTier = existing.PlanningTier
} }
return s.SaveRunMeta(meta) return s.saveRunMetaUnlocked(meta)
})
} }
// AppendSteerEntry 追加用户干预记录到 meta/run.json。 // AppendSteerEntry 追加用户干预记录到 meta/run.json。
func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error { func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error {
meta, err := s.LoadRunMeta() return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -50,12 +70,14 @@ func (s *Store) AppendSteerEntry(entry domain.SteerEntry) error {
meta = &domain.RunMeta{} meta = &domain.RunMeta{}
} }
meta.SteerHistory = append(meta.SteerHistory, entry) meta.SteerHistory = append(meta.SteerHistory, entry)
return s.SaveRunMeta(*meta) return s.saveRunMetaUnlocked(*meta)
})
} }
// SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。 // SetPendingSteer 记录未完成的 Steer 指令,用于中断恢复。
func (s *Store) SetPendingSteer(input string) error { func (s *Store) SetPendingSteer(input string) error {
meta, err := s.LoadRunMeta() return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -63,12 +85,14 @@ func (s *Store) SetPendingSteer(input string) error {
meta = &domain.RunMeta{} meta = &domain.RunMeta{}
} }
meta.PendingSteer = input meta.PendingSteer = input
return s.SaveRunMeta(*meta) return s.saveRunMetaUnlocked(*meta)
})
} }
// ClearPendingSteer 清除已处理的 Steer 指令。 // ClearPendingSteer 清除已处理的 Steer 指令。
func (s *Store) ClearPendingSteer() error { func (s *Store) ClearPendingSteer() error {
meta, err := s.LoadRunMeta() return s.withWriteLock(func() error {
meta, err := s.loadRunMetaUnlocked()
if err != nil { if err != nil {
return err return err
} }
@@ -76,7 +100,23 @@ func (s *Store) ClearPendingSteer() error {
return nil return nil
} }
meta.PendingSteer = "" meta.PendingSteer = ""
return s.SaveRunMeta(*meta) 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/。 // SaveCheckpoint 保存当前进度快照到 meta/checkpoints/。

View File

@@ -1,7 +1,9 @@
package state package state
import ( import (
"fmt"
"os" "os"
"sync"
"testing" "testing"
"github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/domain"
@@ -13,6 +15,7 @@ func TestSaveAndLoadRunMeta(t *testing.T) {
meta := domain.RunMeta{ meta := domain.RunMeta{
StartedAt: "2026-03-07T10:00:00+08:00", StartedAt: "2026-03-07T10:00:00+08:00",
Provider: "openrouter",
Style: "fantasy", Style: "fantasy",
Model: "gpt-4o", Model: "gpt-4o",
} }
@@ -27,6 +30,9 @@ func TestSaveAndLoadRunMeta(t *testing.T) {
if loaded.Style != "fantasy" { if loaded.Style != "fantasy" {
t.Errorf("style mismatch: %s", loaded.Style) t.Errorf("style mismatch: %s", loaded.Style)
} }
if loaded.Provider != "openrouter" {
t.Errorf("provider mismatch: %s", loaded.Provider)
}
if loaded.Model != "gpt-4o" { if loaded.Model != "gpt-4o" {
t.Errorf("model mismatch: %s", loaded.Model) 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) { func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
store := NewStore(dir) store := NewStore(dir)
@@ -80,6 +132,7 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
// 先保存 RunMeta // 先保存 RunMeta
_ = store.SaveRunMeta(domain.RunMeta{ _ = store.SaveRunMeta(domain.RunMeta{
StartedAt: "2026-03-07T10:00:00+08:00", StartedAt: "2026-03-07T10:00:00+08:00",
Provider: "openrouter",
Style: "suspense", Style: "suspense",
Model: "gpt-4o", Model: "gpt-4o",
}) })
@@ -91,6 +144,9 @@ func TestAppendSteerEntry_PreservesExistingMeta(t *testing.T) {
if meta.Style != "suspense" { if meta.Style != "suspense" {
t.Errorf("style should be preserved, got %s", meta.Style) 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" { if meta.Model != "gpt-4o" {
t.Errorf("model should be preserved, got %s", meta.Model) t.Errorf("model should be preserved, got %s", meta.Model)
} }
@@ -106,6 +162,7 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) {
// 先建立带历史的 RunMeta // 先建立带历史的 RunMeta
_ = store.SaveRunMeta(domain.RunMeta{ _ = store.SaveRunMeta(domain.RunMeta{
StartedAt: "old", StartedAt: "old",
Provider: "openai",
Style: "fantasy", Style: "fantasy",
Model: "old-model", Model: "old-model",
SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}}, SteerHistory: []domain.SteerEntry{{Input: "历史干预", Timestamp: "ts"}},
@@ -113,12 +170,15 @@ func TestInitRunMeta_PreservesHistory(t *testing.T) {
}) })
// InitRunMeta 应保留 SteerHistory 和 PendingSteer // InitRunMeta 应保留 SteerHistory 和 PendingSteer
_ = store.InitRunMeta("suspense", "new-model") _ = store.InitRunMeta("suspense", "openrouter", "new-model")
meta, _ := store.LoadRunMeta() meta, _ := store.LoadRunMeta()
if meta.Style != "suspense" { if meta.Style != "suspense" {
t.Errorf("style should be updated, got %s", meta.Style) 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" { if meta.Model != "new-model" {
t.Errorf("model should be updated, got %s", meta.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) { func TestClearPendingSteer_Noop(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
store := NewStore(dir) 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" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
) )
// Store 封装小说输出目录,提供所有状态读写操作。 // Store 封装小说输出目录,提供所有状态读写操作。
type Store struct { type Store struct {
dir string dir string
mu sync.RWMutex
} }
// NewStore 创建状态管理器dir 为小说输出根目录。 // NewStore 创建状态管理器dir 为小说输出根目录。
@@ -36,19 +38,61 @@ func (s *Store) path(rel string) string {
} }
func (s *Store) readFile(rel string) ([]byte, error) { 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 { 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) p := s.path(rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err 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 { 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 { if err != nil {
return err return err
} }
@@ -56,21 +100,41 @@ func (s *Store) readJSON(rel string, v any) error {
} }
func (s *Store) writeJSON(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, "", " ") data, err := json.MarshalIndent(v, "", " ")
if err != nil { if err != nil {
return err return err
} }
return s.writeFile(rel, data) return s.writeFileUnlocked(rel, data)
} }
func (s *Store) writeMarkdown(rel string, content string) error { 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 { 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)) err := os.Remove(s.path(rel))
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil return nil
} }
return err 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 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

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

View File

@@ -6,12 +6,11 @@ import (
"fmt" "fmt"
"github.com/voocel/agentcore/schema" "github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain"
"github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/state"
) )
// CheckConsistencyTool 对照状态文件检查章节一致性 // CheckConsistencyTool 返回章节内容和全部状态数据,供 Agent 自行对照判断
// 返回上下文数据和已知约束供 LLM 判断,不做 AI 推理 // 纯 IO 工具:只负责加载数据,不注入指令
type CheckConsistencyTool struct { type CheckConsistencyTool struct {
store *state.Store store *state.Store
} }
@@ -22,7 +21,7 @@ func NewCheckConsistencyTool(store *state.Store) *CheckConsistencyTool {
func (t *CheckConsistencyTool) Name() string { return "check_consistency" } func (t *CheckConsistencyTool) Name() string { return "check_consistency" }
func (t *CheckConsistencyTool) Description() string { func (t *CheckConsistencyTool) Description() string {
return "检查章节一致性。返回章节内容、全部状态数据和具体检查清单,你需要逐项对照并以 JSON 格式返回冲突项" return "加载章节内容和对照数据(世界规则、伏笔、关系、别名、最近摘要),供你检查一致性"
} }
func (t *CheckConsistencyTool) Label() string { 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} result := map[string]any{"chapter": a.Chapter}
// 加载章节内容polished 优先) // 章节内容
content, wordCount, err := t.store.LoadChapterContent(a.Chapter) content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil { if err != nil {
return nil, fmt.Errorf("load chapter content: %w", err) 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["content"] = content
result["word_count"] = wordCount result["word_count"] = wordCount
// 加载全部状态数据供 LLM 对照 // 对照数据:保留全局性的一致性检查数据,避免重复加载 novel_context 已有的窗口数据
if timeline, _ := t.store.LoadTimeline(); len(timeline) > 0 { if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 {
result["timeline"] = timeline result["world_rules"] = rules
} }
if foreshadow, _ := t.store.LoadForeshadowLedger(); len(foreshadow) > 0 { if foreshadow, _ := t.store.LoadActiveForeshadow(); len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow result["foreshadow_ledger"] = foreshadow
if active := filterActive(foreshadow); len(active) > 0 {
result["unresolved_foreshadow"] = active
}
} }
if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 { if relationships, _ := t.store.LoadRelationships(); len(relationships) > 0 {
result["relationships"] = relationships result["relationships"] = relationships
} }
if chars, _ := t.store.LoadCharacters(); len(chars) > 0 { if chars, _ := t.store.LoadCharacters(); len(chars) > 0 {
result["characters"] = chars aliasMap := make(map[string]string)
} for _, c := range chars {
for _, alias := range c.Aliases {
if rules, _ := t.store.LoadWorldRules(); len(rules) > 0 { aliasMap[alias] = c.Name
result["world_rules"] = rules
// 提取边界清单,方便 LLM 逐条对照
var boundaries []string
for _, r := range rules {
if r.Boundary != "" {
boundaries = append(boundaries, fmt.Sprintf("[%s] %s", r.Category, r.Boundary))
} }
} }
if len(boundaries) > 0 { if len(aliasMap) > 0 {
result["world_rules_boundaries"] = boundaries result["alias_map"] = aliasMap
} }
} }
// 加载前两章摘要
if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 { if summaries, _ := t.store.LoadRecentSummaries(a.Chapter, 2); len(summaries) > 0 {
result["recent_summaries"] = summaries 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) 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" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"github.com/voocel/agentcore/schema" "github.com/voocel/agentcore/schema"
"github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/domain"
@@ -11,7 +12,6 @@ import (
) )
// CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。 // CommitChapterTool 提交章节:加载正文 → 保存终稿 → 生成摘要 → 更新状态 → 更新进度。
// 这是唯一允许写入 chapters/、summaries/、更新状态文件和进度的工具。
type CommitChapterTool struct { type CommitChapterTool struct {
store *state.Store store *state.Store
} }
@@ -22,7 +22,7 @@ func NewCommitChapterTool(store *state.Store) *CommitChapterTool {
func (t *CommitChapterTool) Name() string { return "commit_chapter" } func (t *CommitChapterTool) Name() string { return "commit_chapter" }
func (t *CommitChapterTool) Description() string { func (t *CommitChapterTool) Description() string {
return "提交章节。优先使用打磨版正文,同时更新时间线、伏笔、关系状态。返回结构化信号" return "提交章节终稿。加载草稿正文,保存为终稿,同时更新时间线、伏笔、关系、角色状态。返回结构化信号"
} }
func (t *CommitChapterTool) Label() string { 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(""))), schema.Property("characters", schema.Array("涉及角色", schema.String(""))),
) )
foreshadowSchema := schema.Object( 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("action", schema.Enum("操作", "plant", "advance", "resolve")).Required(),
schema.Property("description", schema.String("伏笔描述(仅 plant 时必需)")), 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("character_b", schema.String("角色 B")).Required(),
schema.Property("relation", schema.String("当前关系描述")).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( return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(), schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("summary", schema.String("本章内容摘要200字以内")).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("timeline_events", schema.Array("本章时间线事件", timelineSchema)),
schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)), schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)),
schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)), 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("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")),
schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")), 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"` TimelineEvents []domain.TimelineEvent `json:"timeline_events"`
ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"` ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"`
RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"` RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"`
StateChanges []domain.StateChange `json:"state_changes"`
HookType string `json:"hook_type"` HookType string `json:"hook_type"`
DominantStrand string `json:"dominant_strand"` DominantStrand string `json:"dominant_strand"`
Feedback *domain.OutlineFeedback `json:"feedback"`
} }
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err) 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 return nil, err
} }
// 1. 加载章节正文polished 优先,否则 merge scenes // 1. 加载章节正文
content, wordCount, err := t.store.LoadChapterContent(a.Chapter) content, wordCount, err := t.store.LoadChapterContent(a.Chapter)
if err != nil { if err != nil {
return nil, fmt.Errorf("load chapter content: %w", err) 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) 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. 更新进度 // 5. 更新进度
if err := t.store.MarkChapterComplete(a.Chapter, wordCount, a.HookType, a.DominantStrand); err != nil { 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 { if progress != nil {
completedCount = len(progress.CompletedChapters) completedCount = len(progress.CompletedChapters)
} }
reviewRequired, reviewReason := domain.ShouldReview(completedCount)
// 7. 计算场景数 // 6b. 长篇模式:弧级边界检测
sceneCount := 0 var arcEnd, volumeEnd bool
if scenes, err := t.store.LoadSceneDrafts(a.Chapter); err == nil { var vol, arc int
sceneCount = len(scenes) 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{ result := domain.CommitResult{
Chapter: a.Chapter, Chapter: a.Chapter,
Committed: true, Committed: true,
WordCount: wordCount, WordCount: wordCount,
SceneCount: sceneCount,
NextChapter: a.Chapter + 1, NextChapter: a.Chapter + 1,
ReviewRequired: reviewRequired, ReviewRequired: reviewRequired,
ReviewReason: reviewReason, ReviewReason: reviewReason,
HookType: a.HookType, HookType: a.HookType,
DominantStrand: a.DominantStrand, DominantStrand: a.DominantStrand,
Feedback: a.Feedback,
ArcEnd: arcEnd,
VolumeEnd: volumeEnd,
Volume: vol,
Arc: arc,
} }
// 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失) // 8. 写入信号文件
if err := t.store.SaveLastCommit(result); err != nil { if err := t.store.SaveLastCommit(result); err != nil {
return nil, fmt.Errorf("save commit signal: %w", err) return nil, fmt.Errorf("save commit signal: %w", err)
} }
// 10. 清除场景级进度(章节已提交) // 9. 清除进度中间状态
if err := t.store.ClearInProgress(); err != nil { if err := t.store.ClearInProgress(); err != nil {
return nil, fmt.Errorf("clear in-progress: %w", err) 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 { if err := store.SetFlow(domain.FlowRewriting); err != nil {
t.Fatalf("SetFlow: %v", err) t.Fatalf("SetFlow: %v", err)
} }
if err := store.SavePolished(3, "这是错误章节的正文。"); err != nil { if err := store.SaveDraft(3, "这是错误章节的正文。"); err != nil {
t.Fatalf("SavePolished: %v", err) t.Fatalf("SaveDraft: %v", err)
} }
tool := NewCommitChapterTool(store) tool := NewCommitChapterTool(store)
@@ -76,8 +76,8 @@ func TestCommitChapterAllowsPendingRewrite(t *testing.T) {
if err := store.SetFlow(domain.FlowRewriting); err != nil { if err := store.SetFlow(domain.FlowRewriting); err != nil {
t.Fatalf("SetFlow: %v", err) t.Fatalf("SetFlow: %v", err)
} }
if err := store.SavePolished(2, "这是正确待重写章节的正文。"); err != nil { if err := store.SaveDraft(2, "这是正确待重写章节的正文。"); err != nil {
t.Fatalf("SavePolished: %v", err) t.Fatalf("SaveDraft: %v", err)
} }
tool := NewCommitChapterTool(store) 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" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/voocel/agentcore/schema" "github.com/voocel/agentcore/schema"
@@ -26,6 +27,8 @@ type References struct {
DialogueWriting string DialogueWriting string
// V2 // V2
StyleReference string // 风格补充参考(可为空) StyleReference string // 风格补充参考(可为空)
LongformPlanning string // 通用长篇规划参考
Differentiation string // 通用差异化设计参考
} }
// ContextTool 组装当前章节所需上下文。 // ContextTool 组装当前章节所需上下文。
@@ -60,49 +63,136 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
} }
result := make(map[string]any) 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 != "" { if premise, err := t.store.LoadPremise(); err == nil && premise != "" {
result["premise"] = premise result["premise"] = premise
} else {
warn("premise", err)
} }
if outline, err := t.store.LoadOutline(); err == nil && outline != nil { if outline, err := t.store.LoadOutline(); err == nil && outline != nil {
result["outline"] = outline result["outline"] = outline
} else {
warn("outline", err)
} }
if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 { if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 {
result["world_rules"] = rules result["world_rules"] = rules
} else {
warn("world_rules", err)
} }
if a.Chapter > 0 { 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 模式:加载章节相关上下文 // Writer/Editor 模式:加载章节相关上下文
if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil {
result["current_chapter_outline"] = entry result["current_chapter_outline"] = entry
} } else {
if summaries, err := t.store.LoadRecentSummaries(a.Chapter, 2); err == nil && len(summaries) > 0 { warn("current_chapter_outline", err)
result["recent_summaries"] = summaries
} }
// V3: 状态数据分级加载 // 摘要加载:分层 vs 扁平窗口
// timeline只取最近 5 章的事件(避免后期全量膨胀) if profile.Layered {
if timeline, err := t.store.LoadRecentTimeline(a.Chapter, 5); err == nil && len(timeline) > 0 { t.loadLayeredSummaries(result, a.Chapter, profile.SummaryWindow, warn)
result["timeline"] = timeline } 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 { if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 {
result["foreshadow_ledger"] = foreshadow result["foreshadow_ledger"] = foreshadow
} else {
warn("foreshadow_ledger", err)
} }
// relationships保持全量pair-key 去重,数据量天然可控) // relationships保持全量pair-key 去重,数据量天然可控)
if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 { if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 {
result["relationship_state"] = relationships 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: 加载场景级恢复状态 + 节奏追踪 // Layered 模式:注入当前卷弧位置 + 弧目标/卷主题
if progress, err := t.store.LoadProgress(); err == nil && progress != nil { 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{ checkpoint := map[string]any{
"in_progress_chapter": progress.InProgressChapter, "in_progress_chapter": progress.InProgressChapter,
"completed_scenes": progress.CompletedScenes,
} }
if len(progress.StrandHistory) > 0 { if len(progress.StrandHistory) > 0 {
checkpoint["strand_history"] = progress.StrandHistory checkpoint["strand_history"] = progress.StrandHistory
@@ -112,35 +202,230 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw
} }
result["checkpoint"] = checkpoint result["checkpoint"] = checkpoint
} }
// V2: 加载已有的章节规划(支持场景恢复跳过已完成场景) // 加载已有的章节构思
if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil { if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil {
result["chapter_plan"] = plan 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) result["references"] = t.writerReferences(a.Chapter)
} else { } else {
runMeta, err := t.store.LoadRunMeta()
warn("run_meta", err)
if runMeta != nil && runMeta.PlanningTier != "" {
result["planning_tier"] = runMeta.PlanningTier
}
// Architect 模式:全量角色 + 模板 // Architect 模式:全量角色 + 模板
if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { if chars, err := t.store.LoadCharacters(); err == nil && chars != nil {
result["characters"] = chars 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["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) 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 和场景出场过滤角色。 // loadFilteredCharacters 按 Tier 和场景出场过滤角色。
// core/important 始终返回secondary/decorative 只在当前章节大纲提及时返回。 // 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() chars, err := t.store.LoadCharacters()
if err != nil || len(chars) == 0 { if err != nil {
warn("characters", err)
return
}
if len(chars) == 0 {
return return
} }
// 获取当前章节大纲的场景描述,用于匹配次要角色 // 获取当前章节大纲的场景描述,用于匹配次要角色
entry, err := t.store.GetChapterOutline(chapter) entry, err := t.store.GetChapterOutline(chapter)
if err != nil { if err != nil {
warn("current_chapter_outline", err)
result["characters"] = chars result["characters"] = chars
return return
} }
@@ -150,7 +435,7 @@ func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int)
for _, c := range chars { for _, c := range chars {
switch c.Tier { switch c.Tier {
case "secondary", "decorative": case "secondary", "decorative":
if strings.Contains(sceneText, c.Name) { if matchCharacter(sceneText, c) {
filtered = append(filtered, c) filtered = append(filtered, c)
} }
default: // core, important, 或未设置 default: // core, important, 或未设置
@@ -160,6 +445,77 @@ func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int)
result["characters"] = filtered 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 返回全量,后续章节裁剪掉不再需要的模板。 // writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。
func (t *ContextTool) writerReferences(chapter int) map[string]string { func (t *ContextTool) writerReferences(chapter int) map[string]string {
refs := map[string]string{} refs := map[string]string{}
@@ -168,15 +524,17 @@ func (t *ContextTool) writerReferences(chapter int) map[string]string {
refs[k] = v refs[k] = v
} }
} }
// 始终加载的核心参考 // 渐进式加载:始终保留核心参考,前 3 章额外加载完整写作指南
add("chapter_guide", t.refs.ChapterGuide) add("consistency", t.refs.Consistency)
add("hook_techniques", t.refs.HookTechniques) add("hook_techniques", t.refs.HookTechniques)
add("quality_checklist", t.refs.QualityChecklist) add("quality_checklist", t.refs.QualityChecklist)
add("consistency", t.refs.Consistency) if chapter <= 3 {
add("chapter_guide", t.refs.ChapterGuide)
add("dialogue_writing", t.refs.DialogueWriting) add("dialogue_writing", t.refs.DialogueWriting)
add("style_reference", t.refs.StyleReference) add("style_reference", t.refs.StyleReference)
}
// 仅首章加载的补充参考(后续章节不再需要) // 仅首章加载的补充参考
if chapter <= 1 { if chapter <= 1 {
add("chapter_template", t.refs.ChapterTemplate) add("chapter_template", t.refs.ChapterTemplate)
add("content_expansion", t.refs.ContentExpansion) add("content_expansion", t.refs.ContentExpansion)
@@ -193,9 +551,35 @@ func (t *ContextTool) architectReferences() map[string]string {
} }
add("outline_template", t.refs.OutlineTemplate) add("outline_template", t.refs.OutlineTemplate)
add("character_template", t.refs.CharacterTemplate) 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 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 返回当前状态的简要摘要(供日志使用)。 // ContextSummary 返回当前状态的简要摘要(供日志使用)。
func (t *ContextTool) ContextSummary() string { func (t *ContextTool) ContextSummary() string {
var parts []string var parts []string
@@ -213,3 +597,43 @@ func (t *ContextTool) ContextSummary() string {
} }
return strings.Join(parts, ", ") 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" "github.com/voocel/ainovel-cli/state"
) )
// PlanChapterTool 生成章节规划 // PlanChapterTool 保存章节构思Agent 自主决定规划粒度
type PlanChapterTool struct { type PlanChapterTool struct {
store *state.Store store *state.Store
} }
@@ -21,25 +21,19 @@ func NewPlanChapterTool(store *state.Store) *PlanChapterTool {
func (t *PlanChapterTool) Name() string { return "plan_chapter" } func (t *PlanChapterTool) Name() string { return "plan_chapter" }
func (t *PlanChapterTool) Description() string { func (t *PlanChapterTool) Description() string {
return "创建章节写作规划,包括目标、冲突、场景拆分和钩子设计。必须在 write_scene 之前调用" return "保存章节写作构思。Agent 自主决定规划粒度,不强制场景拆分"
} }
func (t *PlanChapterTool) Label() string { return "规划章节" } func (t *PlanChapterTool) Label() string { return "规划章节" }
func (t *PlanChapterTool) Schema() map[string]any { 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( return schema.Object(
schema.Property("chapter", schema.Int("章节号")).Required(), schema.Property("chapter", schema.Int("章节号")).Required(),
schema.Property("title", schema.String("章节标题")).Required(), schema.Property("title", schema.String("章节标题")).Required(),
schema.Property("goal", schema.String("本章目标")).Required(), schema.Property("goal", schema.String("本章目标")).Required(),
schema.Property("conflict", schema.String("核心冲突")).Required(), schema.Property("conflict", schema.String("核心冲突")).Required(),
schema.Property("scenes", schema.Array("场景列表", sceneSchema)).Required(),
schema.Property("hook", schema.String("章末钩子")).Required(), schema.Property("hook", schema.String("章末钩子")).Required(),
schema.Property("emotion_arc", schema.String("情绪曲线")), schema.Property("emotion_arc", schema.String("情绪曲线")),
schema.Property("notes", schema.String("自由备忘(任何你觉得写作时需要记住的东西)")),
) )
} }
@@ -51,9 +45,6 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
if plan.Chapter <= 0 { if plan.Chapter <= 0 {
return nil, fmt.Errorf("chapter must be > 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 { if err := t.store.SaveChapterPlan(plan); err != nil {
return nil, fmt.Errorf("save chapter plan: %w", err) return nil, fmt.Errorf("save chapter plan: %w", err)
@@ -62,6 +53,5 @@ func (t *PlanChapterTool) Execute(_ context.Context, args json.RawMessage) (json
return json.Marshal(map[string]any{ return json.Marshal(map[string]any{
"planned": true, "planned": true,
"chapter": plan.Chapter, "chapter": plan.Chapter,
"scene_count": len(plan.Scenes),
}) })
} }

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) Name() string { return "save_foundation" }
func (t *SaveFoundationTool) Description() string { 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) Label() string { return "保存设定" }
func (t *SaveFoundationTool) Schema() map[string]any { func (t *SaveFoundationTool) Schema() map[string]any {
return schema.Object( return schema.Object(
schema.Property("type", schema.Enum("设定类型", "premise", "outline", "characters", "world_rules")).Required(), schema.Property("type", schema.Enum("设定类型", "premise", "outline", "layered_outline", "characters", "world_rules")).Required(),
schema.Property("content", schema.String("内容。premise 为 Markdown 文本outline 和 characters 为 JSON 字符串")).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) { func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
var a struct { var a struct {
Type string `json:"type"` Type string `json:"type"`
Content string `json:"content"` Content json.RawMessage `json:"content"`
Scale string `json:"scale"`
} }
if err := json.Unmarshal(args, &a); err != nil { if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("invalid args: %w", err) 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 { switch a.Type {
case "premise": 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) return nil, fmt.Errorf("save premise: %w", err)
} }
_ = t.store.UpdatePhase(domain.PhasePremise) _ = t.store.UpdatePhase(domain.PhasePremise)
return json.Marshal(map[string]any{"saved": true, "type": "premise"})
case "outline": case "outline":
var entries []domain.OutlineEntry 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) return nil, fmt.Errorf("parse outline JSON: %w", err)
} }
if err := t.store.SaveOutline(entries); err != nil { if err := t.store.SaveOutline(entries); err != nil {
return nil, fmt.Errorf("save outline: %w", err) return nil, fmt.Errorf("save outline: %w", err)
} }
_ = t.store.UpdatePhase(domain.PhaseOutline) _ = t.store.UpdatePhase(domain.PhaseOutline)
// 根据大纲长度自动设定总章节数
_ = t.store.SetTotalChapters(len(entries)) _ = 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": case "characters":
var chars []domain.Character 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) return nil, fmt.Errorf("parse characters JSON: %w", err)
} }
if err := t.store.SaveCharacters(chars); err != nil { if err := t.store.SaveCharacters(chars); err != nil {
return nil, fmt.Errorf("save characters: %w", err) 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": case "world_rules":
var rules []domain.WorldRule 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) return nil, fmt.Errorf("parse world_rules JSON: %w", err)
} }
if err := t.store.SaveWorldRules(rules); err != nil { if err := t.store.SaveWorldRules(rules); err != nil {
return nil, fmt.Errorf("save world_rules: %w", err) 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: 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 { func (t *SaveReviewTool) Schema() map[string]any {
issueSchema := schema.Object( issueSchema := schema.Object(
schema.Property("type", schema.Enum("问题类型", "timeline", "foreshadow", "relationship", "character", "pacing", "logic")).Required(), schema.Property("type", schema.Enum("问题维度", "consistency", "character", "pacing", "continuity", "foreshadow", "hook")).Required(),
schema.Property("severity", schema.Enum("严重程度", "error", "warning")).Required(), schema.Property("severity", schema.Enum("严重程度", "critical", "error", "warning")).Required(),
schema.Property("description", schema.String("问题描述")).Required(), schema.Property("description", schema.String("问题描述")).Required(),
schema.Property("suggestion", schema.String("修改建议")), 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( return schema.Object(
schema.Property("chapter", schema.Int("审阅的章节号(全局审阅填最新章节号)")).Required(), 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("issues", schema.Array("发现的问题", issueSchema)).Required(),
schema.Property("verdict", schema.Enum("审阅结论", "accept", "polish", "rewrite")).Required(), schema.Property("verdict", schema.Enum("审阅结论", "accept", "polish", "rewrite")).Required(),
schema.Property("summary", schema.String("审阅总结")).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 package tui
import ( import (
"fmt"
"io" "io"
"log" "log"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"syscall"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/voocel/ainovel-cli/app" "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 { if err != nil {
return err return err
} }
bridge := newAskUserBridge()
rt.AskUser().SetHandler(bridge.handler)
restoreLog := redirectLogger(rt.Dir()) restoreLog := redirectLogger(rt.Dir())
defer restoreLog() defer restoreLog()
defer rt.Close()
m := NewModel(rt) m := NewModel(rt, bridge)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err = p.Run() _, 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 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 eventMsg app.UIEvent
snapshotMsg app.UISnapshot snapshotMsg app.UISnapshot
doneMsg struct{} doneMsg struct{}
askUserMsg askUserRequest
startResultMsg struct{ err error } startResultMsg struct{ err error }
steerResultMsg struct{} steerResultMsg struct{}
spinnerTickMsg time.Time spinnerTickMsg time.Time
@@ -102,3 +103,13 @@ func listenStreamClear(rt *app.Runtime) tea.Cmd {
return streamClearMsg{} 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 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) info := buildRightInfo(snap, outputDir)
hintsW := lipgloss.Width(hints) 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 { func buildRightInfo(snap app.UISnapshot, outputDir string) string {
var parts []string var parts []string
if snap.Provider != "" {
parts = append(parts, snap.Provider)
}
if snap.ModelName != "" { if snap.ModelName != "" {
parts = append(parts, snap.ModelName) parts = append(parts, snap.ModelName)
} }

View File

@@ -3,6 +3,7 @@ package tui
import ( import (
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@@ -13,6 +14,14 @@ import (
const maxEvents = 500 const maxEvents = 500
type focusPane int
const (
focusEvents focusPane = iota
focusStream
focusDetail
)
type appMode int type appMode int
const ( const (
@@ -27,17 +36,23 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "
// Model 是 TUI 的顶层状态。 // Model 是 TUI 的顶层状态。
type Model struct { type Model struct {
runtime *app.Runtime runtime *app.Runtime
askBridge *askUserBridge
askState *askUserState
snapshot app.UISnapshot snapshot app.UISnapshot
events []app.UIEvent events []app.UIEvent
viewport viewport.Model // 事件流 viewport viewport viewport.Model // 事件流 viewport
streamVP viewport.Model // 流式输出 viewport streamVP viewport.Model // 流式输出 viewport
detailVP viewport.Model // 右侧详情 viewport
streamBuf *strings.Builder // 流式文本累积缓冲 streamBuf *strings.Builder // 流式文本累积缓冲
streamRounds []string
textarea textarea.Model textarea textarea.Model
width int width int
height int height int
autoScroll bool autoScroll bool
streamScroll bool // 流式面板自动跟随 streamScroll bool // 流式面板自动跟随
focusStream bool // true=焦点在流式面板, false=事件流 focusPane focusPane
hoverPane focusPane
hoverActive bool
mode appMode mode appMode
err error err error
spinnerIdx int spinnerIdx int
@@ -45,7 +60,7 @@ type Model struct {
} }
// NewModel 创建 TUI Model。 // NewModel 创建 TUI Model。
func NewModel(rt *app.Runtime) Model { func NewModel(rt *app.Runtime, bridge *askUserBridge) Model {
ta := textarea.New() ta := textarea.New()
ta.Placeholder = "输入小说需求例如写一部12章都市悬疑小说" ta.Placeholder = "输入小说需求例如写一部12章都市悬疑小说"
ta.CharLimit = 500 ta.CharLimit = 500
@@ -63,14 +78,19 @@ func NewModel(rt *app.Runtime) Model {
svp := viewport.New(80, 10) svp := viewport.New(80, 10)
svp.SetContent("") svp.SetContent("")
dvp := viewport.New(40, 20)
dvp.SetContent("")
return Model{ return Model{
runtime: rt, runtime: rt,
askBridge: bridge,
autoScroll: true, autoScroll: true,
streamScroll: true, streamScroll: true,
mode: modeNew, mode: modeNew,
textarea: ta, textarea: ta,
viewport: vp, viewport: vp,
streamVP: svp, streamVP: svp,
detailVP: dvp,
streamBuf: &strings.Builder{}, streamBuf: &strings.Builder{},
} }
} }
@@ -79,6 +99,7 @@ func (m Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
textarea.Blink, textarea.Blink,
listenEvents(m.runtime), listenEvents(m.runtime),
listenAskUser(m.askBridge),
listenDone(m.runtime), listenDone(m.runtime),
listenStream(m.runtime), listenStream(m.runtime),
listenStreamClear(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.height = msg.Height
m.textarea.SetWidth(m.inputWidth()) m.textarea.SetWidth(m.inputWidth())
m.updateViewportSize() m.updateViewportSize()
m.refreshDetailViewport()
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if m.askState != nil {
return m.handleAskUserKey(msg)
}
switch msg.Type { switch msg.Type {
case tea.KeyCtrlC: case tea.KeyCtrlC:
return m, tea.Quit return m, tea.Quit
@@ -111,12 +136,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetContent("") m.viewport.SetContent("")
m.viewport.GotoTop() m.viewport.GotoTop()
m.streamBuf.Reset() m.streamBuf.Reset()
m.streamRounds = nil
m.streamVP.SetContent("") m.streamVP.SetContent("")
m.streamVP.GotoTop() m.streamVP.GotoTop()
m.streamRound = 0 m.streamRound = 0
return m, nil return m, nil
case tea.KeyTab: case tea.KeyTab:
m.focusStream = !m.focusStream m.focusPane = (m.focusPane + 1) % 3
return m, nil return m, nil
case tea.KeyEnter: case tea.KeyEnter:
text := strings.TrimSpace(m.textarea.Value()) text := strings.TrimSpace(m.textarea.Value())
@@ -134,18 +160,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case tea.KeyUp, tea.KeyPgUp: case tea.KeyUp, tea.KeyPgUp:
if m.focusStream { if m.focusPane == focusStream {
m.streamScroll = false m.streamScroll = false
var cmd tea.Cmd var cmd tea.Cmd
m.streamVP, cmd = m.streamVP.Update(msg) m.streamVP, cmd = m.streamVP.Update(msg)
return m, cmd return m, cmd
} }
if m.focusPane == focusDetail {
var cmd tea.Cmd
m.detailVP, cmd = m.detailVP.Update(msg)
return m, cmd
}
m.autoScroll = false m.autoScroll = false
var cmd tea.Cmd var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
return m, cmd return m, cmd
case tea.KeyDown, tea.KeyPgDown: case tea.KeyDown, tea.KeyPgDown:
if m.focusStream { if m.focusPane == focusStream {
var cmd tea.Cmd var cmd tea.Cmd
m.streamVP, cmd = m.streamVP.Update(msg) m.streamVP, cmd = m.streamVP.Update(msg)
if m.streamVP.AtBottom() { if m.streamVP.AtBottom() {
@@ -153,6 +184,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, 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 var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
if m.viewport.AtBottom() { if m.viewport.AtBottom() {
@@ -160,9 +196,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, cmd return m, cmd
case tea.KeyEnd: case tea.KeyEnd:
if m.focusStream { if m.focusPane == focusStream {
m.streamScroll = true m.streamScroll = true
m.streamVP.GotoBottom() m.streamVP.GotoBottom()
} else if m.focusPane == focusDetail {
m.detailVP.GotoBottom()
} else { } else {
m.autoScroll = true m.autoScroll = true
m.viewport.GotoBottom() m.viewport.GotoBottom()
@@ -171,12 +209,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.MouseMsg: 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 var cmd tea.Cmd
if m.focusStream { if m.focusPane == focusStream {
m.streamVP, cmd = m.streamVP.Update(msg) m.streamVP, cmd = m.streamVP.Update(msg)
if msg.Action == tea.MouseActionPress { if msg.Action == tea.MouseActionPress {
m.streamScroll = m.streamVP.AtBottom() m.streamScroll = m.streamVP.AtBottom()
} }
} else if m.focusPane == focusDetail {
m.detailVP, cmd = m.detailVP.Update(msg)
} else { } else {
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
if msg.Action == tea.MouseActionPress { if msg.Action == tea.MouseActionPress {
@@ -194,8 +243,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshEventViewport() m.refreshEventViewport()
return m, listenEvents(m.runtime) 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: case snapshotMsg:
m.snapshot = app.UISnapshot(msg) m.snapshot = app.UISnapshot(msg)
m.refreshDetailViewport()
return m, tickSnapshot(m.runtime) return m, tickSnapshot(m.runtime)
case doneMsg: case doneMsg:
@@ -230,22 +289,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tickSpinner() return m, tickSpinner()
case streamDeltaMsg: case streamDeltaMsg:
m.streamBuf.WriteString(string(msg)) if len(m.streamRounds) == 0 {
m.streamVP.SetContent(m.streamBuf.String()) 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 { if m.streamScroll {
m.streamVP.GotoBottom() m.streamVP.GotoBottom()
} }
return m, listenStream(m.runtime) return m, listenStream(m.runtime)
case streamClearMsg: case streamClearMsg:
// 新一轮输出:保留历史内容,用分隔线标记新段落 // 新一轮输出:按轮次分块显示,避免长文本和分隔线直接拼接导致错乱。
m.streamRound++ if len(m.streamRounds) == 0 {
if m.streamBuf.Len() > 0 { m.streamRounds = append(m.streamRounds, "")
m.streamBuf.WriteString("\n") } else if strings.TrimSpace(m.streamRounds[len(m.streamRounds)-1]) != "" {
m.streamBuf.WriteString(renderStreamSeparator(m.streamRound, m.streamVP.Width)) m.streamRounds = append(m.streamRounds, "")
m.streamBuf.WriteString("\n")
} }
m.streamVP.SetContent(m.streamBuf.String()) m.streamRound = len(m.streamRounds)
m.streamVP.SetContent(renderStreamContent(m.streamRounds, m.streamVP.Width))
if m.streamScroll { if m.streamScroll {
m.streamVP.GotoBottom() m.streamVP.GotoBottom()
} }
@@ -260,6 +322,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) 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。 // refreshEventViewport 重新渲染事件流内容并设置 viewport。
func (m *Model) refreshEventViewport() { func (m *Model) refreshEventViewport() {
centerW := m.eventFlowWidth() 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 大小。 // updateViewportSize 根据当前窗口尺寸更新 viewport 大小。
func (m *Model) updateViewportSize() { func (m *Model) updateViewportSize() {
centerW := m.eventFlowWidth() centerW := m.eventFlowWidth()
rightW := m.detailWidth()
bodyH := m.bodyHeight() bodyH := m.bodyHeight()
eventH, streamH := m.splitHeights(bodyH) eventH, streamH := m.splitHeights(bodyH)
m.viewport.Width = centerW - 2 m.viewport.Width = centerW - 2
m.viewport.Height = eventH - 1 // -1 为 event panel header 行 m.viewport.Height = eventH - 1 // -1 为 event panel header 行
m.streamVP.Width = centerW - 2 m.streamVP.Width = centerW - 2
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
m.detailVP.Width = rightW - 2
m.detailVP.Height = bodyH
} }
// splitHeights 计算事件流和流式输出的高度分配。 // splitHeights 计算事件流和流式输出的高度分配。
@@ -309,10 +426,17 @@ func (m *Model) eventFlowWidth() int {
return 80 return 80
} }
leftW := m.width * 25 / 100 leftW := m.width * 25 / 100
rightW := m.width * 30 / 100 rightW := m.detailWidth()
return m.width - leftW - rightW 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 { func (m *Model) bodyHeight() int {
if m.height == 0 { if m.height == 0 {
return 20 return 20
@@ -362,7 +486,7 @@ func (m Model) View() string {
body = renderWelcome(m.width, bodyH, errMsg) body = renderWelcome(m.width, bodyH, errMsg)
} else { } else {
leftW := m.width * 25 / 100 leftW := m.width * 25 / 100
rightW := m.width * 30 / 100 rightW := m.detailWidth()
centerW := m.width - leftW - rightW centerW := m.width - leftW - rightW
eventH, streamH := m.splitHeights(bodyH) eventH, streamH := m.splitHeights(bodyH)
@@ -375,14 +499,89 @@ func (m Model) View() string {
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
} }
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream) eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, m.paneHighlighted(focusEvents))
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream) streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.paneHighlighted(focusStream))
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel) center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)
left := renderStatePanel(m.snapshot, leftW, bodyH) 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) 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 package tui
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -27,6 +28,9 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string {
// 第二行左侧:模型 + 风格 // 第二行左侧:模型 + 风格
var infoParts []string var infoParts []string
if snap.Provider != "" {
infoParts = append(infoParts, snap.Provider)
}
if snap.ModelName != "" { if snap.ModelName != "" {
infoParts = append(infoParts, 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))) b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount)))
if snap.InProgressChapter > 0 { 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 { if len(snap.PendingRewrites) > 0 {
@@ -198,12 +202,8 @@ func renderEventFlowViewport(vp viewport.Model, width, height int, focused bool)
// renderStreamPanel 渲染流式输出面板(中间列下半部分)。 // renderStreamPanel 渲染流式输出面板(中间列下半部分)。
func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string { func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string {
// 分隔标题栏 // 分隔标题栏(始终醒目)
titleColor := colorDim title := lipgloss.NewStyle().Foreground(colorAccent).Bold(focused).Render("✦ 实时输出")
if focused {
titleColor = colorAccent
}
title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 生成内容")
lineW := width - lipgloss.Width(title) - 4 lineW := width - lipgloss.Width(title) - 4
if lineW < 0 { if lineW < 0 {
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()) return header + "\n" + vpStyle.Render(vp.View())
} }
// renderStreamSeparator 渲染流式面板中的轮次分隔线 // renderStreamContent 将流式输出按轮次渲染为语义分块
func renderStreamSeparator(round, width int) string { // Agent 调度块(以 ▸ 开头)用 accent 标题 + dim 指令;正文块用标准文本色。
label := fmt.Sprintf(" #%d ", round) func renderStreamContent(rounds []string, width int) string {
lineW := (width - lipgloss.Width(label)) / 2 if width < 24 {
if lineW < 1 { width = 24
lineW = 1
} }
line := strings.Repeat("─", lineW)
dimLine := lipgloss.NewStyle().Foreground(colorDim).Render(line) var blocks []string
dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label) for _, round := range rounds {
return dimLine + dimLabel + dimLine 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 { func renderDetailContent(snap app.UISnapshot, contentW int) string {
contentW := width - 4 // 边框 + padding
var b strings.Builder 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(). style := lipgloss.NewStyle().
Width(width). Width(width).
Height(height). Height(height).
Border(baseBorder, false, false, false, true). Border(baseBorder, false, false, false, true).
BorderForeground(colorDim). BorderForeground(borderColor).
Padding(0, 1) Padding(0, 1)
return style.Render(b.String()) return style.Render(vp.View())
} }
// renderWelcome 渲染新建态首屏。 // renderWelcome 渲染新建态首屏。

View File

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