Files
ainovel-clients/tui/ask_user.go
shiyue c73f024c83 fix: tmux detach 后 ask_user 不再永久阻塞
问题根因: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 阻塞
2026-03-18 22:31:15 +08:00

303 lines
6.9 KiB
Go
Raw Permalink 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 tui
import (
"context"
"fmt"
"strings"
"sync/atomic"
"github.com/charmbracelet/lipgloss"
"github.com/voocel/ainovel-cli/tools"
)
type askUserRequest struct {
questions []tools.Question
resultCh chan askUserResult
}
type askUserResult struct {
resp *tools.AskUserResponse
err error
}
// askUserBridge 在 TUI goroutine 与 LLM handler goroutine 之间传递 ask_user 请求。
// detachCh 被关闭后,所有阻塞在 handler() 中的调用会立即返回错误,让 LLM 自行决策。
type askUserBridge struct {
requests chan askUserRequest
detachCh chan struct{}
detached uint32 // atomic: 0=active, 1=detached
}
func newAskUserBridge() *askUserBridge {
return &askUserBridge{
requests: make(chan askUserRequest),
detachCh: make(chan struct{}),
}
}
// Detach 标记桥接器为已脱离状态,关闭 detachCh使所有阻塞在 handler() 中的
// goroutine 立即收到错误并返回,让 LLM 收到"用户不在线,请自行决策"后继续运行。
// 多次调用安全(只有第一次真正关闭 channel
func (b *askUserBridge) Detach() {
if atomic.CompareAndSwapUint32(&b.detached, 0, 1) {
close(b.detachCh)
}
}
func (b *askUserBridge) handler(ctx context.Context, questions []tools.Question) (*tools.AskUserResponse, error) {
// 若已脱离 TUI直接返回错误让 tools/ask_user.go 将错误提示返回给 LLM
if atomic.LoadUint32(&b.detached) == 1 {
return nil, fmt.Errorf("用户不在线,请自行决策")
}
req := askUserRequest{
questions: questions,
resultCh: make(chan askUserResult, 1),
}
select {
case b.requests <- req:
case <-b.detachCh:
return nil, fmt.Errorf("用户不在线,请自行决策")
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case result := <-req.resultCh:
return result.resp, result.err
case <-b.detachCh:
return nil, fmt.Errorf("用户不在线,请自行决策")
case <-ctx.Done():
return nil, ctx.Err()
}
}
type askUserState struct {
request askUserRequest
index int
cursor int
typing bool
input string
selected map[int]bool
answers map[string]string
notes map[string]string
}
func newAskUserState(req askUserRequest) *askUserState {
return &askUserState{
request: req,
selected: make(map[int]bool),
answers: make(map[string]string),
notes: make(map[string]string),
}
}
func (s *askUserState) currentQuestion() tools.Question {
return s.request.questions[s.index]
}
func (s *askUserState) optionCount() int {
return len(s.currentQuestion().Options) + 1
}
func (s *askUserState) choiceLabel(idx int) string {
q := s.currentQuestion()
if idx < len(q.Options) {
return q.Options[idx].Label
}
return "自由输入"
}
func (s *askUserState) choiceDescription(idx int) string {
q := s.currentQuestion()
if idx < len(q.Options) {
return q.Options[idx].Description
}
return "以上都不合适,自己补充"
}
func (s *askUserState) moveCursor(delta int) {
total := s.optionCount()
if total == 0 {
s.cursor = 0
return
}
s.cursor = (s.cursor + delta + total) % total
}
func (s *askUserState) toggleSelection() {
if s.selected[s.cursor] {
delete(s.selected, s.cursor)
return
}
s.selected[s.cursor] = true
}
func (s *askUserState) finishCurrentAnswer() bool {
q := s.currentQuestion()
if s.typing {
text := strings.TrimSpace(s.input)
if text == "" {
return false
}
s.answers[q.Question] = text
s.notes[q.Question] = text
return s.advance()
}
if q.MultiSelect {
var values []string
var custom string
for idx := 0; idx < s.optionCount(); idx++ {
if !s.selected[idx] {
continue
}
if idx < len(q.Options) {
values = append(values, q.Options[idx].Label)
continue
}
custom = strings.TrimSpace(s.input)
}
if custom != "" {
values = append(values, custom)
s.notes[q.Question] = custom
}
if len(values) == 0 {
return false
}
s.answers[q.Question] = strings.Join(values, "、")
return s.advance()
}
if s.cursor >= len(q.Options) {
s.typing = true
s.input = ""
return false
}
s.answers[q.Question] = q.Options[s.cursor].Label
return s.advance()
}
func (s *askUserState) advance() bool {
s.index++
if s.index >= len(s.request.questions) {
return true
}
s.cursor = 0
s.typing = false
s.input = ""
s.selected = make(map[int]bool)
return false
}
func (s *askUserState) submit() {
s.request.resultCh <- askUserResult{
resp: &tools.AskUserResponse{
Answers: s.answers,
Notes: s.notes,
},
}
}
func (s *askUserState) cancelCurrentTyping() {
s.typing = false
s.input = ""
}
func renderAskUserModal(width, height int, state *askUserState) string {
if state == nil {
return ""
}
q := state.currentQuestion()
boxW := minInt(maxInt(width*60/100, 52), width-4)
boxH := minInt(maxInt(height*60/100, 16), height-4)
if boxW < 40 {
boxW = maxInt(width-2, 20)
}
if boxH < 10 {
boxH = maxInt(height-2, 8)
}
var b strings.Builder
title := fmt.Sprintf("需要补充信息 %d/%d", state.index+1, len(state.request.questions))
b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(title))
b.WriteString("\n\n")
if q.Header != "" {
b.WriteString(highlightValueStyle.Render(q.Header))
b.WriteString("\n")
}
b.WriteString(cardContentStyle.Render(q.Question))
b.WriteString("\n\n")
for idx := 0; idx < state.optionCount(); idx++ {
prefix := " "
if state.cursor == idx {
prefix = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(" ")
}
label := state.choiceLabel(idx)
if q.MultiSelect {
marker := "[ ]"
if state.selected[idx] {
marker = "[x]"
}
label = marker + " " + label
}
b.WriteString(prefix + cardContentStyle.Render(label))
b.WriteString("\n")
b.WriteString(" " + lipgloss.NewStyle().Foreground(colorDim).Render(state.choiceDescription(idx)))
b.WriteString("\n")
}
if state.typing || (q.MultiSelect && state.selected[len(q.Options)]) {
b.WriteString("\n")
b.WriteString(panelTitleStyle.Render("补充内容"))
b.WriteString("\n")
content := state.input
if content == "" {
content = "请输入..."
}
style := lipgloss.NewStyle().
Width(boxW-8).
Border(baseBorder).
BorderForeground(colorDim).
Padding(0, 1)
b.WriteString(style.Render(content))
b.WriteString("\n")
}
hint := "↑↓ 选择 · Enter 确认"
if q.MultiSelect {
hint = "↑↓ 选择 · Space 勾选 · Enter 提交"
}
if state.typing {
hint = "输入补充内容 · Enter 确认 · Esc 返回选项"
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(hint))
box := lipgloss.NewStyle().
Width(boxW).
Height(boxH).
Border(baseBorder).
BorderForeground(colorAccent).
Padding(1, 2).
Background(lipgloss.Color("#2a2520")).
Render(b.String())
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}