From 22de000f2356a25bcc12d795ce5be944a826a7e3 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 25 Aug 2026 00:34:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20bump=20llm=20http=20client=20timeout=201?= =?UTF-8?q?20s=E2=86=92180s=20for=20llmsproxy=20AUTO=20chain=20failover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. --- cmd/waiter/builtin.go | 53 +-- cmd/waiter/main.go | 24 +- cmd/waiter/tui.go | 677 +++++++++++++++++++++++++++++++++ go.mod | 28 +- go.sum | 58 ++- internal/agent/api/provider.go | 5 +- 6 files changed, 810 insertions(+), 35 deletions(-) create mode 100644 cmd/waiter/tui.go diff --git a/cmd/waiter/builtin.go b/cmd/waiter/builtin.go index b889268..c7fb8be 100644 --- a/cmd/waiter/builtin.go +++ b/cmd/waiter/builtin.go @@ -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 ") + fmt.Fprintln(out, "usage: /settings set ") 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)) } diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index 1afe38c..b6babfa 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -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 } diff --git a/cmd/waiter/tui.go b/cmd/waiter/tui.go new file mode 100644 index 0000000..e7afbbc --- /dev/null +++ b/cmd/waiter/tui.go @@ -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 +} diff --git a/go.mod b/go.mod index 20ca860..37dfe0a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,33 @@ require github.com/yalue/onnxruntime_go v1.13.0 require ( gitcode.com/JianFeeeee/homeagent-sdk v0.8.0 - golang.org/x/sys v0.8.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + golang.org/x/sys v0.38.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.3.8 // indirect ) replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk diff --git a/go.sum b/go.sum index 311e121..3fcd858 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,67 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yalue/onnxruntime_go v1.13.0 h1:5HDXHon3EukQMyYA7yPMed/raWaDE/gjwLOwnVoiwy8= github.com/yalue/onnxruntime_go v1.13.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/yanyiwu/gojieba v1.4.7 h1:2YkXELcYLTE0SJetq6xv4MjpEikWga6VpFn4jIFFQ/k= github.com/yanyiwu/gojieba v1.4.7/go.mod h1:JUq4DddFVGdHXJHxxepxRmhrKlDpaBxR8O28v6fKYLY= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 4f49cae..19f891d 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -298,7 +298,10 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, name, adapter string) * cfg: cfg, vm: vm, adapter: adapter, - client: &http.Client{Timeout: 120 * time.Second}, + // 180s: llmsproxy 的 AUTO 链会串行尝试多个 tier,每个失败 tier 耗 + // busyWait(2s)+上游超时;120s 曾导致网关侧记录大量 "context canceled" + // (客户端先放弃)。放宽到 180s 给链式 failover 留足时间。 + client: &http.Client{Timeout: 180 * time.Second}, } }