Files
ainovel-clients/app/run.go
2026-03-17 09:50:32 +08:00

1070 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 模式下为 nilRuntime 模式下指向 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 {
return recoveryResult{IsNew: true}
}
guidance := planningTierGuidance(runMeta)
withGuidance := func(prompt string) string {
if guidance == "" {
return prompt
}
return prompt + "\n" + guidance
}
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_summaryvolume=%darc=%d\n"+
"3. 调用 editor 生成卷摘要save_volume_summaryvolume=%d\n"+
"%s",
result.Volume, result.Arc, result.Chapter, result.Volume, result.Arc, result.Volume, tail)))
} else {
log.Printf("[host] 第 %d 卷第 %d 弧结束,注入弧级评审指令", result.Volume, result.Arc)
if err := store.SetFlow(domain.FlowReviewing); err != nil {
log.Printf("[host] 设置审阅流程失败: %v", err)
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("第 %d 卷第 %d 弧结束,触发弧级评审", result.Volume, result.Arc), Level: "warn"})
}
coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf(
"[系统] 第 %d 卷第 %d 弧结束。请依次:\n"+
"1. 调用 editor 进行弧级评审scope=arc最新章节为第 %d 章)\n"+
"2. 调用 editor 生成弧摘要和角色快照save_arc_summaryvolume=%darc=%d\n"+
"完成后继续写下一弧的章节。",
result.Volume, result.Arc, result.Chapter, result.Volume, result.Arc)))
}
if isBookEnd {
log.Printf("[host] 全书最后一弧,评审完成后将结束")
if err := store.MarkComplete(); err != nil {
log.Printf("[host] 标记完成失败: %v", err)
}
if emit != nil {
emit(UIEvent{Time: time.Now(), Category: "SYSTEM",
Summary: fmt.Sprintf("全部 %d 章已完成,等待最终评审", progress.TotalChapters), Level: "success"})
}
}
clearHandledSteer(store)
saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter))
return true
}
// 确定性判断 1全书完成TotalChapters 由大纲自动设定)
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=%derror=%d",
review.Verdict, len(review.Issues), criticalN, review.ErrorCount())
// 宿主兜底:如果 LLM 给了 accept 但存在 critical 问题,强制升级为 rewrite
if review.Verdict == "accept" && criticalN > 0 {
log.Printf("[host] 检测到 %d 个 critical 问题但 verdict=accept强制升级为 rewrite", criticalN)
review.Verdict = "rewrite"
}
chaptersInfo := ""
if len(review.AffectedChapters) > 0 {
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()
}