feat: support tui
This commit is contained in:
@@ -10,6 +10,7 @@ type Config struct {
|
||||
Prompt string // 用户的小说需求
|
||||
NovelName string // 小说名(用作输出目录名)
|
||||
OutputDir string // 输出根目录,默认 output/{NovelName}
|
||||
Provider string // LLM 提供商:openai / anthropic / gemini
|
||||
ModelName string // LLM 模型名
|
||||
APIKey string // API Key
|
||||
BaseURL string // API Base URL(可选)
|
||||
@@ -25,17 +26,34 @@ type Prompts struct {
|
||||
Editor string
|
||||
}
|
||||
|
||||
// Validate 校验配置。
|
||||
// Validate 校验配置(CLI 模式,要求 Prompt 非空)。
|
||||
func (c *Config) Validate() error {
|
||||
if c.Prompt == "" {
|
||||
return fmt.Errorf("prompt is required")
|
||||
}
|
||||
return c.ValidateBase()
|
||||
}
|
||||
|
||||
// ValidateBase 校验基础配置(TUI 模式下 Prompt 由用户输入,不在此检查)。
|
||||
func (c *Config) ValidateBase() error {
|
||||
if c.APIKey == "" {
|
||||
return fmt.Errorf("api key is required (set OPENAI_API_KEY)")
|
||||
return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)")
|
||||
}
|
||||
switch c.Provider {
|
||||
case "openai", "anthropic", "gemini":
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider %q (use openai/anthropic/gemini)", c.Provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 各 provider 的默认模型名。
|
||||
var defaultModels = map[string]string{
|
||||
"openai": "gpt-4o",
|
||||
"anthropic": "claude-sonnet-4-20250514",
|
||||
"gemini": "gemini-2.5-pro",
|
||||
}
|
||||
|
||||
// FillDefaults 填充默认值。
|
||||
func (c *Config) FillDefaults() {
|
||||
if c.NovelName == "" {
|
||||
@@ -44,8 +62,11 @@ func (c *Config) FillDefaults() {
|
||||
if c.OutputDir == "" {
|
||||
c.OutputDir = filepath.Join("output", c.NovelName)
|
||||
}
|
||||
if c.Provider == "" {
|
||||
c.Provider = "openai"
|
||||
}
|
||||
if c.ModelName == "" {
|
||||
c.ModelName = "gpt-4o"
|
||||
c.ModelName = defaultModels[c.Provider]
|
||||
}
|
||||
if c.Style == "" {
|
||||
c.Style = "default"
|
||||
|
||||
347
app/run.go
347
app/run.go
@@ -16,7 +16,11 @@ import (
|
||||
"github.com/voocel/ainovel-cli/tools"
|
||||
)
|
||||
|
||||
// Run 启动小说创作流程。
|
||||
// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。
|
||||
// CLI 模式下为 nil,Runtime 模式下指向 events channel。
|
||||
type emitFn func(UIEvent)
|
||||
|
||||
// Run 启动小说创作流程(CLI 模式,阻塞直到完成)。
|
||||
func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error {
|
||||
cfg.FillDefaults()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
@@ -30,11 +34,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
||||
}
|
||||
|
||||
// 2. 创建模型
|
||||
var baseURL []string
|
||||
if cfg.BaseURL != "" {
|
||||
baseURL = append(baseURL, cfg.BaseURL)
|
||||
}
|
||||
model, err := llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||
model, err := createModel(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create model: %w", err)
|
||||
}
|
||||
@@ -43,33 +43,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
||||
coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles)
|
||||
|
||||
// 4. 确定性控制面:事件监听 + FollowUp 注入
|
||||
coordinator.Subscribe(func(ev agentcore.Event) {
|
||||
switch ev.Type {
|
||||
case agentcore.EventToolExecStart:
|
||||
log.Printf("[tool:start] %s", ev.Tool)
|
||||
|
||||
case agentcore.EventToolExecEnd:
|
||||
if ev.IsError {
|
||||
log.Printf("[tool:error] %s", ev.Tool)
|
||||
return
|
||||
}
|
||||
log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200))
|
||||
|
||||
// 宿主确定性控制:SubAgent 完成后读取信号文件
|
||||
if ev.Tool == "subagent" {
|
||||
handleSubAgentDone(coordinator, store, cfg.MaxChapters)
|
||||
handleEditorDone(coordinator, store)
|
||||
}
|
||||
|
||||
case agentcore.EventMessageEnd:
|
||||
if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant {
|
||||
log.Printf("[assistant] %s", truncateLog(ev.Message.TextContent(), 300))
|
||||
}
|
||||
|
||||
case agentcore.EventError:
|
||||
log.Printf("[error] %v", ev.Err)
|
||||
}
|
||||
})
|
||||
registerSubscription(coordinator, store, cfg.MaxChapters, nil)
|
||||
|
||||
// 5. 初始化运行元信息(保留已有 SteerHistory)
|
||||
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
||||
@@ -84,98 +58,36 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
log.Printf("[steer] 用户干预: %s", text)
|
||||
if err := store.AppendSteerEntry(domain.SteerEntry{
|
||||
Input: text,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("[warn] 追加干预记录失败: %v", err)
|
||||
}
|
||||
if err := store.SetPendingSteer(text); err != nil {
|
||||
log.Printf("[warn] 设置待处理干预失败: %v", err)
|
||||
}
|
||||
if err := store.SetFlow(domain.FlowSteering); err != nil {
|
||||
log.Printf("[warn] 设置流程状态失败: %v", err)
|
||||
}
|
||||
coordinator.Steer(agentcore.UserMsg(fmt.Sprintf(
|
||||
"[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text)))
|
||||
submitSteer(store, coordinator, text)
|
||||
}
|
||||
}()
|
||||
|
||||
// 7. 恢复或启动(按优先级链)
|
||||
// 7. 恢复或启动
|
||||
progress, _ := store.LoadProgress()
|
||||
runMeta, _ := store.LoadRunMeta()
|
||||
if progress != nil && progress.InProgressChapter > 0 {
|
||||
// 场景级恢复:章节写到一半
|
||||
ch := progress.InProgressChapter
|
||||
scenes := len(progress.CompletedScenes)
|
||||
log.Printf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes)
|
||||
if err := coordinator.Prompt(fmt.Sprintf(
|
||||
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。",
|
||||
ch, scenes, scenes+1, progress.TotalChapters,
|
||||
)); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
} else if progress != nil && len(progress.PendingRewrites) > 0 {
|
||||
// 重写恢复:有待重写章节
|
||||
log.Printf("重写恢复:%d 章待处理 %v", len(progress.PendingRewrites), progress.PendingRewrites)
|
||||
verb := "重写"
|
||||
if progress.Flow == domain.FlowPolishing {
|
||||
verb = "打磨"
|
||||
}
|
||||
if err := coordinator.Prompt(fmt.Sprintf(
|
||||
"有 %d 章待%s(受影响章节:%v)。原因:%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。",
|
||||
len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters,
|
||||
)); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
} else if progress != nil && progress.Flow == domain.FlowReviewing {
|
||||
// 审阅恢复:审阅中断
|
||||
log.Printf("审阅恢复:上次审阅中断")
|
||||
if err := coordinator.Prompt(fmt.Sprintf(
|
||||
"上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。",
|
||||
len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters,
|
||||
)); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
} else if progress != nil && progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" {
|
||||
next := progress.NextChapter()
|
||||
log.Printf("Steer 恢复:上次干预未完成,重新注入")
|
||||
if err := coordinator.Prompt(fmt.Sprintf(
|
||||
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。",
|
||||
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer,
|
||||
)); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
} else if progress != nil && progress.IsResumable() {
|
||||
next := progress.NextChapter()
|
||||
log.Printf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)",
|
||||
next, len(progress.CompletedChapters), progress.TotalWordCount)
|
||||
if err := coordinator.Prompt(fmt.Sprintf(
|
||||
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。",
|
||||
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters,
|
||||
)); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 新建:初始化进度
|
||||
recovery := determineRecovery(progress, runMeta, cfg.MaxChapters)
|
||||
|
||||
if recovery.IsNew {
|
||||
if err := store.InitProgress(cfg.NovelName, cfg.MaxChapters); err != nil {
|
||||
return fmt.Errorf("init progress: %w", err)
|
||||
}
|
||||
log.Printf("新建模式:%s(%d 章)", cfg.NovelName, cfg.MaxChapters)
|
||||
if err := coordinator.Prompt(fmt.Sprintf(
|
||||
"请创作一部 %d 章的小说。要求如下:\n\n%s",
|
||||
cfg.MaxChapters, cfg.Prompt,
|
||||
)); err != nil {
|
||||
promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", cfg.MaxChapters, cfg.Prompt)
|
||||
if err := coordinator.Prompt(promptText); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("%s", recovery.Label)
|
||||
if err := coordinator.Prompt(recovery.PromptText); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 等待完成
|
||||
// 8. 等待完成
|
||||
coordinator.WaitForIdle()
|
||||
finalizeSteerIfIdle(store)
|
||||
|
||||
// 7. 输出结果
|
||||
// 9. 输出结果
|
||||
finalProgress, _ := store.LoadProgress()
|
||||
if finalProgress != nil {
|
||||
log.Printf("创作完成:%d 章,共 %d 字,输出目录:%s",
|
||||
@@ -184,20 +96,161 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent 转发。
|
||||
func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) {
|
||||
coordinator.Subscribe(func(ev agentcore.Event) {
|
||||
switch ev.Type {
|
||||
case agentcore.EventToolExecStart:
|
||||
log.Printf("[tool:start] %s", ev.Tool)
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".start", Level: "info"})
|
||||
}
|
||||
|
||||
case agentcore.EventToolExecEnd:
|
||||
if ev.IsError {
|
||||
log.Printf("[tool:error] %s", ev.Tool)
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: ev.Tool + " 执行失败", Level: "error"})
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200))
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".done", Level: "info"})
|
||||
}
|
||||
|
||||
if ev.Tool == "subagent" {
|
||||
handleSubAgentDone(coordinator, store, maxChapters, emit)
|
||||
handleEditorDone(coordinator, store, emit)
|
||||
}
|
||||
|
||||
case agentcore.EventMessageEnd:
|
||||
if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant {
|
||||
text := truncateLog(ev.Message.TextContent(), 300)
|
||||
log.Printf("[assistant] %s", text)
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "AGENT", Summary: truncateLog(ev.Message.TextContent(), 80), Level: "info"})
|
||||
}
|
||||
}
|
||||
|
||||
case agentcore.EventError:
|
||||
log.Printf("[error] %v", ev.Err)
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("%v", ev.Err), Level: "error"})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// submitSteer 提交用户干预(CLI 和 Runtime 共用)。
|
||||
func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) {
|
||||
log.Printf("[steer] 用户干预: %s", text)
|
||||
if err := store.AppendSteerEntry(domain.SteerEntry{
|
||||
Input: text,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
log.Printf("[warn] 追加干预记录失败: %v", err)
|
||||
}
|
||||
if err := store.SetPendingSteer(text); err != nil {
|
||||
log.Printf("[warn] 设置待处理干预失败: %v", err)
|
||||
}
|
||||
if err := store.SetFlow(domain.FlowSteering); err != nil {
|
||||
log.Printf("[warn] 设置流程状态失败: %v", err)
|
||||
}
|
||||
coordinator.Steer(agentcore.UserMsg(fmt.Sprintf(
|
||||
"[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text)))
|
||||
}
|
||||
|
||||
// recoveryResult 恢复链的判断结果。
|
||||
type recoveryResult struct {
|
||||
PromptText string // 恢复时的 Prompt 文本
|
||||
Label string // 恢复类型描述(供日志和 TUI 显示)
|
||||
IsNew bool // true 表示新建模式
|
||||
}
|
||||
|
||||
// determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。
|
||||
func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta, maxChapters int) recoveryResult {
|
||||
if progress == nil {
|
||||
return recoveryResult{IsNew: true}
|
||||
}
|
||||
|
||||
if progress.InProgressChapter > 0 {
|
||||
ch := progress.InProgressChapter
|
||||
scenes := len(progress.CompletedScenes)
|
||||
return recoveryResult{
|
||||
PromptText: fmt.Sprintf(
|
||||
"第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。",
|
||||
ch, scenes, scenes+1, progress.TotalChapters),
|
||||
Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes),
|
||||
}
|
||||
}
|
||||
|
||||
if len(progress.PendingRewrites) > 0 {
|
||||
verb := "重写"
|
||||
if progress.Flow == domain.FlowPolishing {
|
||||
verb = "打磨"
|
||||
}
|
||||
return recoveryResult{
|
||||
PromptText: fmt.Sprintf(
|
||||
"有 %d 章待%s(受影响章节:%v)。原因:%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。",
|
||||
len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters),
|
||||
Label: fmt.Sprintf("%s恢复:%d 章待处理 %v", verb, len(progress.PendingRewrites), progress.PendingRewrites),
|
||||
}
|
||||
}
|
||||
|
||||
if progress.Flow == domain.FlowReviewing {
|
||||
return recoveryResult{
|
||||
PromptText: fmt.Sprintf(
|
||||
"上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。",
|
||||
len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters),
|
||||
Label: "审阅恢复:上次审阅中断",
|
||||
}
|
||||
}
|
||||
|
||||
if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" {
|
||||
next := progress.NextChapter()
|
||||
return recoveryResult{
|
||||
PromptText: fmt.Sprintf(
|
||||
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。",
|
||||
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer),
|
||||
Label: "Steer 恢复:上次干预未完成,重新注入",
|
||||
}
|
||||
}
|
||||
|
||||
if progress.IsResumable() {
|
||||
next := progress.NextChapter()
|
||||
return recoveryResult{
|
||||
PromptText: fmt.Sprintf(
|
||||
"从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。",
|
||||
next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters),
|
||||
Label: fmt.Sprintf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)",
|
||||
next, len(progress.CompletedChapters), progress.TotalWordCount),
|
||||
}
|
||||
}
|
||||
|
||||
return recoveryResult{IsNew: true}
|
||||
}
|
||||
|
||||
// handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。
|
||||
// SubAgent 内部工具事件不冒泡,所以通过 meta/last_commit.json 传递信号。
|
||||
func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int) {
|
||||
func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) {
|
||||
result, err := store.LoadLastCommit()
|
||||
if err != nil || result == nil {
|
||||
return // 不是 Writer 的 commit,可能是 Architect 的 SubAgent 调用
|
||||
return
|
||||
}
|
||||
// 消费即清除,防止重复注入 FollowUp
|
||||
if err := store.ClearLastCommit(); err != nil {
|
||||
log.Printf("[host] 清除 commit 信号失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("[host] 章节提交信号:第 %d 章,%d 字,%d 个场景",
|
||||
result.Chapter, result.WordCount, result.SceneCount)
|
||||
if emit != nil {
|
||||
emit(UIEvent{
|
||||
Time: time.Now(),
|
||||
Category: "SYSTEM",
|
||||
Summary: fmt.Sprintf("第 %d 章已提交:%d 字,%d 个场景", result.Chapter, result.WordCount, result.SceneCount),
|
||||
Level: "success",
|
||||
})
|
||||
}
|
||||
|
||||
// 确定性判断 0:正在重写/打磨流程中
|
||||
progress, _ := store.LoadProgress()
|
||||
@@ -216,19 +269,16 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha
|
||||
updated, _ := store.LoadProgress()
|
||||
if updated != nil && len(updated.PendingRewrites) == 0 {
|
||||
log.Printf("[host] 所有重写/打磨已完成,恢复正常写作")
|
||||
if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil {
|
||||
log.Printf("[host] 保存检查点失败: %v", err)
|
||||
}
|
||||
if err := store.SaveCheckpoint("rewrite-done"); err != nil {
|
||||
log.Printf("[host] 保存检查点失败: %v", err)
|
||||
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
|
||||
saveCheckpoint(store, "rewrite-done")
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "所有重写/打磨已完成", Level: "success"})
|
||||
}
|
||||
} else if updated != nil {
|
||||
log.Printf("[host] 还有 %d 章待处理:%v", len(updated.PendingRewrites), updated.PendingRewrites)
|
||||
if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil {
|
||||
log.Printf("[host] 保存检查点失败: %v", err)
|
||||
}
|
||||
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
|
||||
}
|
||||
return // 重写期间不触发全书完成/审阅判断
|
||||
return
|
||||
}
|
||||
|
||||
// 确定性判断 1:全书完成
|
||||
@@ -238,8 +288,9 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha
|
||||
log.Printf("[host] 标记完成失败: %v", err)
|
||||
}
|
||||
clearHandledSteer(store)
|
||||
if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil {
|
||||
log.Printf("[host] 保存检查点失败: %v", err)
|
||||
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("全部 %d 章已完成", maxChapters), Level: "success"})
|
||||
}
|
||||
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
||||
"[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。",
|
||||
@@ -253,18 +304,19 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha
|
||||
if err := store.SetFlow(domain.FlowReviewing); err != nil {
|
||||
log.Printf("[host] 设置审阅流程失败: %v", err)
|
||||
}
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "review_required=true " + result.ReviewReason, Level: "warn"})
|
||||
}
|
||||
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
||||
"[系统] review_required=true,%s。请调用 editor 对已完成章节进行全局审阅,然后根据审阅结果决定继续写第 %d 章还是修正已有章节。",
|
||||
result.ReviewReason, result.NextChapter)))
|
||||
}
|
||||
clearHandledSteer(store)
|
||||
if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil {
|
||||
log.Printf("[host] 保存检查点失败: %v", err)
|
||||
}
|
||||
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
|
||||
}
|
||||
|
||||
// handleEditorDone 在 Editor SubAgent 完成后读取审阅信号。
|
||||
func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) {
|
||||
func handleEditorDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) {
|
||||
review, err := store.LoadLastReviewSignal()
|
||||
if err != nil {
|
||||
log.Printf("[host] 加载审阅信号失败: %v", err)
|
||||
@@ -273,7 +325,6 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) {
|
||||
if review == nil {
|
||||
return
|
||||
}
|
||||
// 消费即清除,防止重复注入 FollowUp
|
||||
if err := store.ClearLastReview(); err != nil {
|
||||
log.Printf("[host] 清除审阅信号失败: %v", err)
|
||||
}
|
||||
@@ -293,6 +344,10 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) {
|
||||
if err := store.SetFlow(domain.FlowRewriting); err != nil {
|
||||
log.Printf("[host] 设置流程状态失败: %v", err)
|
||||
}
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "REVIEW",
|
||||
Summary: fmt.Sprintf("verdict=rewrite affected=%v", review.AffectedChapters), Level: "warn"})
|
||||
}
|
||||
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
||||
"[系统] Editor 审阅结论:rewrite。%s%s请逐章调用 writer 重写受影响章节,全部完成后继续正常写作。",
|
||||
review.Summary, chaptersInfo)))
|
||||
@@ -303,17 +358,31 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) {
|
||||
if err := store.SetFlow(domain.FlowPolishing); err != nil {
|
||||
log.Printf("[host] 设置流程状态失败: %v", err)
|
||||
}
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "REVIEW",
|
||||
Summary: fmt.Sprintf("verdict=polish affected=%v", review.AffectedChapters), Level: "warn"})
|
||||
}
|
||||
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
|
||||
"[系统] Editor 审阅结论:polish。%s%s请逐章调用 writer 打磨受影响章节,全部完成后继续正常写作。",
|
||||
review.Summary, chaptersInfo)))
|
||||
default:
|
||||
// accept — 审阅通过,清除审阅状态
|
||||
if err := store.SetFlow(domain.FlowWriting); err != nil {
|
||||
log.Printf("[host] 清除审阅状态失败: %v", err)
|
||||
}
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "REVIEW", Summary: "verdict=accept 审阅通过", Level: "success"})
|
||||
}
|
||||
}
|
||||
clearHandledSteer(store)
|
||||
if err := store.SaveCheckpoint(fmt.Sprintf("review-ch%02d-%s", review.Chapter, review.Verdict)); err != nil {
|
||||
saveCheckpoint(store, fmt.Sprintf("review-ch%02d-%s", review.Chapter, review.Verdict))
|
||||
if emit != nil {
|
||||
emit(UIEvent{Time: time.Now(), Category: "CHECK",
|
||||
Summary: fmt.Sprintf("saved review-ch%02d-%s", review.Chapter, review.Verdict), Level: "info"})
|
||||
}
|
||||
}
|
||||
|
||||
func saveCheckpoint(store *state.Store, label string) {
|
||||
if err := store.SaveCheckpoint(label); err != nil {
|
||||
log.Printf("[host] 保存检查点失败: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -349,3 +418,19 @@ func finalizeSteerIfIdle(store *state.Store) {
|
||||
}
|
||||
clearHandledSteer(store)
|
||||
}
|
||||
|
||||
// createModel 根据 provider 创建对应的 LLM 模型。
|
||||
func createModel(cfg Config) (agentcore.ChatModel, error) {
|
||||
var baseURL []string
|
||||
if cfg.BaseURL != "" {
|
||||
baseURL = append(baseURL, cfg.BaseURL)
|
||||
}
|
||||
switch cfg.Provider {
|
||||
case "anthropic":
|
||||
return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||
case "gemini":
|
||||
return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||
default:
|
||||
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
|
||||
}
|
||||
}
|
||||
|
||||
333
app/runtime.go
Normal file
333
app/runtime.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/voocel/agentcore"
|
||||
"github.com/voocel/ainovel-cli/domain"
|
||||
"github.com/voocel/ainovel-cli/state"
|
||||
"github.com/voocel/ainovel-cli/tools"
|
||||
)
|
||||
|
||||
// UIEvent 是 TUI 消费的结构化事件。
|
||||
type UIEvent struct {
|
||||
Time time.Time
|
||||
Category string // TOOL / SYSTEM / REVIEW / CHECK / ERROR / AGENT
|
||||
Summary string
|
||||
Level string // info / warn / error / success
|
||||
}
|
||||
|
||||
// UISnapshot 是 TUI 渲染所需的聚合状态快照。
|
||||
type UISnapshot struct {
|
||||
NovelName string
|
||||
ModelName string
|
||||
Style string
|
||||
StatusLabel string // READY / RUNNING / REVIEW / REWRITE / COMPLETE / ERROR
|
||||
Phase string
|
||||
Flow string
|
||||
CurrentChapter int
|
||||
TotalChapters int
|
||||
CompletedCount int
|
||||
TotalWordCount int
|
||||
InProgressChapter int
|
||||
CompletedScenes int
|
||||
PendingRewrites []int
|
||||
RewriteReason string
|
||||
PendingSteer string
|
||||
RecoveryLabel string // 恢复类型描述,空表示新建
|
||||
IsRunning bool
|
||||
|
||||
// 详情区
|
||||
LastCommitSummary string
|
||||
LastReviewSummary string
|
||||
LastCheckpointName string
|
||||
RecentSummaries []string
|
||||
}
|
||||
|
||||
// Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。
|
||||
type Runtime struct {
|
||||
cfg Config
|
||||
store *state.Store
|
||||
coordinator *agentcore.Agent
|
||||
events chan UIEvent
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
closeOnce sync.Once
|
||||
doneOnce sync.Once
|
||||
}
|
||||
|
||||
// Dir 返回当前运行时的输出目录。
|
||||
func (rt *Runtime) Dir() string {
|
||||
return rt.store.Dir()
|
||||
}
|
||||
|
||||
// NewRuntime 创建 Runtime:初始化 store/model/coordinator,注册事件订阅,但不启动 Prompt。
|
||||
func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) (*Runtime, error) {
|
||||
cfg.FillDefaults()
|
||||
if err := cfg.ValidateBase(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store := state.NewStore(cfg.OutputDir)
|
||||
if err := store.Init(); err != nil {
|
||||
return nil, fmt.Errorf("init store: %w", err)
|
||||
}
|
||||
|
||||
model, err := createModel(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create model: %w", err)
|
||||
}
|
||||
|
||||
coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles)
|
||||
|
||||
rt := &Runtime{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
coordinator: coordinator,
|
||||
events: make(chan UIEvent, 100),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 注册事件订阅:确定性控制 + UIEvent 转发
|
||||
registerSubscription(coordinator, store, cfg.MaxChapters, rt.emit)
|
||||
|
||||
// 初始化运行元信息
|
||||
if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil {
|
||||
log.Printf("[warn] 初始化运行元信息失败: %v", err)
|
||||
}
|
||||
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
// emit 向事件通道发送事件,非阻塞(满时丢弃最旧事件)。
|
||||
func (rt *Runtime) emit(ev UIEvent) {
|
||||
defer func() { recover() }() // 防止 channel 关闭后写入 panic
|
||||
select {
|
||||
case rt.events <- ev:
|
||||
default:
|
||||
select {
|
||||
case <-rt.events:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case rt.events <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start 新建模式:初始化进度并启动 coordinator。
|
||||
func (rt *Runtime) Start(prompt string) error {
|
||||
rt.mu.Lock()
|
||||
if rt.running {
|
||||
rt.mu.Unlock()
|
||||
return fmt.Errorf("already running")
|
||||
}
|
||||
rt.mu.Unlock()
|
||||
|
||||
if err := rt.store.InitProgress(rt.cfg.NovelName, rt.cfg.MaxChapters); err != nil {
|
||||
return fmt.Errorf("init progress: %w", err)
|
||||
}
|
||||
|
||||
promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", rt.cfg.MaxChapters, prompt)
|
||||
if err := rt.coordinator.Prompt(promptText); err != nil {
|
||||
return fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
|
||||
rt.mu.Lock()
|
||||
rt.running = true
|
||||
rt.mu.Unlock()
|
||||
|
||||
go rt.waitDone()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume 恢复模式:根据 Progress/RunMeta 自动判断恢复类型并启动。
|
||||
// 返回恢复标签(空字符串表示无法恢复,应走新建模式)。
|
||||
func (rt *Runtime) Resume() (string, error) {
|
||||
rt.mu.Lock()
|
||||
if rt.running {
|
||||
rt.mu.Unlock()
|
||||
return "", fmt.Errorf("already running")
|
||||
}
|
||||
rt.mu.Unlock()
|
||||
|
||||
progress, _ := rt.store.LoadProgress()
|
||||
runMeta, _ := rt.store.LoadRunMeta()
|
||||
recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters)
|
||||
|
||||
if recovery.IsNew {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := rt.coordinator.Prompt(recovery.PromptText); err != nil {
|
||||
return "", fmt.Errorf("prompt: %w", err)
|
||||
}
|
||||
|
||||
rt.mu.Lock()
|
||||
rt.running = true
|
||||
rt.mu.Unlock()
|
||||
|
||||
go rt.waitDone()
|
||||
return recovery.Label, nil
|
||||
}
|
||||
|
||||
// Steer 提交用户干预。
|
||||
func (rt *Runtime) Steer(text string) {
|
||||
submitSteer(rt.store, rt.coordinator, text)
|
||||
rt.emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "干预已提交: " + truncateLog(text, 40), Level: "info"})
|
||||
}
|
||||
|
||||
// Snapshot 读取 store 聚合为状态快照。
|
||||
func (rt *Runtime) Snapshot() UISnapshot {
|
||||
snap := UISnapshot{
|
||||
NovelName: rt.cfg.NovelName,
|
||||
ModelName: rt.cfg.ModelName,
|
||||
Style: rt.cfg.Style,
|
||||
}
|
||||
|
||||
rt.mu.Lock()
|
||||
snap.IsRunning = rt.running
|
||||
rt.mu.Unlock()
|
||||
|
||||
progress, _ := rt.store.LoadProgress()
|
||||
if progress != nil {
|
||||
snap.Phase = string(progress.Phase)
|
||||
snap.Flow = string(progress.Flow)
|
||||
snap.CurrentChapter = progress.CurrentChapter
|
||||
snap.TotalChapters = progress.TotalChapters
|
||||
snap.CompletedCount = len(progress.CompletedChapters)
|
||||
snap.TotalWordCount = progress.TotalWordCount
|
||||
snap.InProgressChapter = progress.InProgressChapter
|
||||
snap.CompletedScenes = len(progress.CompletedScenes)
|
||||
snap.PendingRewrites = progress.PendingRewrites
|
||||
snap.RewriteReason = progress.RewriteReason
|
||||
}
|
||||
|
||||
runMeta, _ := rt.store.LoadRunMeta()
|
||||
if runMeta != nil {
|
||||
snap.PendingSteer = runMeta.PendingSteer
|
||||
}
|
||||
|
||||
// 状态标签映射
|
||||
snap.StatusLabel = rt.deriveStatusLabel(progress, snap.IsRunning)
|
||||
|
||||
// 恢复标签
|
||||
recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters)
|
||||
if !recovery.IsNew {
|
||||
snap.RecoveryLabel = recovery.Label
|
||||
}
|
||||
|
||||
// 详情区
|
||||
rt.fillDetails(&snap, progress)
|
||||
|
||||
return snap
|
||||
}
|
||||
|
||||
// Events 返回只读事件通道。
|
||||
func (rt *Runtime) Events() <-chan UIEvent {
|
||||
return rt.events
|
||||
}
|
||||
|
||||
// Done 返回完成信号通道。
|
||||
func (rt *Runtime) Done() <-chan struct{} {
|
||||
return rt.done
|
||||
}
|
||||
|
||||
// Close 终止 coordinator 并关闭事件通道。
|
||||
func (rt *Runtime) Close() {
|
||||
rt.coordinator.AbortSilent()
|
||||
finalizeSteerIfIdle(rt.store)
|
||||
rt.closeOnce.Do(func() {
|
||||
close(rt.events)
|
||||
})
|
||||
}
|
||||
|
||||
func (rt *Runtime) waitDone() {
|
||||
rt.coordinator.WaitForIdle()
|
||||
finalizeSteerIfIdle(rt.store)
|
||||
rt.mu.Lock()
|
||||
rt.running = false
|
||||
rt.mu.Unlock()
|
||||
rt.doneOnce.Do(func() {
|
||||
close(rt.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (rt *Runtime) deriveStatusLabel(progress *domain.Progress, isRunning bool) string {
|
||||
if progress == nil {
|
||||
return "READY"
|
||||
}
|
||||
if progress.Phase == domain.PhaseComplete {
|
||||
return "COMPLETE"
|
||||
}
|
||||
if !isRunning {
|
||||
return "READY"
|
||||
}
|
||||
switch progress.Flow {
|
||||
case domain.FlowReviewing:
|
||||
return "REVIEW"
|
||||
case domain.FlowRewriting, domain.FlowPolishing:
|
||||
return "REWRITE"
|
||||
default:
|
||||
return "RUNNING"
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *Runtime) fillDetails(snap *UISnapshot, progress *domain.Progress) {
|
||||
// 最近 commit:从 progress 的已完成章节 + 摘要推算(信号文件是一次性的,不可靠)
|
||||
if progress != nil && len(progress.CompletedChapters) > 0 {
|
||||
lastCh := progress.CompletedChapters[len(progress.CompletedChapters)-1]
|
||||
wordCount := progress.ChapterWordCounts[lastCh]
|
||||
snap.LastCommitSummary = fmt.Sprintf("第%d章 %d字", lastCh, wordCount)
|
||||
}
|
||||
|
||||
// 最近 review
|
||||
currentCh := 1
|
||||
if progress != nil && len(progress.CompletedChapters) > 0 {
|
||||
currentCh = progress.CompletedChapters[len(progress.CompletedChapters)-1]
|
||||
}
|
||||
if review, err := rt.store.LoadLastReview(currentCh); err == nil && review != nil {
|
||||
snap.LastReviewSummary = fmt.Sprintf("verdict=%s %d个问题", review.Verdict, len(review.Issues))
|
||||
if len(review.AffectedChapters) > 0 {
|
||||
snap.LastReviewSummary += fmt.Sprintf(" 影响%v", review.AffectedChapters)
|
||||
}
|
||||
}
|
||||
|
||||
// 最近 checkpoint
|
||||
snap.LastCheckpointName = rt.latestCheckpoint()
|
||||
|
||||
// 最近两章摘要
|
||||
if progress != nil {
|
||||
for i := len(progress.CompletedChapters) - 1; i >= 0 && len(snap.RecentSummaries) < 2; i-- {
|
||||
ch := progress.CompletedChapters[i]
|
||||
if summary, err := rt.store.LoadSummary(ch); err == nil && summary != nil {
|
||||
snap.RecentSummaries = append(snap.RecentSummaries,
|
||||
fmt.Sprintf("第%d章: %s", ch, truncateLog(summary.Summary, 50)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *Runtime) latestCheckpoint() string {
|
||||
dir := filepath.Join(rt.store.Dir(), "meta", "checkpoints")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() > entries[j].Name()
|
||||
})
|
||||
name := entries[0].Name()
|
||||
if ext := filepath.Ext(name); ext != "" {
|
||||
name = name[:len(name)-len(ext)]
|
||||
}
|
||||
return name
|
||||
}
|
||||
Reference in New Issue
Block a user