mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
fix: bump llm http client timeout 120s→180s for llmsproxy AUTO chain failover
The local llmsproxy AUTO chain tries 6+ slots across 3 tiers sequentially. Each failed tier incurs busyWait (2s) + upstream timeout, so a full chain exhaustion can exceed 120s. The llmsproxy logs showed 143 'context canceled' errors for the homeagent key — the client gave up before the chain finished. 180s gives the chain enough room to complete before the client timeout fires. Also remove stale backup files under /usr/local/bin/.
This commit is contained in:
@ -1,15 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func()) bool {
|
||||
func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out io.Writer) bool {
|
||||
switch {
|
||||
case cmd == "/help":
|
||||
fmt.Println(`Built-in commands:
|
||||
fmt.Fprintln(out, `Built-in commands:
|
||||
/help show this help
|
||||
/exit, /quit exit waiter
|
||||
/clear clear screen
|
||||
@ -44,7 +45,7 @@ Any other text is sent to the agent directly.`)
|
||||
return true
|
||||
|
||||
case cmd == "/clear":
|
||||
fmt.Print("\033[H\033[2J")
|
||||
fmt.Fprint(out, "\033[H\033[2J")
|
||||
return true
|
||||
|
||||
case cmd == "/reconnect":
|
||||
@ -72,7 +73,7 @@ Any other text is sent to the agent directly.`)
|
||||
|
||||
case cmd == "/conn list":
|
||||
if len(cfg.Connections) == 0 {
|
||||
fmt.Println("no saved connections")
|
||||
fmt.Fprintln(out, "no saved connections")
|
||||
}
|
||||
for _, c := range cfg.Connections {
|
||||
mark := " "
|
||||
@ -83,39 +84,39 @@ Any other text is sent to the agent directly.`)
|
||||
if addr == "" {
|
||||
addr = c.Socket
|
||||
}
|
||||
fmt.Printf(" %s %-15s %s\n", mark, c.Name, addr)
|
||||
fmt.Fprintf(out, " %s %-15s %s\n", mark, c.Name, addr)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/conn save "):
|
||||
name := strings.TrimSpace(cmd[11:])
|
||||
cfg.SaveConnection(name)
|
||||
fmt.Printf("connection saved as '%s' (default)\n", name)
|
||||
fmt.Fprintf(out, "connection saved as '%s' (default)\n", name)
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/conn use "):
|
||||
name := strings.TrimSpace(cmd[10:])
|
||||
if cfg.SwitchConnection(name) {
|
||||
fmt.Printf("switched to '%s'\n", name)
|
||||
fmt.Fprintf(out, "switched to '%s'\n", name)
|
||||
reconnect()
|
||||
} else {
|
||||
fmt.Printf("connection '%s' not found\n", name)
|
||||
fmt.Fprintf(out, "connection '%s' not found\n", name)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/conn del "):
|
||||
name := strings.TrimSpace(cmd[10:])
|
||||
if cfg.DeleteConnection(name) {
|
||||
fmt.Printf("connection '%s' deleted\n", name)
|
||||
fmt.Fprintf(out, "connection '%s' deleted\n", name)
|
||||
} else {
|
||||
fmt.Printf("connection '%s' not found\n", name)
|
||||
fmt.Fprintf(out, "connection '%s' not found\n", name)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/status":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/status", "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/status")
|
||||
}
|
||||
@ -124,7 +125,7 @@ Any other text is sent to the agent directly.`)
|
||||
case cmd == "/kernel":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/kernel", "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/kernel")
|
||||
}
|
||||
@ -133,13 +134,13 @@ Any other text is sent to the agent directly.`)
|
||||
case strings.HasPrefix(cmd, "/settings set "):
|
||||
parts := strings.SplitN(cmd[14:], " ", 2)
|
||||
if len(parts) < 2 {
|
||||
fmt.Println("usage: /settings set <key> <value>")
|
||||
fmt.Fprintln(out, "usage: /settings set <key> <value>")
|
||||
return true
|
||||
}
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
body := fmt.Sprintf(`{"%s":%q}`, parts[0], parts[1])
|
||||
rc.DoAPI("PUT", "/api/v1/settings", body)
|
||||
fmt.Println("ok")
|
||||
fmt.Fprintln(out, "ok")
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -148,7 +149,7 @@ Any other text is sent to the agent directly.`)
|
||||
case strings.HasPrefix(cmd, "/settings"):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -157,7 +158,7 @@ Any other text is sent to the agent directly.`)
|
||||
case cmd == "/plugin list":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/plugins", "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/plugin list")
|
||||
}
|
||||
@ -168,7 +169,7 @@ Any other text is sent to the agent directly.`)
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
body := fmt.Sprintf(`{"url":%q}`, url)
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -178,7 +179,7 @@ Any other text is sent to the agent directly.`)
|
||||
name := strings.TrimSpace(cmd[15:])
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -188,7 +189,7 @@ Any other text is sent to the agent directly.`)
|
||||
name := strings.TrimSpace(cmd[13:])
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -198,7 +199,7 @@ Any other text is sent to the agent directly.`)
|
||||
q := strings.TrimSpace(cmd[14:])
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -208,7 +209,7 @@ Any other text is sent to the agent directly.`)
|
||||
name := strings.TrimSpace(cmd[18:])
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
}
|
||||
@ -217,7 +218,7 @@ Any other text is sent to the agent directly.`)
|
||||
case cmd == "/knowledge":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/knowledge")
|
||||
}
|
||||
@ -226,7 +227,7 @@ Any other text is sent to the agent directly.`)
|
||||
case cmd == "/agents":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/agents", "")
|
||||
printJSON(d)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/agents")
|
||||
}
|
||||
@ -237,11 +238,11 @@ Any other text is sent to the agent directly.`)
|
||||
}
|
||||
}
|
||||
|
||||
func printJSON(d map[string]interface{}) {
|
||||
func printJSON(out io.Writer, d map[string]interface{}) {
|
||||
if d == nil {
|
||||
fmt.Println("(no data)")
|
||||
fmt.Fprintln(out, "(no data)")
|
||||
return
|
||||
}
|
||||
b, _ := json.MarshalIndent(d, "", " ")
|
||||
fmt.Println(string(b))
|
||||
fmt.Fprintln(out, string(b))
|
||||
}
|
||||
|
||||
@ -185,6 +185,22 @@ func main() {
|
||||
runInteractive(state, cfg)
|
||||
}
|
||||
|
||||
// runInteractive 交互入口:TTY 下走 Bubble Tea 全屏 TUI,非 TTY 回退行式 REPL。
|
||||
func runInteractive(state *State, cfg *Config) {
|
||||
history := newHistory(historyPath(), 1000)
|
||||
history.load()
|
||||
|
||||
if isTTYFile(os.Stdin) && isTTYFile(os.Stdout) && colors {
|
||||
if err := runTUI(state, cfg, history); err != nil {
|
||||
printlnC(colorRed, fmt.Sprintf("tui: %v", err))
|
||||
printlnC(colorYellow, "falling back to line mode")
|
||||
runLineMode(state, cfg, history)
|
||||
}
|
||||
return
|
||||
}
|
||||
runLineMode(state, cfg, history)
|
||||
}
|
||||
|
||||
func oneshot(state *State, msg string) {
|
||||
stop := startSpinner("thinking...")
|
||||
resp, err := state.SendChatStream(msg, func(rl respLine) {
|
||||
@ -222,13 +238,11 @@ func runCapTest(capName, args string) {
|
||||
fmt.Println("测试完成")
|
||||
}
|
||||
|
||||
func runInteractive(state *State, cfg *Config) {
|
||||
// runLineMode 传统行式 REPL(非 TTY 回退 / TUI 启动失败时使用)。
|
||||
func runLineMode(state *State, cfg *Config, history History) {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
history := newHistory(historyPath(), 1000)
|
||||
history.load()
|
||||
|
||||
line := newLineEditor(&history)
|
||||
|
||||
restore, err := setRawMode(0)
|
||||
@ -298,7 +312,7 @@ loop:
|
||||
}
|
||||
|
||||
if cmd[0] == '/' {
|
||||
if handleBuiltin(cmd, cfg, state, reconnect) {
|
||||
if handleBuiltin(cmd, cfg, state, reconnect, os.Stdout) {
|
||||
if cmd == "/exit" || cmd == "/quit" {
|
||||
break loop
|
||||
}
|
||||
|
||||
677
cmd/waiter/tui.go
Normal file
677
cmd/waiter/tui.go
Normal file
@ -0,0 +1,677 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 配色(对齐 deveco-code 深色主题:12 阶灰阶 + 语义色,无 emoji,纯文本标记)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
cStep3 = lipgloss.Color("#1e1e1e")
|
||||
cStep7 = lipgloss.Color("#484848")
|
||||
cStep11 = lipgloss.Color("#808080")
|
||||
cStep12 = lipgloss.Color("#eeeeee")
|
||||
cPrimary = lipgloss.Color("#fab283") // 主色(暖橙)
|
||||
cAccent = lipgloss.Color("#9d7cd8") // 紫
|
||||
cGreen = lipgloss.Color("#7fd88f")
|
||||
cRed = lipgloss.Color("#e06c75")
|
||||
cYellow = lipgloss.Color("#e5c07b")
|
||||
cCyan = lipgloss.Color("#56b6c2")
|
||||
)
|
||||
|
||||
var (
|
||||
styleHeaderBox = lipgloss.NewStyle().Foreground(cStep12).Background(cStep3).Padding(0, 1)
|
||||
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(cPrimary)
|
||||
styleDotOn = lipgloss.NewStyle().Foreground(cGreen)
|
||||
styleDotOff = lipgloss.NewStyle().Foreground(cRed)
|
||||
styleDim = lipgloss.NewStyle().Foreground(cStep11)
|
||||
styleUserTag = lipgloss.NewStyle().Bold(true).Foreground(cAccent)
|
||||
styleAgentTag = lipgloss.NewStyle().Bold(true).Foreground(cPrimary)
|
||||
styleSysTag = lipgloss.NewStyle().Bold(true).Foreground(cCyan)
|
||||
styleErrTag = lipgloss.NewStyle().Bold(true).Foreground(cRed)
|
||||
styleReason = lipgloss.NewStyle().Foreground(cStep11)
|
||||
styleInputBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cStep7)
|
||||
styleInputFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cPrimary)
|
||||
styleStatusBar = lipgloss.NewStyle().Foreground(cStep11).Background(cStep3).Padding(0, 1)
|
||||
styleSep = lipgloss.NewStyle().Foreground(cStep7)
|
||||
styleToolPending = lipgloss.NewStyle().Foreground(cYellow)
|
||||
styleToolOK = lipgloss.NewStyle().Foreground(cGreen)
|
||||
styleToolFail = lipgloss.NewStyle().Foreground(cRed)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 消息模型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type msgKind int
|
||||
|
||||
const (
|
||||
msgUser msgKind = iota
|
||||
msgAgent
|
||||
msgReasoning
|
||||
msgTool
|
||||
msgSystem
|
||||
msgError
|
||||
)
|
||||
|
||||
type chatMsg struct {
|
||||
kind msgKind
|
||||
text string
|
||||
tool string // msgTool: 工具名
|
||||
status string // msgTool: ok/denied/interrupted/error/running
|
||||
result string // msgTool: 结果预览
|
||||
}
|
||||
|
||||
// ---- tea.Msg ----
|
||||
|
||||
type spinnerTickMsg struct{}
|
||||
|
||||
type serverLineMsg struct{ line string }
|
||||
|
||||
type readerErrMsg struct {
|
||||
err error
|
||||
gen int // reader 世代号:旧 reader 迟到的错误会被忽略
|
||||
}
|
||||
|
||||
type reconnectDoneMsg struct{ ok bool }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type tuiModel struct {
|
||||
state *State
|
||||
cfg *Config
|
||||
|
||||
vp viewport.Model
|
||||
input textarea.Model
|
||||
|
||||
messages []chatMsg
|
||||
width int
|
||||
height int
|
||||
ready bool
|
||||
|
||||
busy bool
|
||||
spinnerIdx int
|
||||
|
||||
history *History
|
||||
historyIdx int // -1 = 无导航;0..n-1 = history.all() 下标(越大越新)
|
||||
historyDraft string
|
||||
|
||||
lines chan string
|
||||
errs chan error
|
||||
readerGen int // 当前 reader 世代;重启时递增
|
||||
readerAlive bool
|
||||
|
||||
reconnecting bool
|
||||
}
|
||||
|
||||
func newTuiModel(state *State, cfg *Config, history History, lines chan string, errs chan error) tuiModel {
|
||||
ti := textarea.New()
|
||||
ti.Placeholder = "输入消息,/help 查看命令"
|
||||
ti.Prompt = ""
|
||||
ti.CharLimit = -1
|
||||
ti.SetHeight(1)
|
||||
ti.ShowLineNumbers = false
|
||||
ti.Focus()
|
||||
|
||||
return tuiModel{
|
||||
state: state,
|
||||
cfg: cfg,
|
||||
input: ti,
|
||||
vp: viewport.New(80, 20),
|
||||
history: &history,
|
||||
historyIdx: -1,
|
||||
lines: lines,
|
||||
errs: errs,
|
||||
readerAlive: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m tuiModel) Init() tea.Cmd {
|
||||
return tea.Batch(textarea.Blink, waitServer(m.lines, m.errs))
|
||||
}
|
||||
|
||||
// waitServer 阻塞等待下一行服务器输出或读错误。
|
||||
func waitServer(lines chan string, errs chan error) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
select {
|
||||
case l := <-lines:
|
||||
return serverLineMsg{l}
|
||||
case e := <-errs:
|
||||
return readerErrMsg{e}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func spinTick() tea.Cmd {
|
||||
return tea.Tick(90*time.Millisecond, func(time.Time) tea.Msg { return spinnerTickMsg{} })
|
||||
}
|
||||
|
||||
// reconnectCmd 后台重连(State 自带锁,goroutine 安全)。
|
||||
func (m tuiModel) reconnectCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
m.state.Disconnect()
|
||||
for i := 0; i < 15; i++ {
|
||||
if err := m.state.Connect(m.cfg); err == nil {
|
||||
return reconnectDoneMsg{ok: true}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return reconnectDoneMsg{ok: false}
|
||||
}
|
||||
}
|
||||
|
||||
// restartReader 重启读循环 goroutine。
|
||||
func (m *tuiModel) restartReader() {
|
||||
m.readerGen++ // 使旧 reader 的迟到错误失效
|
||||
m.readerAlive = true
|
||||
go m.readPump(m.readerGen)
|
||||
}
|
||||
|
||||
// readPump 持续读服务器输出并投递到 channel;出错时投递带世代号的 err 后退出。
|
||||
func (m *tuiModel) readPump(gen int) {
|
||||
for {
|
||||
line, err := m.state.readLine()
|
||||
if err != nil {
|
||||
select {
|
||||
case m.errs <- readerErrMsg{err: err, gen: gen}:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case m.lines <- line:
|
||||
case <-time.After(30 * time.Second):
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
if !m.ready {
|
||||
m.ready = true
|
||||
m.append(chatMsg{kind: msgSystem,
|
||||
text: fmt.Sprintf("connected %s://%s", m.modeLabel(), m.addrLabel())})
|
||||
}
|
||||
m.layout()
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyCtrlD:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEnter:
|
||||
return m.handleSubmit()
|
||||
case tea.KeyUp:
|
||||
lines := m.history.all()
|
||||
if len(lines) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
if m.historyIdx == -1 {
|
||||
m.historyDraft = m.input.Value()
|
||||
m.historyIdx = len(lines) - 1
|
||||
} else if m.historyIdx > 0 {
|
||||
m.historyIdx--
|
||||
}
|
||||
m.input.SetValue(lines[m.historyIdx])
|
||||
return m, nil
|
||||
case tea.KeyDown:
|
||||
if m.historyIdx >= 0 {
|
||||
m.historyIdx++
|
||||
if m.historyIdx >= len(m.history.all()) {
|
||||
m.historyIdx = -1
|
||||
m.input.SetValue(m.historyDraft)
|
||||
} else {
|
||||
m.input.SetValue(m.history.all()[m.historyIdx])
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyPgUp:
|
||||
m.vp.HalfPageUp()
|
||||
return m, nil
|
||||
case tea.KeyPgDown:
|
||||
m.vp.HalfPageDown()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case spinnerTickMsg:
|
||||
if m.busy {
|
||||
m.spinnerIdx++
|
||||
return m, spinTick()
|
||||
}
|
||||
|
||||
case serverLineMsg:
|
||||
m.handleServerLine(msg.line)
|
||||
var cmds []tea.Cmd
|
||||
if m.readerAlive {
|
||||
cmds = append(cmds, waitServer(m.lines, m.errs))
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case readerErrMsg:
|
||||
if msg.gen != m.readerGen {
|
||||
// 旧 reader 的迟到错误:新 reader 已在运行,忽略
|
||||
return m, nil
|
||||
}
|
||||
m.readerAlive = false
|
||||
if !m.reconnecting {
|
||||
m.append(chatMsg{kind: msgError, text: "connection lost: " + msg.err.Error()})
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case reconnectDoneMsg:
|
||||
m.reconnecting = false
|
||||
if msg.ok {
|
||||
m.restartReader()
|
||||
m.append(chatMsg{kind: msgSystem, text: "reconnected"})
|
||||
return m, waitServer(m.lines, m.errs)
|
||||
}
|
||||
m.append(chatMsg{kind: msgError, text: "reconnect failed after 15 attempts"})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// 其余按键交给输入框
|
||||
var icmd tea.Cmd
|
||||
m.input, icmd = m.input.Update(msg)
|
||||
var vcmd tea.Cmd
|
||||
m.vp, vcmd = m.vp.Update(msg)
|
||||
return m, tea.Batch(icmd, vcmd)
|
||||
}
|
||||
|
||||
func (m tuiModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
text := strings.TrimSpace(m.input.Value())
|
||||
m.input.Reset()
|
||||
m.historyIdx = -1
|
||||
if text == "" {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// TUI 自己处理的内置命令
|
||||
switch text {
|
||||
case "/exit", "/quit":
|
||||
return m, tea.Quit
|
||||
case "/clear":
|
||||
m.messages = nil
|
||||
m.refreshViewport()
|
||||
return m, nil
|
||||
case "/reconnect":
|
||||
m.append(chatMsg{kind: msgSystem, text: "reconnecting..."})
|
||||
m.reconnecting = true
|
||||
return m, m.reconnectCmd()
|
||||
}
|
||||
|
||||
// 其余内置命令:捕获输出进消息区
|
||||
if strings.HasPrefix(text, "/") {
|
||||
needReconnect := false
|
||||
var buf strings.Builder
|
||||
handled := handleBuiltin(text, m.cfg, m.state, func() { needReconnect = true }, &buf)
|
||||
if handled {
|
||||
if out := strings.TrimRight(buf.String(), "\n"); out != "" {
|
||||
m.append(chatMsg{kind: msgSystem, text: out})
|
||||
}
|
||||
if needReconnect {
|
||||
m.reconnecting = true
|
||||
return m, m.reconnectCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 普通消息:发给 agent
|
||||
m.append(chatMsg{kind: msgUser, text: text})
|
||||
m.history.add(text)
|
||||
m.history.save()
|
||||
m.busy = true
|
||||
|
||||
if err := m.state.Send(text); err != nil {
|
||||
m.busy = false
|
||||
m.append(chatMsg{kind: msgError, text: "send failed: " + err.Error()})
|
||||
m.reconnecting = true
|
||||
return m, m.reconnectCmd()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) handleServerLine(line string) {
|
||||
rl := parseRespLineStruct(line)
|
||||
switch rl.Type {
|
||||
case "reasoning":
|
||||
cm := chatMsg{kind: msgReasoning, text: rl.Content}
|
||||
// 连续 reasoning 增量合并到最后一条,形成流式效果
|
||||
if n := len(m.messages); n > 0 && m.messages[n-1].kind == msgReasoning {
|
||||
m.messages[n-1].text += rl.Content
|
||||
} else {
|
||||
m.append(cm)
|
||||
}
|
||||
case "tool_call":
|
||||
m.append(chatMsg{kind: msgTool, tool: rl.Tool, status: rl.Status, result: rl.Result})
|
||||
case "response":
|
||||
m.busy = false
|
||||
m.append(chatMsg{kind: msgAgent, text: rl.Content})
|
||||
case "error":
|
||||
m.busy = false
|
||||
m.append(chatMsg{kind: msgError, text: rl.Error})
|
||||
default:
|
||||
// 非 JSON 旧行(旧服务器):当最终输出
|
||||
m.busy = false
|
||||
m.append(chatMsg{kind: msgAgent, text: line})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *tuiModel) append(cm chatMsg) {
|
||||
m.messages = append(m.messages, cm)
|
||||
m.refreshViewport()
|
||||
}
|
||||
|
||||
func (m *tuiModel) modeLabel() string {
|
||||
if m.cfg.Remote != "" {
|
||||
return "remote"
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
func (m *tuiModel) addrLabel() string {
|
||||
if m.cfg.Remote != "" {
|
||||
return m.cfg.Remote
|
||||
}
|
||||
return m.cfg.Socket
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 渲染
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tagWidth = 5
|
||||
|
||||
func (m tuiModel) renderMessage(cm chatMsg, width int) string {
|
||||
switch cm.kind {
|
||||
case msgUser:
|
||||
return hangingIndent(styleUserTag.Render("You"), cm.text, width)
|
||||
case msgAgent:
|
||||
return hangingIndent(styleAgentTag.Render("小宅"), cm.text, width)
|
||||
case msgReasoning:
|
||||
tail := lastNonEmptyLine(cm.text)
|
||||
if w := width - tagWidth - 4; w > 8 && lipgloss.Width(tail) > w {
|
||||
tail = truncateTail(tail, w)
|
||||
}
|
||||
return styleDim.Render(" · " + tail)
|
||||
case msgTool:
|
||||
var mark string
|
||||
var st lipgloss.Style
|
||||
switch cm.status {
|
||||
case "ok":
|
||||
mark, st = "[ok]", styleToolOK
|
||||
case "denied", "interrupted", "error":
|
||||
mark, st = "[fail]", styleToolFail
|
||||
default:
|
||||
mark, st = "[..]", styleToolPending
|
||||
}
|
||||
preview := ""
|
||||
if cm.result != "" {
|
||||
first := firstLine(cm.result)
|
||||
if w := width - tagWidth - 24; w > 8 && lipgloss.Width(first) > w {
|
||||
first = truncateTail(first, w)
|
||||
}
|
||||
preview = styleDim.Render(" " + first)
|
||||
}
|
||||
return " " + st.Render(fmt.Sprintf("%-6s", mark)) +
|
||||
styleDim.Render(fmt.Sprintf("%-*s", 18, cm.tool)) + preview
|
||||
case msgSystem:
|
||||
return styleSysTag.Render("[sys]") + " " + styleDim.Render(cm.text)
|
||||
case msgError:
|
||||
return styleErrTag.Render("[err]") + " " +
|
||||
lipgloss.NewStyle().Foreground(cRed).Render(cm.text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hangingIndent 两列布局:首行 "tag body",续行缩进对齐 body。
|
||||
func hangingIndent(tag, body string, width int) string {
|
||||
indent := tagWidth + 1
|
||||
avail := width - indent
|
||||
if avail < 10 {
|
||||
avail = 10
|
||||
}
|
||||
pad := indent - lipgloss.Width(tag)
|
||||
if pad < 1 {
|
||||
pad = 1
|
||||
}
|
||||
prefix := tag + strings.Repeat(" ", pad)
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for _, ln := range strings.Split(body, "\n") {
|
||||
for j, seg := range wordWrap(ln, avail) {
|
||||
if first && j == 0 {
|
||||
b.WriteString(prefix)
|
||||
} else {
|
||||
b.WriteString(strings.Repeat(" ", indent))
|
||||
}
|
||||
b.WriteString(seg)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
first = false
|
||||
}
|
||||
return strings.TrimSuffix(b.String(), "\n")
|
||||
}
|
||||
|
||||
// wordWrap 按 display 宽度断行(宽字符按 2 列计)。
|
||||
func wordWrap(s string, limit int) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
if lipgloss.Width(s) <= limit {
|
||||
return []string{s}
|
||||
}
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
curW := 0
|
||||
for _, r := range s {
|
||||
w := runeWidth(r)
|
||||
if curW+w > limit && cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
curW = 0
|
||||
}
|
||||
cur.WriteRune(r)
|
||||
curW += w
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runeWidth(r rune) int {
|
||||
if r >= 0x1100 && (r <= 0x115F ||
|
||||
r == 0x2329 || r == 0x232A ||
|
||||
(r >= 0x2E80 && r <= 0xA4CF) ||
|
||||
(r >= 0xAC00 && r <= 0xD7A3) ||
|
||||
(r >= 0xF900 && r <= 0xFAFF) ||
|
||||
(r >= 0xFE30 && r <= 0xFE4F) ||
|
||||
(r >= 0xFF00 && r <= 0xFF60) ||
|
||||
(r >= 0xFFE0 && r <= 0xFFE6) ||
|
||||
(r >= 0x20000 && r <= 0x3FFFD)) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func lastNonEmptyLine(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if strings.TrimSpace(lines[i]) != "" {
|
||||
return lines[i]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truncateTail 尾部省略(保留开头)。
|
||||
func truncateTail(s string, max int) string {
|
||||
out := ""
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
rw := runeWidth(r)
|
||||
if w+rw > max-1 {
|
||||
break
|
||||
}
|
||||
out += string(r)
|
||||
w += rw
|
||||
}
|
||||
return out + "…"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 布局与视图
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
headerRows = 1
|
||||
inputRows = 3 // 圆角边框上下 + 1 行输入
|
||||
statusRows = 1
|
||||
gapRows = 2 // 头部与消息区、消息区与输入框之间的空行
|
||||
minVpHeight = 4
|
||||
)
|
||||
|
||||
func (m *tuiModel) layout() {
|
||||
h := m.height - headerRows - statusRows - inputRows - gapRows
|
||||
if h < minVpHeight {
|
||||
h = minVpHeight
|
||||
}
|
||||
m.vp.Width = m.width
|
||||
m.vp.Height = h
|
||||
}
|
||||
|
||||
func (m *tuiModel) refreshViewport() {
|
||||
var b strings.Builder
|
||||
w := m.width - 2
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
for i, cm := range m.messages {
|
||||
b.WriteString(m.renderMessage(cm, w))
|
||||
if i < len(m.messages)-1 {
|
||||
b.WriteString("\n\n") // 消息间空行分隔
|
||||
}
|
||||
}
|
||||
m.vp.SetContent(b.String())
|
||||
m.vp.GotoBottom()
|
||||
}
|
||||
|
||||
func (m tuiModel) View() string {
|
||||
if !m.ready {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 顶栏
|
||||
dot := styleDotOn.Render("●")
|
||||
connText := "connected"
|
||||
if !m.state.Connected() {
|
||||
dot = styleDotOff.Render("●")
|
||||
connText = "disconnected"
|
||||
}
|
||||
left := styleTitle.Render("HomeAgent") + " " + dot + " " + connText
|
||||
right := styleDim.Render(truncMid(m.addrLabel(), maxInt(10, m.width-lipgloss.Width(left)-8)))
|
||||
header := styleHeaderBox.Width(m.width).MaxWidth(m.width).Render(left + " " + right)
|
||||
|
||||
// 输入区
|
||||
boxStyle := styleInputBox
|
||||
if m.input.Focused() {
|
||||
boxStyle = styleInputFocus
|
||||
}
|
||||
inputBox := boxStyle.Width(m.width - 2).Render(m.input.View())
|
||||
|
||||
statusBar := styleStatusBar.Width(m.width).MaxWidth(m.width).Render(m.statusLine())
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
"",
|
||||
m.vp.View(),
|
||||
"",
|
||||
inputBox,
|
||||
statusBar,
|
||||
)
|
||||
}
|
||||
|
||||
func (m tuiModel) statusLine() string {
|
||||
var leftSeg string
|
||||
if m.busy {
|
||||
leftSeg = styleTitle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)]+" thinking...")
|
||||
} else {
|
||||
leftSeg = styleDim.Render("enter 发送 · PgUp/PgDn 翻页 · ctrl+c 退出")
|
||||
}
|
||||
right := styleDim.Render(connSummary(m.state, m.modeLabel()))
|
||||
gap := m.width - lipgloss.Width(leftSeg) - lipgloss.Width(right) - 2
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
return leftSeg + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
func connSummary(state *State, mode string) string {
|
||||
s := "● " + mode
|
||||
if state.Connected() {
|
||||
return styleDotOn.Render(s)
|
||||
}
|
||||
return styleDotOff.Render(s + " disconnected")
|
||||
}
|
||||
|
||||
func truncMid(s string, limit int) string {
|
||||
if lipgloss.Width(s) <= limit {
|
||||
return s
|
||||
}
|
||||
half := (limit - 1) / 2
|
||||
r := []rune(s)
|
||||
if half < 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(r[:half]) + "…" + string(r[len(r)-half:])
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 入口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// runTUI 启动 Bubble Tea 全屏界面。
|
||||
func runTUI(state *State, cfg *Config, history History) error {
|
||||
m := newTuiModel(state, cfg, history, make(chan string, 128), make(chan error, 8))
|
||||
m.readerAlive = true
|
||||
go m.readPump(m.readerGen) // 初始读循环
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user