package tui import ( "fmt" "io" "log" "os" "os/signal" "path/filepath" "syscall" tea "github.com/charmbracelet/bubbletea" "github.com/voocel/ainovel-cli/app" "github.com/voocel/ainovel-cli/tools" ) // Run 启动 TUI 模式。 func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[string]string) error { rt, err := app.NewRuntime(cfg, refs, prompts, styles) if err != nil { return err } bridge := newAskUserBridge() rt.AskUser().SetHandler(bridge.handler) restoreLog := redirectLogger(rt.Dir()) defer restoreLog() m := NewModel(rt, bridge) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) _, err = p.Run() // bubbletea 退出后,检查任务是否仍在运行 snap := rt.Snapshot() if snap.IsRunning { // 任务仍在运行(可能是 tmux detach 导致 TUI 退出), // 不中断任务,回退到无 UI 阻塞等待模式。 // 脱离 TUI 后解除所有阻塞在 ask_user 的 handler goroutine, // 让 LLM 收到"用户不在线,请自行决策"提示后继续运行, // 而非无限等待一个永远不会到来的用户输入。 bridge.Detach() fmt.Println("\n[TUI 已退出,任务仍在后台运行中...]") fmt.Printf("[小说: %s | 阶段: %s | 进度: %d/%d 章]\n", snap.NovelName, snap.Phase, snap.CompletedCount, snap.TotalChapters) fmt.Println("[等待任务完成,按 Ctrl+C 强制中断]") // 监听中断信号 sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) select { case <-rt.Done(): fmt.Println("\n[任务已完成!]") case <-sigCh: fmt.Println("\n[收到中断信号,正在停止...]") rt.Close() } return nil } // 任务未在运行,正常关闭 rt.Close() return err } // redirectLogger 将标准日志重定向到文件,避免破坏 TUI 画面。 func redirectLogger(outputDir string) func() { prev := log.Writer() logPath := filepath.Join(outputDir, "meta", "tui.log") if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { log.SetOutput(io.Discard) return func() { log.SetOutput(prev) } } f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { log.SetOutput(io.Discard) return func() { log.SetOutput(prev) } } log.SetOutput(f) return func() { log.SetOutput(prev) _ = f.Close() } }