522 lines
12 KiB
Go
522 lines
12 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
type appState struct {
|
|
app *tview.Application
|
|
pages *tview.Pages
|
|
stack []string
|
|
config *picoclawconfig.Config
|
|
configPath string
|
|
gatewayCmd *exec.Cmd
|
|
menus map[string]*Menu
|
|
original []byte
|
|
hasOriginal bool
|
|
backupPath string
|
|
dirty bool
|
|
logPath string
|
|
}
|
|
|
|
func Run() error {
|
|
applyStyles()
|
|
cfg, err := configstore.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path, err := configstore.ConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if cfg == nil {
|
|
cfg = picoclawconfig.DefaultConfig()
|
|
}
|
|
|
|
originalData, hasOriginal := loadOriginalConfig(path)
|
|
backupPath := path + ".bak"
|
|
if hasOriginal {
|
|
_ = writeBackupConfig(backupPath, originalData)
|
|
}
|
|
|
|
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
|
|
state := &appState{
|
|
app: tview.NewApplication(),
|
|
pages: tview.NewPages(),
|
|
config: cfg,
|
|
configPath: path,
|
|
menus: map[string]*Menu{},
|
|
original: originalData,
|
|
hasOriginal: hasOriginal,
|
|
backupPath: backupPath,
|
|
logPath: logPath,
|
|
}
|
|
|
|
state.push("main", state.mainMenu())
|
|
|
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
root.AddItem(bannerView(), 6, 0, false)
|
|
root.AddItem(state.pages, 0, 1, true)
|
|
root.AddItem(footerView(), 1, 0, false)
|
|
|
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *appState) push(name string, primitive tview.Primitive) {
|
|
s.pages.AddPage(name, primitive, true, true)
|
|
s.stack = append(s.stack, name)
|
|
s.pages.SwitchToPage(name)
|
|
if menu, ok := primitive.(*Menu); ok {
|
|
s.menus[name] = menu
|
|
}
|
|
}
|
|
|
|
func (s *appState) pop() {
|
|
if len(s.stack) == 0 {
|
|
return
|
|
}
|
|
last := s.stack[len(s.stack)-1]
|
|
s.pages.RemovePage(last)
|
|
s.stack = s.stack[:len(s.stack)-1]
|
|
if len(s.stack) == 0 {
|
|
s.app.Stop()
|
|
return
|
|
}
|
|
current := s.stack[len(s.stack)-1]
|
|
s.pages.SwitchToPage(current)
|
|
if menu, ok := s.menus[current]; ok {
|
|
s.refreshMenu(current, menu)
|
|
}
|
|
}
|
|
|
|
func (s *appState) mainMenu() tview.Primitive {
|
|
menu := NewMenu("Menu", nil)
|
|
refreshMainMenu(menu, s)
|
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
switch event.Key() {
|
|
case tcell.KeyEsc:
|
|
s.requestExit()
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
})
|
|
|
|
return menu
|
|
}
|
|
|
|
func (s *appState) refreshMenu(name string, menu *Menu) {
|
|
switch name {
|
|
case "main":
|
|
refreshMainMenu(menu, s)
|
|
case "model":
|
|
refreshModelMenuFromState(menu, s)
|
|
case "channel":
|
|
refreshChannelMenuFromState(menu, s)
|
|
}
|
|
}
|
|
|
|
func (s *appState) countChannels() (enabled int, total int) {
|
|
c := s.config.Channels
|
|
entries := []bool{
|
|
c.Telegram.Enabled,
|
|
c.Discord.Enabled,
|
|
c.QQ.Enabled,
|
|
c.MaixCam.Enabled,
|
|
c.WhatsApp.Enabled,
|
|
c.Feishu.Enabled,
|
|
c.DingTalk.Enabled,
|
|
c.Slack.Enabled,
|
|
c.Matrix.Enabled,
|
|
c.LINE.Enabled,
|
|
c.OneBot.Enabled,
|
|
c.WeCom.Enabled,
|
|
c.WeComApp.Enabled,
|
|
}
|
|
total = len(entries)
|
|
for _, v := range entries {
|
|
if v {
|
|
enabled++
|
|
}
|
|
}
|
|
return enabled, total
|
|
}
|
|
|
|
func refreshMainMenuIfPresent(s *appState) {
|
|
if menu, ok := s.menus["main"]; ok {
|
|
refreshMainMenu(menu, s)
|
|
}
|
|
}
|
|
|
|
func refreshMainMenu(menu *Menu, s *appState) {
|
|
selectedModel := s.selectedModelName()
|
|
modelReady := selectedModel != ""
|
|
channelReady := s.hasEnabledChannel()
|
|
enabledCount, totalChannels := s.countChannels()
|
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
|
|
|
gatewayLabel := "Start Gateway"
|
|
gatewayDescription := "Launch gateway for channels"
|
|
if gatewayRunning {
|
|
gatewayLabel = "Stop Gateway"
|
|
gatewayDescription = "Gateway running"
|
|
}
|
|
|
|
items := []MenuItem{
|
|
{
|
|
Label: rootModelLabel(selectedModel),
|
|
Description: rootModelDescription(),
|
|
Action: func() {
|
|
s.push("model", s.modelMenu())
|
|
},
|
|
MainColor: func() *tcell.Color {
|
|
if modelReady {
|
|
return nil
|
|
}
|
|
color := tcell.ColorGray
|
|
return &color
|
|
}(),
|
|
},
|
|
{
|
|
Label: rootChannelLabel(channelReady),
|
|
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
|
Action: func() {
|
|
s.push("channel", s.channelMenu())
|
|
},
|
|
MainColor: func() *tcell.Color {
|
|
if channelReady {
|
|
return nil
|
|
}
|
|
color := tcell.ColorGray
|
|
return &color
|
|
}(),
|
|
},
|
|
{
|
|
Label: "Start Talk",
|
|
Description: "Open picoclaw agent in terminal",
|
|
Action: func() {
|
|
s.requestStartTalk()
|
|
},
|
|
Disabled: !modelReady,
|
|
},
|
|
{
|
|
Label: gatewayLabel,
|
|
Description: gatewayDescription,
|
|
Action: func() {
|
|
if gatewayRunning {
|
|
s.stopGateway()
|
|
} else {
|
|
s.requestStartGateway()
|
|
}
|
|
refreshMainMenu(menu, s)
|
|
},
|
|
Disabled: !gatewayRunning && (!modelReady || !channelReady),
|
|
},
|
|
{
|
|
Label: "View Gateway Log",
|
|
Description: "Open gateway.log",
|
|
Action: func() {
|
|
s.viewGatewayLog()
|
|
},
|
|
},
|
|
{
|
|
Label: "Exit",
|
|
Description: "Exit the TUI",
|
|
Action: func() {
|
|
s.requestExit()
|
|
},
|
|
},
|
|
}
|
|
menu.applyItems(items)
|
|
}
|
|
|
|
func (s *appState) applyChangesValidated() bool {
|
|
if err := s.config.ValidateModelList(); err != nil {
|
|
s.showMessage("Validation failed", err.Error())
|
|
return false
|
|
}
|
|
if err := s.validateAgentModel(); err != nil {
|
|
s.showMessage("Validation failed", err.Error())
|
|
return false
|
|
}
|
|
if err := configstore.Save(s.config); err != nil {
|
|
s.showMessage("Save failed", err.Error())
|
|
return false
|
|
}
|
|
if data, err := os.ReadFile(s.configPath); err == nil {
|
|
s.original = data
|
|
s.hasOriginal = true
|
|
_ = writeBackupConfig(s.backupPath, data)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *appState) requestExit() {
|
|
if s.dirty {
|
|
s.confirmApplyOrDiscard(func() {
|
|
s.app.Stop()
|
|
}, func() {
|
|
s.discardChanges()
|
|
s.app.Stop()
|
|
})
|
|
return
|
|
}
|
|
s.app.Stop()
|
|
}
|
|
|
|
func (s *appState) requestStartTalk() {
|
|
if s.dirty {
|
|
s.confirmApplyOrDiscard(func() {
|
|
s.startTalk()
|
|
}, func() {
|
|
s.startTalk()
|
|
})
|
|
return
|
|
}
|
|
s.startTalk()
|
|
}
|
|
|
|
func (s *appState) requestStartGateway() {
|
|
if s.dirty {
|
|
s.confirmApplyOrDiscard(func() {
|
|
s.startGateway()
|
|
}, func() {
|
|
s.startGateway()
|
|
})
|
|
return
|
|
}
|
|
s.startGateway()
|
|
}
|
|
|
|
func (s *appState) viewGatewayLog() {
|
|
data, err := os.ReadFile(s.logPath)
|
|
if err != nil {
|
|
s.showMessage("Log not found", "gateway.log not found")
|
|
return
|
|
}
|
|
text := tview.NewTextView()
|
|
text.SetBorder(true).SetTitle("Gateway Log")
|
|
text.SetText(string(data))
|
|
text.SetDoneFunc(func(key tcell.Key) {
|
|
s.pages.RemovePage("log")
|
|
})
|
|
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Key() == tcell.KeyEsc {
|
|
s.pages.RemovePage("log")
|
|
return nil
|
|
}
|
|
return event
|
|
})
|
|
s.pages.AddPage("log", text, true, true)
|
|
}
|
|
|
|
func (s *appState) selectedModelName() string {
|
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName())
|
|
if modelName == "" {
|
|
return ""
|
|
}
|
|
if !s.isActiveModelValid() {
|
|
return ""
|
|
}
|
|
return modelName
|
|
}
|
|
|
|
func rootModelLabel(selected string) string {
|
|
if selected == "" {
|
|
return "Model (None)"
|
|
}
|
|
return "Model (" + selected + ")"
|
|
}
|
|
|
|
func rootModelDescription() string {
|
|
return "Using SPACE to choose your model"
|
|
}
|
|
|
|
func rootChannelLabel(valid bool) string {
|
|
if !valid {
|
|
return "Channel (no channel enabled)"
|
|
}
|
|
return "Channel"
|
|
}
|
|
|
|
func (s *appState) startTalk() {
|
|
if !s.isActiveModelValid() {
|
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
|
return
|
|
}
|
|
if !s.applyChangesValidated() {
|
|
return
|
|
}
|
|
s.app.Suspend(func() {
|
|
cmd := exec.Command("picoclaw", "agent")
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
_ = cmd.Run()
|
|
})
|
|
}
|
|
|
|
func (s *appState) startGateway() {
|
|
if !s.isActiveModelValid() {
|
|
s.showMessage("Model required", "Select a valid model before starting gateway")
|
|
return
|
|
}
|
|
if !s.hasEnabledChannel() {
|
|
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
|
|
return
|
|
}
|
|
if !s.applyChangesValidated() {
|
|
return
|
|
}
|
|
_ = stopGatewayProcess()
|
|
cmd := exec.Command("picoclaw", "gateway")
|
|
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
s.showMessage("Gateway failed", err.Error())
|
|
return
|
|
}
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
if err := cmd.Start(); err != nil {
|
|
s.showMessage("Gateway failed", err.Error())
|
|
_ = logFile.Close()
|
|
return
|
|
}
|
|
_ = logFile.Close()
|
|
s.gatewayCmd = cmd
|
|
}
|
|
|
|
func (s *appState) stopGateway() {
|
|
_ = stopGatewayProcess()
|
|
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
|
|
_ = s.gatewayCmd.Process.Kill()
|
|
}
|
|
s.gatewayCmd = nil
|
|
}
|
|
|
|
func (s *appState) isGatewayRunning() bool {
|
|
return isGatewayProcessRunning()
|
|
}
|
|
|
|
func (s *appState) validateAgentModel() error {
|
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName())
|
|
if modelName == "" {
|
|
return nil
|
|
}
|
|
_, err := s.config.GetModelConfig(modelName)
|
|
return err
|
|
}
|
|
|
|
func (s *appState) isActiveModelValid() bool {
|
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.GetModelName())
|
|
if modelName == "" {
|
|
return false
|
|
}
|
|
cfg, err := s.config.GetModelConfig(modelName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
|
|
hasModel := strings.TrimSpace(cfg.Model) != ""
|
|
return hasKey && hasModel
|
|
}
|
|
|
|
func (s *appState) hasEnabledChannel() bool {
|
|
c := s.config.Channels
|
|
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
|
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
|
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
|
}
|
|
|
|
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
|
if s.pages.HasPage("apply") {
|
|
return
|
|
}
|
|
modal := tview.NewModal().
|
|
SetText("Apply changes or discard before continuing?").
|
|
AddButtons([]string{"Cancel", "Discard", "Apply"}).
|
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
|
s.pages.RemovePage("apply")
|
|
switch buttonLabel {
|
|
case "Discard":
|
|
s.discardChanges()
|
|
if onDiscard != nil {
|
|
onDiscard()
|
|
}
|
|
case "Apply":
|
|
if s.applyChangesValidated() {
|
|
s.dirty = false
|
|
if onApply != nil {
|
|
onApply()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
modal.SetBorder(true)
|
|
s.pages.AddPage("apply", modal, true, true)
|
|
}
|
|
|
|
func (s *appState) discardChanges() {
|
|
if s.hasOriginal {
|
|
_ = writeOriginalConfig(s.configPath, s.original)
|
|
} else {
|
|
_ = os.Remove(s.configPath)
|
|
}
|
|
_ = os.Remove(s.backupPath)
|
|
if cfg, err := configstore.Load(); err == nil && cfg != nil {
|
|
s.config = cfg
|
|
}
|
|
s.dirty = false
|
|
refreshMainMenuIfPresent(s)
|
|
}
|
|
|
|
func (s *appState) showMessage(title, message string) {
|
|
if s.pages.HasPage("message") {
|
|
return
|
|
}
|
|
modal := tview.NewModal().
|
|
SetText(strings.TrimSpace(message)).
|
|
AddButtons([]string{"OK"}).
|
|
SetDoneFunc(func(_ int, _ string) {
|
|
s.pages.RemovePage("message")
|
|
})
|
|
modal.SetTitle(title).SetBorder(true)
|
|
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
|
|
modal.SetTextColor(tview.Styles.PrimaryTextColor)
|
|
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
|
|
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
|
|
s.pages.AddPage("message", modal, true, true)
|
|
}
|
|
|
|
func loadOriginalConfig(path string) ([]byte, bool) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, false
|
|
}
|
|
return nil, false
|
|
}
|
|
return data, true
|
|
}
|
|
|
|
func writeOriginalConfig(path string, data []byte) error {
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|
|
|
|
func writeBackupConfig(path string, data []byte) error {
|
|
return os.WriteFile(path, data, 0o600)
|
|
}
|