package app import ( "bufio" "context" "encoding/json" "fmt" "io" "log" "os" "path/filepath" "slices" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/voocel/agentcore" "github.com/voocel/agentcore/llm" "github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/state" "github.com/voocel/ainovel-cli/tools" "github.com/voocel/litellm" ) // emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。 // CLI 模式下为 nil,Runtime 模式下指向 events channel。 type emitFn func(UIEvent) // deltaFn 是可选的流式 token 回调,用于向 TUI 转发 LLM 生成的文字。 type deltaFn func(delta string) // clearFn 是可选的流式缓冲清空回调,在新一轮 LLM 输出开始时触发。 type clearFn func() // Run 启动小说创作流程(CLI 模式,阻塞直到完成)。 func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error { cfg.FillDefaults() if err := cfg.Validate(); err != nil { return err } log.Printf("[boot] provider=%s model=%s base_url=%s output=%s", cfg.Provider, cfg.ModelName, cfg.BaseURL, cfg.OutputDir) // 1. 初始化状态 store := state.NewStore(cfg.OutputDir) if err := store.Init(); err != nil { return fmt.Errorf("init store: %w", err) } // 1.5 日志写入文件(CLI 模式同时输出到 stderr 和日志文件) if cleanup := setupFileLogger(store.Dir()); cleanup != nil { defer cleanup() } // 2. 创建模型 model, err := createModel(cfg) if err != nil { return fmt.Errorf("create model: %w", err) } // 3. 组装 Coordinator coordinator, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles) askUser.SetHandler(cliAskUserHandler) // 4. 确定性控制面:事件监听 + FollowUp 注入 registerSubscription(coordinator, store, cfg.Provider, nil, nil, nil) // 5. 初始化运行元信息(保留已有 SteerHistory) if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil { log.Printf("[warn] 初始化运行元信息失败: %v", err) } // 6. Steer 协程:stdin 读取用户干预 go func() { scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { text := strings.TrimSpace(scanner.Text()) if text == "" { continue } submitSteer(store, coordinator, text) } }() // 7. 恢复或启动 progress, _ := store.LoadProgress() runMeta, _ := store.LoadRunMeta() recovery := determineRecovery(progress, runMeta) if recovery.IsNew { if err := store.InitProgress(cfg.NovelName, 0); err != nil { return fmt.Errorf("init progress: %w", err) } log.Printf("新建模式:%s", cfg.NovelName) promptText := fmt.Sprintf( "请创作一部小说。若需求中有明确章节数,请严格遵守;若无明确章节数且题材适合长篇连载,请优先规划为分层长篇结构。要求如下:\n\n%s", 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) } } // 8. 等待完成 coordinator.WaitForIdle() finalizeSteerIfIdle(store) // 9. 输出结果 finalProgress, _ := store.LoadProgress() if finalProgress != nil { log.Printf("创作完成:%d 章,共 %d 字,输出目录:%s", len(finalProgress.CompletedChapters), finalProgress.TotalWordCount, store.Dir()) } return nil } // registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。 func registerSubscription(coordinator *agentcore.Agent, store *state.Store, provider string, emit emitFn, onDelta deltaFn, onClear clearFn) { var lastProgressSummary string agentExt := newFieldExtractor("agent") // Coordinator → subagent 目标 agent 名称 taskExt := newFieldExtractor("task") // Coordinator → subagent 调度指令 subFilter := newStreamFilter("content") // SubAgent:文本透传 + JSON 提取 content coordinator.Subscribe(func(ev agentcore.Event) { switch ev.Type { case agentcore.EventToolExecStart: 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.EventToolExecUpdate: // 区分流式 delta 和进度摘要 if delta, ok := parseStreamDelta(ev); ok { if onDelta != nil { if text := subFilter.Feed(delta); text != "" { onDelta(text) } } return } summary := parseProgressSummary(ev) if summary == "" { return } if summary == lastProgressSummary { return } lastProgressSummary = summary log.Printf("[progress] %s", summary) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"}) } case agentcore.EventMessageStart: // 新一轮 LLM 输出开始,重置提取器 + 清空流式缓冲 agentExt.Reset() taskExt.Reset() subFilter.Reset() if onClear != nil { onClear() } case agentcore.EventMessageUpdate: // Coordinator 的流式 token:先提取 agent 名称做标题,再提取 task 内容 if ev.Delta != "" && onDelta != nil { if name := agentExt.Feed(ev.Delta); name != "" { onDelta("\n▸ " + agentLabel(name) + "\n") } if text := taskExt.Feed(ev.Delta); text != "" { onDelta(text) } } case agentcore.EventToolExecEnd: lastProgressSummary = "" if ev.IsError { detail := extractToolErrorText(ev.Result) if detail != "" { log.Printf("[tool:error] %s → %s", ev.Tool, detail) } else { log.Printf("[tool:error] %s", ev.Tool) } if emit != nil { summary := ev.Tool + " 执行失败" if detail != "" { summary += ": " + truncateLog(detail, 80) } emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: summary, Level: "error"}) } return } // subagent 结果:提取 usage 和 error,单独记录 if ev.Tool == "subagent" { logSubAgentResult(ev.Result, emit) handleFoundationCheck(coordinator, store, emit) committed := handleSubAgentDone(coordinator, store, emit) if !committed { handleUncommittedDraft(coordinator, store, emit) } handleEditorDone(coordinator, store, emit) break } // novel_context:提取加载摘要替代原始 JSON if ev.Tool == "novel_context" { if summary := extractLoadingSummary(ev.Result); summary != "" { log.Printf("[tool:done] novel_context → %s", summary) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "CONTEXT", Summary: summary, Level: "info"}) } } else { log.Printf("[tool:done] novel_context → %s", truncateLog(string(ev.Result), 200)) } if emit != nil { emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: "novel_context.done", Level: "info"}) } break } // 其他工具:保持原样 log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200)) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".done", Level: "info"}) } 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][provider=%s] %v", provider, ev.Err) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("[%s] %v", provider, ev.Err), Level: "error"}) } } }) } func planningTierGuidance(runMeta *domain.RunMeta) string { if runMeta == nil { return "" } switch runMeta.PlanningTier { case domain.PlanningTierShort: return "当前规划级别:short。如需调整设定或重做大纲,优先调用 architect_short。" case domain.PlanningTierMid: return "当前规划级别:mid。如需调整设定或重做大纲,优先调用 architect_mid。" case domain.PlanningTierLong: return "当前规划级别:long。如需调整设定或重做大纲,优先调用 architect_long,并保持分层大纲的一致性。" default: return "" } } // submitSteer 提交用户干预(CLI 和 Runtime 共用)。 func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) { log.Printf("[steer] 用户干预: %s", text) 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) } runMeta, err := store.LoadRunMeta() if err != nil { log.Printf("[warn] 读取运行元信息失败: %v", err) } guidance := planningTierGuidance(runMeta) message := fmt.Sprintf("[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text) if guidance != "" { message += "\n" + guidance } coordinator.Steer(agentcore.UserMsg(fmt.Sprintf( "%s", message))) } // recoveryResult 恢复链的判断结果。 type recoveryResult struct { PromptText string // 恢复时的 Prompt 文本 Label string // 恢复类型描述(供日志和 TUI 显示) IsNew bool // true 表示新建模式 } // determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。 // 章节总数完全来自 Progress.TotalChapters(由大纲自动设定),不再由外部传入。 func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recoveryResult { if progress == nil || progress.Phase == domain.PhaseInit { return recoveryResult{IsNew: true} } guidance := planningTierGuidance(runMeta) withGuidance := func(prompt string) string { if guidance == "" { return prompt } return prompt + "\n" + guidance } // 规划阶段恢复:premise 已保存但 outline/characters 尚未完成 if progress.Phase == domain.PhasePremise { return recoveryResult{ PromptText: withGuidance("前提设定已保存,但大纲/角色设定尚未完成。请调用 novel_context 查看已有设定,然后继续完成 outline、characters、world_rules 的规划,完成后开始逐章写作。"), Label: "规划恢复:继续生成大纲/角色设定", } } // 规划阶段恢复:outline 已保存但写作尚未开始 if progress.Phase == domain.PhaseOutline { return recoveryResult{ PromptText: withGuidance(fmt.Sprintf( "大纲规划已完成,尚未开始写作。请从第 1 章开始逐章写作,总共需要写 %d 章。", progress.TotalChapters)), Label: fmt.Sprintf("写作恢复:大纲就绪,从第1章开始(共 %d 章)", progress.TotalChapters), } } if progress.InProgressChapter > 0 { ch := progress.InProgressChapter return recoveryResult{ PromptText: withGuidance(fmt.Sprintf( "第 %d 章正在进行中,已有部分草稿。请调用 writer 继续完成该章(可用 read_chapter 读取已有草稿)。总共需要写 %d 章。", ch, progress.TotalChapters)), Label: fmt.Sprintf("恢复:第 %d 章进行中", ch), } } if len(progress.PendingRewrites) > 0 { verb := "重写" if progress.Flow == domain.FlowPolishing { verb = "打磨" } return recoveryResult{ PromptText: withGuidance(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: withGuidance(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: withGuidance(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: withGuidance(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} } // 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 调用完成后读取文件系统信号,注入确定性任务。 // 返回 true 表示检测到 commit 信号(Writer 正常完成)。 func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) bool { result, err := store.LoadLastCommit() if err != nil || result == nil { return false } if err := store.ClearLastCommit(); err != nil { log.Printf("[host] 清除 commit 信号失败: %v", err) } log.Printf("[host] 章节提交信号:第 %d 章,%d 字", result.Chapter, result.WordCount) if emit != nil { emit(UIEvent{ Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("第 %d 章已提交:%d 字", result.Chapter, result.WordCount), Level: "success", }) } // outline_feedback 处理:Writer 反馈大纲偏离 if result.Feedback != nil && result.Feedback.Deviation != "" { log.Printf("[host] outline_feedback: %s", result.Feedback.Deviation) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "Writer 反馈大纲偏离: " + truncateLog(result.Feedback.Deviation, 60), Level: "info"}) } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] Writer 在第 %d 章写作中发现大纲偏离。偏离:%s。建议:%s。请评估是否需要调整后续大纲。", result.Chapter, result.Feedback.Deviation, result.Feedback.Suggestion))) } // 确定性判断 0:正在重写/打磨流程中 progress, _ := store.LoadProgress() if progress != nil && (progress.Flow == domain.FlowRewriting || progress.Flow == domain.FlowPolishing) { if !slices.Contains(progress.PendingRewrites, result.Chapter) { log.Printf("[host] 警告:重写期间提交了非队列章节 %d,拒绝并提醒", result.Chapter) coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] 当前处于重写流程,但提交了非队列章节(第 %d 章)。请先完成待重写章节 %v 后再继续新章节。", result.Chapter, progress.PendingRewrites))) return true } if err := store.CompleteRewrite(result.Chapter); err != nil { log.Printf("[host] 完成重写标记失败: %v", err) } clearHandledSteer(store) updated, _ := store.LoadProgress() if updated != nil && len(updated.PendingRewrites) == 0 { log.Printf("[host] 所有重写/打磨已完成,恢复正常写作") 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) saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) } 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 由大纲自动设定) totalChapters := 0 if progress != nil { totalChapters = progress.TotalChapters } if totalChapters > 0 && result.NextChapter > totalChapters { log.Printf("[host] 所有 %d 章已完成,注入完成指令", totalChapters) if err := store.MarkComplete(); err != nil { log.Printf("[host] 标记完成失败: %v", err) } clearHandledSteer(store) saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("全部 %d 章已完成", totalChapters), Level: "success"}) } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。", totalChapters))) return true } // 确定性判断 2:需要全局审阅 if result.ReviewRequired { log.Printf("[host] review_required=true(%s),注入审阅指令", result.ReviewReason) if err := store.SetFlow(domain.FlowReviewing); err != nil { log.Printf("[host] 设置审阅流程失败: %v", err) } 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) 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 完成后读取审阅信号。 func handleEditorDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) { review, err := store.LoadLastReviewSignal() if err != nil { log.Printf("[host] 加载审阅信号失败: %v", err) return } if review == nil { return } if err := store.ClearLastReview(); err != nil { log.Printf("[host] 清除审阅信号失败: %v", err) } 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 := "" if len(review.AffectedChapters) > 0 { chaptersInfo = fmt.Sprintf("受影响章节:%v。", review.AffectedChapters) } switch review.Verdict { case "rewrite": if err := store.SetPendingRewrites(review.AffectedChapters, review.Summary); err != nil { log.Printf("[host] 设置重写队列失败: %v", err) } if err := store.SetFlow(domain.FlowRewriting); err != nil { log.Printf("[host] 设置流程状态失败: %v", err) } 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))) case "polish": if err := store.SetPendingRewrites(review.AffectedChapters, review.Summary); err != nil { log.Printf("[host] 设置打磨队列失败: %v", err) } if err := store.SetFlow(domain.FlowPolishing); err != nil { log.Printf("[host] 设置流程状态失败: %v", err) } 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: 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) 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) } } // parseStreamDelta 从 EventToolExecUpdate 中提取流式 delta 文本。 // 如果事件是 SubAgent 转发的 token delta(含 "delta" 字段),返回文本和 true。 func parseStreamDelta(ev agentcore.Event) (string, bool) { if len(ev.Result) == 0 { return "", false } var data struct { Delta string `json:"delta"` } if err := json.Unmarshal(ev.Result, &data); err != nil { return "", false } if data.Delta != "" { return data.Delta, true } return "", false } // parseProgressSummary 从 EventToolExecUpdate 中提取可读摘要。 func parseProgressSummary(ev agentcore.Event) string { if len(ev.Result) == 0 { return "progress" } var data struct { Agent string `json:"agent"` Tool string `json:"tool"` Turn int `json:"turn"` Error bool `json:"error"` Message string `json:"message"` Thinking string `json:"thinking"` } if err := json.Unmarshal(ev.Result, &data); err != nil { return truncateLog(string(ev.Result), 60) } // subagent 的 thinking 更新属于高频内部推理,不适合刷到事件流面板。 if data.Thinking != "" && data.Tool == "" { return "" } if data.Tool != "" { if data.Error { if data.Message != "" { return fmt.Sprintf("%s → %s (error: %s)", data.Agent, data.Tool, truncateLog(data.Message, 120)) } return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool) } return fmt.Sprintf("%s → %s", data.Agent, data.Tool) } if data.Turn > 0 { return fmt.Sprintf("%s turn %d", data.Agent, data.Turn) } return truncateLog(string(ev.Result), 60) } // extractLoadingSummary 从 novel_context 的返回 JSON 中提取 _loading_summary 字段。 func extractLoadingSummary(result json.RawMessage) string { if len(result) == 0 { return "" } var data struct { Summary string `json:"_loading_summary"` } if err := json.Unmarshal(result, &data); err != nil { return "" } return data.Summary } // logSubAgentResult 从 subagent 结果中提取 usage 和 error,分别记录结构化日志。 func logSubAgentResult(result json.RawMessage, emit emitFn) { if len(result) == 0 { log.Printf("[tool:done] subagent → (empty)") return } var data struct { Output string `json:"output"` Error string `json:"error"` Usage struct { Input int `json:"input"` Output int `json:"output"` CacheRead int `json:"cache_read"` CacheWrite int `json:"cache_write"` Cost float64 `json:"cost"` Turns int `json:"turns"` Tools int `json:"tools"` } `json:"usage"` } if err := json.Unmarshal(result, &data); err != nil { log.Printf("[tool:done] subagent → %s", truncateLog(string(result), 200)) return } // 记录 usage u := data.Usage log.Printf("[usage] input=%d output=%d cache_read=%d turns=%d tools=%d", u.Input, u.Output, u.CacheRead, u.Turns, u.Tools) if data.Error != "" { log.Printf("[subagent:error] %s", data.Error) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: "subagent: " + truncateLog(data.Error, 80), Level: "error"}) } return } log.Printf("[tool:done] subagent → %s", truncateLog(data.Output, 200)) if emit != nil { emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: "subagent.done", Level: "info"}) } } func extractToolErrorText(result json.RawMessage) string { if len(result) == 0 { return "" } var plain string if err := json.Unmarshal(result, &plain); err == nil { return plain } var obj struct { Error string `json:"error"` Message string `json:"message"` Detail string `json:"detail"` } if err := json.Unmarshal(result, &obj); err == nil { switch { case obj.Error != "": return obj.Error case obj.Message != "": return obj.Message case obj.Detail != "": return obj.Detail } } return truncateLog(string(result), 160) } // agentLabel 将内部 agent 名称映射为用户友好的标签。 func agentLabel(name string) string { switch name { case "architect_short", "architect_mid", "architect_long": return "Architect 规划中" case "writer": return "Writer 创作中" case "editor": return "Editor 审阅中" default: return name } } func truncateLog(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes]) + "..." } func clearHandledSteer(store *state.Store) { if err := store.ClearPendingSteer(); err != nil { log.Printf("[host] 清除待处理干预失败: %v", err) } progress, _ := store.LoadProgress() if progress != nil && progress.Flow == domain.FlowSteering { if err := store.SetFlow(domain.FlowWriting); err != nil { log.Printf("[host] 重置流程状态失败: %v", err) } } } func finalizeSteerIfIdle(store *state.Store) { runMeta, _ := store.LoadRunMeta() progress, _ := store.LoadProgress() if runMeta == nil || runMeta.PendingSteer == "" || progress == nil { return } if progress.Flow != domain.FlowSteering { return } clearHandledSteer(store) } // setupFileLogger 设置 CLI 模式日志,同时输出到 stderr 和日志文件。 func setupFileLogger(outputDir string) func() { logPath := filepath.Join(outputDir, "meta", "cli.log") if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { return nil } f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return nil } log.SetOutput(io.MultiWriter(os.Stderr, f)) return func() { log.SetOutput(os.Stderr) _ = f.Close() } } // 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...) case "openrouter": return newOpenRouterModel(cfg.ModelName, cfg.APIKey, baseURL...) default: // openai 及其他 OpenAI 兼容服务 return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) } } func newOpenRouterModel(model, apiKey string, baseURL ...string) (agentcore.ChatModel, error) { cfg := litellm.ProviderConfig{APIKey: apiKey} if len(baseURL) > 0 { cfg.BaseURL = baseURL[0] } client, err := litellm.NewWithProvider("openrouter", cfg) if err != nil { return nil, fmt.Errorf("openrouter: %w", err) } return llm.NewLiteLLMAdapter(model, client), nil } // cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。 func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) { resp := &tools.AskUserResponse{ Answers: make(map[string]string), Notes: make(map[string]string), } for _, q := range questions { m := newSelectModel(q) p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) final, err := p.Run() if err != nil { return resp, err } result := final.(selectModel) if result.cancelled { continue } resp.Answers[q.Question] = result.answer if result.isCustom { resp.Notes[q.Question] = result.answer } } return resp, nil } // ---------- 交互式选择器(bubbletea mini program)---------- var ( selectCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) selectDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) selectHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")) selectInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) ) type selectModel struct { question tools.Question items []string // label 列表,最后一项是"自由输入" descs []string // 描述列表 cursor int answer string isCustom bool cancelled bool typing bool // 是否进入自由输入模式 input string // 自由输入缓冲 } func newSelectModel(q tools.Question) selectModel { items := make([]string, 0, len(q.Options)+1) descs := make([]string, 0, len(q.Options)+1) for _, opt := range q.Options { items = append(items, opt.Label) descs = append(descs, opt.Description) } items = append(items, "自由输入") descs = append(descs, "以上都不合适,我自己写") return selectModel{question: q, items: items, descs: descs} } func (m selectModel) Init() tea.Cmd { return nil } func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.typing { return m.updateTyping(msg) } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < len(m.items)-1 { m.cursor++ } case "enter": if m.cursor == len(m.items)-1 { m.typing = true return m, nil } m.answer = m.items[m.cursor] return m, tea.Quit case "q", "esc", "ctrl+c": m.cancelled = true return m, tea.Quit } } return m, nil } func (m selectModel) updateTyping(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter": text := strings.TrimSpace(m.input) if text == "" { return m, nil } m.answer = text m.isCustom = true return m, tea.Quit case "esc": m.typing = false m.input = "" return m, nil case "ctrl+c": m.cancelled = true return m, tea.Quit case "backspace": if len(m.input) > 0 { runes := []rune(m.input) m.input = string(runes[:len(runes)-1]) } default: if msg.Type == tea.KeyRunes { m.input += string(msg.Runes) } else if msg.Type == tea.KeySpace { m.input += " " } } } return m, nil } func (m selectModel) View() string { var b strings.Builder b.WriteString(selectHeaderStyle.Render(fmt.Sprintf("[%s] %s", m.question.Header, m.question.Question))) b.WriteString("\n\n") for i, item := range m.items { cursor := " " if i == m.cursor { cursor = selectCursorStyle.Render("❯ ") } label := item if i == m.cursor { label = selectCursorStyle.Render(item) } desc := selectDescStyle.Render(" " + m.descs[i]) b.WriteString(fmt.Sprintf("%s%s%s\n", cursor, label, desc)) } if m.typing { b.WriteString("\n") b.WriteString(selectInputStyle.Render(" ✎ ")) b.WriteString(m.input) b.WriteString(selectCursorStyle.Render("▌")) b.WriteString(selectDescStyle.Render(" (Enter 确认, Esc 返回)")) } else { b.WriteString(selectDescStyle.Render("\n ↑↓ 选择 Enter 确认 Esc 取消")) } return b.String() }