Compare commits
14 Commits
74a8c8eaef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73f024c83 | ||
|
|
8900332910 | ||
|
|
7ed7a6c81b | ||
|
|
351c12fdaa | ||
|
|
40a3479e2a | ||
|
|
b23ac0fb6b | ||
|
|
c913a49ffd | ||
|
|
568ef0b1d1 | ||
|
|
25e219e934 | ||
|
|
7488198461 | ||
|
|
16e790a372 | ||
|
|
bce0adeff1 | ||
|
|
3d65afa276 | ||
|
|
e9c8220bc3 |
206
README.md
Normal file
206
README.md
Normal 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 驱动原则
|
||||||
|
|
||||||
|
**工具负责 IO,Agent 负责思考。不要用流水线绑住 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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
467
app/run.go
467
app/run.go
@@ -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_summary,volume=%d,arc=%d)\n"+
|
||||||
|
"3. 调用 editor 生成卷摘要(save_volume_summary,volume=%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_summary,volume=%d,arc=%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=%d,error=%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{
|
||||||
|
|||||||
107
app/run_test.go
107
app/run_test.go
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
216
app/stream_extract.go
Normal 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 工具调用。
|
||||||
|
// 文本回复标记为思考内容(前缀 ThinkingSep);JSON 工具调用只提取指定字段。
|
||||||
|
//
|
||||||
|
// 判断依据:遇到 { 进入 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
194
app/stream_extract_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 指令,中断恢复时重新注入
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
11
domain/tracking.go
Normal 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"` // 变化原因
|
||||||
|
}
|
||||||
@@ -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
8
go.mod
@@ -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
8
go.sum
@@ -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
207
main.go
@@ -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
118
prompts/architect-long.md
Normal 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
113
prompts/architect-mid.md
Normal 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
|
||||||
|
- scenes(3-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
111
prompts/architect-short.md
Normal 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
|
||||||
|
- scenes(3-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 非空时停止。**
|
||||||
@@ -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 个场景
|
|
||||||
- 角色弧线要有变化,不要扁平
|
|
||||||
- 钩子要制造悬念,吸引读者继续阅读
|
|
||||||
@@ -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": "根据以下需求生成小说基础设定(premise、outline、characters):\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. 继续写下一卷的章节
|
||||||
|
|||||||
@@ -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**:七个维度的评分
|
||||||
- severity:error 或 warning
|
- dimension:维度名(consistency/character/pacing/continuity/foreshadow/hook/aesthetic)
|
||||||
- description:具体问题描述
|
- score:0-100 分
|
||||||
|
- verdict:pass(≥80)/ warning(60-79)/ fail(<60)
|
||||||
|
- comment:简要结论,aesthetic 维度必须引用原文
|
||||||
|
|
||||||
|
- **issues**:发现的具体问题列表
|
||||||
|
- type:问题维度
|
||||||
|
- severity:critical / 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
|
- **审美维度的问题必须引用原文**,不接受空泛的"文笔还需提升"
|
||||||
|
|||||||
@@ -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: 对大纲的反馈(可选)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 主角
|
## 主角
|
||||||
|
|
||||||
### [角色一姓名]
|
### [角色一姓名]
|
||||||
|
- **别名/称号**:(如"废物少年"、"炎哥"、"不灭战神"等,正文中可能用到的各种称呼)
|
||||||
- **年龄/职业**:
|
- **年龄/职业**:
|
||||||
- **外貌特征**:
|
- **外貌特征**:
|
||||||
- **性格核心**:
|
- **性格核心**:
|
||||||
|
|||||||
86
references/differentiation.md
Normal file
86
references/differentiation.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 通用差异化设计参考
|
||||||
|
|
||||||
|
这份参考用于避免同题材作品自动滑向“最高频模板”。
|
||||||
|
|
||||||
|
## 当用户只给出一个大题材词时,不能直接套最常见范式
|
||||||
|
|
||||||
|
例如用户说:
|
||||||
|
|
||||||
|
- 都市
|
||||||
|
- 奇幻
|
||||||
|
- 修仙
|
||||||
|
- 悬疑
|
||||||
|
- 言情
|
||||||
|
- 科幻
|
||||||
|
|
||||||
|
这不等于“照该题材最常见的开局写”。你必须先补足差异化维度。
|
||||||
|
|
||||||
|
## 差异化的五个维度
|
||||||
|
|
||||||
|
### 1. 主角维度
|
||||||
|
|
||||||
|
- 出身是否过于常见
|
||||||
|
- 初始优势/劣势是否过于常见
|
||||||
|
- 主角最强驱动力是什么
|
||||||
|
- 主角最大的盲区是什么
|
||||||
|
|
||||||
|
### 2. 冲突维度
|
||||||
|
|
||||||
|
- 主冲突是否只是该题材默认矛盾
|
||||||
|
- 有没有第二层冲突改变读者预期
|
||||||
|
- 冲突是否会在中期转型
|
||||||
|
|
||||||
|
### 3. 世界维度
|
||||||
|
|
||||||
|
- 世界规则是否真的改变角色行为
|
||||||
|
- 社会结构、资源结构、权力结构是否能持续制造问题
|
||||||
|
- 世界是否存在非主角视角也合理运转的逻辑
|
||||||
|
|
||||||
|
### 4. 关系维度
|
||||||
|
|
||||||
|
- 主要关系是否只有“队友/恋人/敌人”三个静态功能
|
||||||
|
- 是否存在长期互相塑造、互相伤害、互相利用、互相成全的关系
|
||||||
|
- 关系线是否会反向推动主线
|
||||||
|
|
||||||
|
### 5. 节奏维度
|
||||||
|
|
||||||
|
- 爽点是否单一重复
|
||||||
|
- 是否规划了不同阶段的阅读驱动力
|
||||||
|
- 前期吸引力和中后期吸引力是否一致,还是有自然升级
|
||||||
|
|
||||||
|
## 常见同质化信号
|
||||||
|
|
||||||
|
出现越多,越说明作品在滑向通用模板:
|
||||||
|
|
||||||
|
- 最常见的主角出身设定
|
||||||
|
- 最常见的“被看不起”起手
|
||||||
|
- 最常见的导师/宗门/学院/豪门/案件开场
|
||||||
|
- 最常见的反派动机
|
||||||
|
- 最常见的阶段升级节奏
|
||||||
|
- 最常见的“发现秘密”型钩子反复出现
|
||||||
|
|
||||||
|
## 规划时必须主动给自己设限
|
||||||
|
|
||||||
|
在同题材下,至少给出 2-3 条反模板约束。例如:
|
||||||
|
|
||||||
|
- 不使用最常见的开局身份
|
||||||
|
- 不使用最常见的金手指/能力来源
|
||||||
|
- 不使用最常见的中期升级路径
|
||||||
|
- 不让主要关系线停留在单一功能
|
||||||
|
- 不让终局只是“打败更大的敌人”
|
||||||
|
|
||||||
|
## 差异化不是猎奇,而是重新分配重心
|
||||||
|
|
||||||
|
有效的差异化通常来自:
|
||||||
|
|
||||||
|
- 更换主角真正关心的东西
|
||||||
|
- 更换长期冲突的来源
|
||||||
|
- 更换世界规则的压力点
|
||||||
|
- 更换关系线在故事中的功能
|
||||||
|
- 更换中期之后的推进方式
|
||||||
|
|
||||||
|
## 输出前自问
|
||||||
|
|
||||||
|
- 如果把角色名和设定名抹掉,这个故事还像同题材里另外十本书吗?
|
||||||
|
- 如果只看前 10 章,读者能说出这本书“独特在哪”吗?
|
||||||
|
- 如果写到 50 章后,作品的推进方式会不会和前 10 章完全重复?
|
||||||
105
references/longform-planning.md
Normal file
105
references/longform-planning.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 通用长篇规划参考
|
||||||
|
|
||||||
|
这份参考用于“适合长篇连载”的题材,不限定具体品类。
|
||||||
|
|
||||||
|
## 长篇不是把短篇拉长
|
||||||
|
|
||||||
|
长篇的核心不是章节更多,而是具备长期展开能力。判断一部作品能否写长,关键看它是否具备以下“故事引擎”:
|
||||||
|
|
||||||
|
- **目标引擎**:主角会不断追求新的阶段目标
|
||||||
|
- **世界引擎**:世界规则、势力格局、资源结构可以持续制造新问题
|
||||||
|
- **关系引擎**:主要人物关系会持续演化,而不是定型后停滞
|
||||||
|
- **身份引擎**:主角的位置、身份、阵营、责任会变化
|
||||||
|
- **代价引擎**:每次成长都带来新的约束、损失或风险
|
||||||
|
|
||||||
|
如果这几个引擎都很弱,再多章节也只会变成重复灌水。
|
||||||
|
|
||||||
|
## 长篇推荐规划顺序
|
||||||
|
|
||||||
|
### 1. 作品卖点
|
||||||
|
|
||||||
|
先明确:
|
||||||
|
|
||||||
|
- 这本书最吸引读者的承诺是什么
|
||||||
|
- 它和同题材常见写法最不同的点是什么
|
||||||
|
- 读者为什么愿意跟随主角走到中后期
|
||||||
|
|
||||||
|
### 2. 长期冲突
|
||||||
|
|
||||||
|
不要只有一个“终极反派”。长篇更适合多阶段冲突:
|
||||||
|
|
||||||
|
- 近程冲突:当前生存、当前任务、当前阶段目标
|
||||||
|
- 中程冲突:势力博弈、关系重组、身份变化
|
||||||
|
- 远程冲突:世界真相、时代命题、终局选择
|
||||||
|
|
||||||
|
### 3. 卷级设计
|
||||||
|
|
||||||
|
每一卷至少要有一个明确功能,常见功能包括:
|
||||||
|
|
||||||
|
- 立足
|
||||||
|
- 扩张
|
||||||
|
- 试错
|
||||||
|
- 反噬
|
||||||
|
- 失去
|
||||||
|
- 转向
|
||||||
|
- 收束
|
||||||
|
- 终局
|
||||||
|
|
||||||
|
每卷不只升级强度,还要升级问题类型。
|
||||||
|
|
||||||
|
### 4. 弧级设计
|
||||||
|
|
||||||
|
每一弧都应该像“一个可独立成立的小故事”:
|
||||||
|
|
||||||
|
- 有明确目标
|
||||||
|
- 有明确阻力
|
||||||
|
- 有阶段转折
|
||||||
|
- 有结果与代价
|
||||||
|
|
||||||
|
### 5. 章节设计
|
||||||
|
|
||||||
|
章节不是平均分配事件,而是为弧服务:
|
||||||
|
|
||||||
|
- 关键推进章
|
||||||
|
- 关系变化章
|
||||||
|
- 代价兑现章
|
||||||
|
- 误判与反噬章
|
||||||
|
- 转折章
|
||||||
|
- 收束与引出下弧章
|
||||||
|
|
||||||
|
## 避免长篇同质化
|
||||||
|
|
||||||
|
### 错误做法
|
||||||
|
|
||||||
|
- 每一卷都只是“换地图 + 换敌人”
|
||||||
|
- 每次升级都只是“主角更强了”
|
||||||
|
- 中期仍然重复前期的爽点结构
|
||||||
|
- 配角只在需要时出现,没有独立动机
|
||||||
|
- 世界规则只在设定里写,剧情中不产生压力
|
||||||
|
|
||||||
|
### 正确做法
|
||||||
|
|
||||||
|
- 升级“冲突类型”,不只升级“敌人强度”
|
||||||
|
- 升级“选择代价”,不只升级“资源规模”
|
||||||
|
- 升级“关系复杂度”,不只升级“出场人数”
|
||||||
|
- 升级“命题”,不只升级“舞台大小”
|
||||||
|
|
||||||
|
## 中期转向必须提前规划
|
||||||
|
|
||||||
|
很多作品前 20 章能写,50 章后就开始重复,根因是没有中期转向。
|
||||||
|
|
||||||
|
在规划时必须提前想清楚:
|
||||||
|
|
||||||
|
- 第一次结构转向发生在什么时候
|
||||||
|
- 为什么前期方法在中期失效
|
||||||
|
- 主角到中期后必须学会什么新的思维方式
|
||||||
|
- 中后期的核心吸引力与前期有什么不同
|
||||||
|
|
||||||
|
## 长篇通用检查清单
|
||||||
|
|
||||||
|
- 这本书是否具备至少 3 个阶段性主矛盾?
|
||||||
|
- 前 3 卷是否各自承担不同功能?
|
||||||
|
- 主角的“得到”和“失去”是否同步增长?
|
||||||
|
- 主要配角是否会改变主线,而不是只被主角改变?
|
||||||
|
- 世界规则是否真的限制了剧情决策?
|
||||||
|
- 中期转向后,作品是否仍然成立?
|
||||||
@@ -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
BIN
scripts/sample.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -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")
|
||||||
|
|||||||
200
state/drafts.go
200
state/drafts.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
162
state/outline.go
162
state/outline.go
@@ -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")
|
||||||
|
|||||||
@@ -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 校验当前章节是否允许提交。
|
||||||
|
|||||||
@@ -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/。
|
||||||
|
|||||||
@@ -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
42
state/state_changes.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
82
tools/draft_chapter.go
Normal 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 提交",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
67
tools/novel_context_test.go
Normal file
67
tools/novel_context_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
116
tools/read_chapter.go
Normal 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
215
tools/read_draft_test.go
Normal 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
86
tools/save_arc_summary.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/voocel/agentcore/schema"
|
||||||
|
"github.com/voocel/ainovel-cli/domain"
|
||||||
|
"github.com/voocel/ainovel-cli/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 为 Markdown;type=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
|
||||||
}
|
}
|
||||||
|
|||||||
151
tools/save_foundation_test.go
Normal file
151
tools/save_foundation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
62
tools/save_volume_summary.go
Normal file
62
tools/save_volume_summary.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
40
tui/app.go
40
tui/app.go
@@ -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
302
tui/ask_user.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
243
tui/model.go
243
tui/model.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
266
tui/panels.go
266
tui/panels.go
@@ -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)
|
|
||||||
dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label)
|
|
||||||
return dimLine + dimLabel + dimLine
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderDetailPanel 渲染右侧详情面板。
|
var blocks []string
|
||||||
|
for _, round := range rounds {
|
||||||
|
text := strings.TrimSpace(round)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "▸") {
|
||||||
|
blocks = append(blocks, renderAgentBlock(text, width))
|
||||||
|
} else {
|
||||||
|
blocks = append(blocks, renderChapterBlock(text, width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(blocks, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 渲染新建态首屏。
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基础样式
|
// 基础样式
|
||||||
|
|||||||
Reference in New Issue
Block a user