问题根因: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 阻塞
303 lines
6.9 KiB
Go
303 lines
6.9 KiB
Go
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
|
||
}
|