问题根因:askUserBridge.requests 是无缓冲 channel,TUI 退出后
listenAskUser goroutine 消失,LLM 调用 ask_user 工具时 handler()
阻塞在 `b.requests <- req`,只有 ctx 取消才能解除(会杀掉整个任务)。
修复:
- askUserBridge 新增 detachCh(chan struct{})和 atomic 的 detached 标志
- Detach() 用 CAS 保证只关闭一次 channel,防止 double-close panic
- handler() 两处 select 均增加 `<-b.detachCh` case,立即返回
"用户不在线,请自行决策" 错误,LLM 收到后自主继续
- tui/app.go fallback 分支(p.Run() 退出且任务仍在运行时)
立即调用 bridge.Detach(),解除所有 ask_user 阻塞
87 lines
2.3 KiB
Go
87 lines
2.3 KiB
Go
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()
|
||
}
|
||
}
|