picoclaw/cmd/picoclaw-launcher-tui/ui/gateway.go
dj-oyu 31376d2961 merge: sync upstream/main
Merge upstream/main (c0bb8d6) into fork. Key upstream changes:
- SSE streaming support (Telegram sendMessageDraft, channel StreamDelegate)
- Pico client outbound WebSocket channel
- Smarter heartbeat task detection (heartbeatHasUserTasks)
- Separate tool-limit vs empty-response messages
- TUI launcher refactoring with cyberpunk theme
- Android fallback DNS resolver
- Improved docs translations

Conflict resolution:
- Kept fork's channel-based StreamingProvider interface and ChatStream API
- Kept fork's buildHTTPRequest (superset of upstream's buildRequestBody)
- Merged upstream's streamActive tracking alongside fork's draft-based streaming
- Added upstream's parser_markdown_to_html.go for new Telegram streaming
- Resolved duplicate declarations (MessageDeleter, escapeHTML, DeleteMessage)
- Adopted upstream's resolveScopeKey prefix-gated behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 09:23:04 +09:00

261 lines
5.9 KiB
Go
Raw 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.

// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const pidFileName = "gateway.pid"
type gatewayStatus struct {
running bool
pid int
}
func getPidPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", pidFileName)
}
func isProcessRunning(pid int) bool {
if runtime.GOOS == "windows" {
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid))
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), strconv.Itoa(pid))
} else if runtime.GOOS == "darwin" {
cmd := exec.Command("ps", "aux")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(string(output), fmt.Sprintf(" %d ", pid))
}
// Linux
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
return err == nil
}
func getGatewayStatus() gatewayStatus {
pidPath := getPidPath()
data, err := os.ReadFile(pidPath)
if err != nil {
return gatewayStatus{running: false}
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return gatewayStatus{running: false}
}
if !isProcessRunning(pid) {
os.Remove(pidPath)
return gatewayStatus{running: false}
}
return gatewayStatus{
running: true,
pid: pid,
}
}
func startGateway() error {
status := getGatewayStatus()
if status.running {
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
}
pidPath := getPidPath()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
} else {
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 & echo $! > "+pidPath)
}
err := cmd.Start()
if err != nil {
return err
}
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
cmd := exec.Command(
"wmic",
"process",
"where",
"name='picoclaw.exe' and commandline like '%gateway%'",
"get",
"processid",
)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pid, err := strconv.Atoi(line)
if err == nil {
os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o600)
break
}
}
}
status = getGatewayStatus()
if !status.running {
return fmt.Errorf("failed to start gateway")
}
return nil
}
func stopGateway() error {
status := getGatewayStatus()
if !status.running {
return fmt.Errorf("gateway is not running")
}
var err error
if runtime.GOOS == "windows" {
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
} else {
err = exec.Command("kill", "-9", strconv.Itoa(status.pid)).Run()
}
if err != nil {
return err
}
// 多次尝试确认进程已停止
for i := 0; i < 5; i++ {
if !isProcessRunning(status.pid) {
break
}
time.Sleep(200 * time.Millisecond)
}
os.Remove(getPidPath())
return nil
}
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("Checking status...")
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
var updateStatus func()
// 使用List作为按钮保证显示和交互正常
buttons := tview.NewList()
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
buttons.SetMainTextColor(tcell.ColorWhite)
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
buttons.SetSelectedTextColor(tcell.ColorBlack)
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
if !getGatewayStatus().running {
err := startGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
if getGatewayStatus().running {
err := stopGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
buttonFlex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttons, 20, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
flex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(statusTV, 3, 1, false).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttonFlex, 4, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
updateStatus = func() {
status := getGatewayStatus()
if status.running {
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d", status.pid))
buttons.SetItemText(0, " [gray]START[white] ", "")
buttons.SetItemText(1, " [red]STOP[white] ", "")
} else {
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
buttons.SetItemText(0, " [lime]START[white] ", "")
buttons.SetItemText(1, " [gray]STOP[white] ", "")
}
}
updateStatus()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.tapp.QueueUpdateDraw(updateStatus)
case <-done:
return
}
}
}()
originalInputCapture := flex.GetInputCapture()
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
close(done)
return a.goBack()
}
if originalInputCapture != nil {
return originalInputCapture(event)
}
return event
})
a.pageRefreshFns["gateway"] = updateStatus
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
}