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 { Provider string 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 PendingRewrites []int RewriteReason string PendingSteer string RecoveryLabel string // 恢复类型描述,空表示新建 IsRunning bool // 基础设定 Premise string // 前提概要 Outline []OutlineSnapshot // 大纲(每章标题 + 核心事件) Characters []string // 角色列表(名字 + 身份) // 详情区 LastCommitSummary string LastReviewSummary string LastCheckpointName string RecentSummaries []string } // OutlineSnapshot 是大纲条目的展示摘要。 type OutlineSnapshot struct { Chapter int Title string CoreEvent string } // Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。 type Runtime struct { cfg Config store *state.Store coordinator *agentcore.Agent askUser *tools.AskUserTool events chan UIEvent streamCh chan string // 流式 token channel(独立于 events,避免淹没事件日志) clearCh chan struct{} // 流式缓冲清空信号 done chan struct{} mu sync.Mutex running bool closeOnce sync.Once doneOnce sync.Once } // Dir 返回当前运行时的输出目录。 func (rt *Runtime) Dir() string { return rt.store.Dir() } // AskUser 返回 ask_user 工具实例,供 TUI 注入交互 handler。 func (rt *Runtime) AskUser() *tools.AskUserTool { return rt.askUser } // 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 } 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) 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, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles) rt := &Runtime{ cfg: cfg, store: store, coordinator: coordinator, askUser: askUser, events: make(chan UIEvent, 100), streamCh: make(chan string, 256), clearCh: make(chan struct{}, 4), done: make(chan struct{}), } // 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发 registerSubscription(coordinator, store, cfg.Provider, rt.emit, rt.emitDelta, rt.emitClear) // 初始化运行元信息 if err := store.InitRunMeta(cfg.Style, cfg.Provider, cfg.ModelName); err != nil { log.Printf("[warn] 初始化运行元信息失败: %v", err) } return rt, nil } // Stream 返回只读流式 token 通道。 func (rt *Runtime) Stream() <-chan string { return rt.streamCh } // StreamClear 返回只读流式清空信号通道。 func (rt *Runtime) StreamClear() <-chan struct{} { return rt.clearCh } // emitClear 发送流式缓冲清空信号,非阻塞。 func (rt *Runtime) emitClear() { defer func() { recover() }() select { case rt.clearCh <- struct{}{}: default: } } // emitDelta 向流式通道发送 token,非阻塞(满时丢弃旧数据)。 func (rt *Runtime) emitDelta(delta string) { defer func() { recover() }() select { case rt.streamCh <- delta: default: // 满了就丢弃最旧的再写入 select { case <-rt.streamCh: default: } select { case rt.streamCh <- delta: default: } } } // 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, 0); err != nil { return fmt.Errorf("init progress: %w", err) } promptText := fmt.Sprintf( "请创作一部小说。若需求中有明确章节数,请严格遵守;若无明确章节数且题材适合长篇连载,请优先规划为分层长篇结构。要求如下:\n\n%s", 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) 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, Provider: rt.cfg.Provider, 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.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) 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) close(rt.streamCh) close(rt.clearCh) }) } 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) { // 基础设定 if premise, _ := rt.store.LoadPremise(); premise != "" { snap.Premise = truncateLog(premise, 80) } if outline, _ := rt.store.LoadOutline(); len(outline) > 0 { for _, e := range outline { snap.Outline = append(snap.Outline, OutlineSnapshot{ Chapter: e.Chapter, Title: e.Title, CoreEvent: e.CoreEvent, }) } } if chars, _ := rt.store.LoadCharacters(); len(chars) > 0 { for _, c := range chars { label := c.Name if c.Role != "" { label += "(" + c.Role + ")" } snap.Characters = append(snap.Characters, label) } } // 最近 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 }