feat(tui): Add configurable Launcher and Gateway process management (#909)
- Implement POSIX-specific gateway process management in gateway_posix.go - Implement Windows-specific gateway process management in gateway_windows.go - Create a menu system in menu.go for user interaction - Develop model management functionality in model.go, including adding, deleting, and testing models - Introduce a style configuration in style.go for consistent UI appearance - Set up the main application entry point in main.go - Update go.mod and go.sum to include necessary dependencies for tcell and tview
This commit is contained in:
parent
08599f8736
commit
27e988c484
12 changed files with 1671 additions and 0 deletions
|
|
@ -67,6 +67,33 @@ builds:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
||||||
|
- id: picoclaw-launcher-tui
|
||||||
|
binary: picoclaw-launcher-tui
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
tags:
|
||||||
|
- stdjson
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- riscv64
|
||||||
|
- loong64
|
||||||
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "6"
|
||||||
|
- "7"
|
||||||
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: docker/Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
|
@ -105,6 +132,7 @@ nfpms:
|
||||||
builds:
|
builds:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
package_name: picoclaw
|
package_name: picoclaw
|
||||||
file_name_template: >-
|
file_name_template: >-
|
||||||
{{ .PackageName }}_
|
{{ .PackageName }}_
|
||||||
|
|
|
||||||
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
49
cmd/picoclaw-launcher-tui/internal/config/store.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package configstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
configDirName = ".picoclaw"
|
||||||
|
configFileName = "config.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ConfigPath() (string, error) {
|
||||||
|
dir, err := ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, configFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigDir() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, configDirName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*picoclawconfig.Config, error) {
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return picoclawconfig.LoadConfig(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(cfg *picoclawconfig.Config) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return errors.New("config is nil")
|
||||||
|
}
|
||||||
|
path, err := ConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return picoclawconfig.SaveConfig(path, cfg)
|
||||||
|
}
|
||||||
506
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
506
cmd/picoclaw-launcher-tui/internal/ui/app.go
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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)
|
||||||
|
|
||||||
|
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("Config Menu", nil)
|
||||||
|
refreshMainMenu(menu, s)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyEsc:
|
||||||
|
s.requestExit()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
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 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()
|
||||||
|
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(selectedModel),
|
||||||
|
Action: func() {
|
||||||
|
s.push("model", s.modelMenu())
|
||||||
|
},
|
||||||
|
MainColor: func() *tcell.Color {
|
||||||
|
if modelReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: rootChannelLabel(channelReady),
|
||||||
|
Description: rootChannelDescription(channelReady),
|
||||||
|
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.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !s.isActiveModelValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelLabel(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "Model (no model selected)"
|
||||||
|
}
|
||||||
|
return "Model (" + selected + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootModelDescription(selected string) string {
|
||||||
|
if selected == "" {
|
||||||
|
return "no model selected"
|
||||||
|
}
|
||||||
|
return "selected"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelLabel(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "Channel (no channel enabled)"
|
||||||
|
}
|
||||||
|
return "Channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootChannelDescription(valid bool) string {
|
||||||
|
if !valid {
|
||||||
|
return "no channel enabled"
|
||||||
|
}
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Model)
|
||||||
|
if modelName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.config.GetModelConfig(modelName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) isActiveModelValid() bool {
|
||||||
|
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
574
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
574
cmd/picoclaw-launcher-tui/internal/ui/channel.go
Normal file
|
|
@ -0,0 +1,574 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) channelMenu() tview.Primitive {
|
||||||
|
items := []MenuItem{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Channels", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := []MenuItem{
|
||||||
|
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
channelItem(
|
||||||
|
"Telegram",
|
||||||
|
"Telegram bot settings",
|
||||||
|
s.config.Channels.Telegram.Enabled,
|
||||||
|
func() { s.push("channel-telegram", s.telegramForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Discord",
|
||||||
|
"Discord bot settings",
|
||||||
|
s.config.Channels.Discord.Enabled,
|
||||||
|
func() { s.push("channel-discord", s.discordForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"QQ",
|
||||||
|
"QQ bot settings",
|
||||||
|
s.config.Channels.QQ.Enabled,
|
||||||
|
func() { s.push("channel-qq", s.qqForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"MaixCam",
|
||||||
|
"MaixCam gateway",
|
||||||
|
s.config.Channels.MaixCam.Enabled,
|
||||||
|
func() { s.push("channel-maixcam", s.maixcamForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WhatsApp",
|
||||||
|
"WhatsApp bridge",
|
||||||
|
s.config.Channels.WhatsApp.Enabled,
|
||||||
|
func() { s.push("channel-whatsapp", s.whatsappForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Feishu",
|
||||||
|
"Feishu bot settings",
|
||||||
|
s.config.Channels.Feishu.Enabled,
|
||||||
|
func() { s.push("channel-feishu", s.feishuForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"DingTalk",
|
||||||
|
"DingTalk bot settings",
|
||||||
|
s.config.Channels.DingTalk.Enabled,
|
||||||
|
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"Slack",
|
||||||
|
"Slack bot settings",
|
||||||
|
s.config.Channels.Slack.Enabled,
|
||||||
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"LINE",
|
||||||
|
"LINE bot settings",
|
||||||
|
s.config.Channels.LINE.Enabled,
|
||||||
|
func() { s.push("channel-line", s.lineForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"OneBot",
|
||||||
|
"OneBot settings",
|
||||||
|
s.config.Channels.OneBot.Enabled,
|
||||||
|
func() { s.push("channel-onebot", s.onebotForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom",
|
||||||
|
"WeCom bot settings",
|
||||||
|
s.config.Channels.WeCom.Enabled,
|
||||||
|
func() { s.push("channel-wecom", s.wecomForm()) },
|
||||||
|
),
|
||||||
|
channelItem(
|
||||||
|
"WeCom App",
|
||||||
|
"WeCom App settings",
|
||||||
|
s.config.Channels.WeComApp.Enabled,
|
||||||
|
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) telegramForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Telegram
|
||||||
|
form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
|
||||||
|
cfg.Proxy = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) discordForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Discord
|
||||||
|
form := baseChannelForm("Discord", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
|
||||||
|
cfg.MentionOnly = checked
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) qqForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.QQ
|
||||||
|
form := baseChannelForm("QQ", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||||
|
cfg.AppSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) maixcamForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.MaixCam
|
||||||
|
form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
|
||||||
|
cfg.Host = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) whatsappForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WhatsApp
|
||||||
|
form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||||
|
cfg.BridgeURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) feishuForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Feishu
|
||||||
|
form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
|
||||||
|
cfg.AppID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
|
||||||
|
cfg.AppSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncryptKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
|
||||||
|
cfg.VerificationToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) dingtalkForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.DingTalk
|
||||||
|
form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
|
||||||
|
cfg.ClientID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
|
||||||
|
cfg.ClientSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) slackForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Slack
|
||||||
|
form := baseChannelForm("Slack", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
|
||||||
|
cfg.BotToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
|
||||||
|
cfg.AppToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) lineForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.LINE
|
||||||
|
form := baseChannelForm("LINE", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
|
||||||
|
cfg.ChannelSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.ChannelAccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) onebotForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.OneBot
|
||||||
|
form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
|
||||||
|
cfg.WSUrl = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reconnect Interval",
|
||||||
|
cfg.ReconnectInterval,
|
||||||
|
func(value int) { cfg.ReconnectInterval = value },
|
||||||
|
)
|
||||||
|
form.AddInputField(
|
||||||
|
"Group Trigger Prefix",
|
||||||
|
strings.Join(cfg.GroupTriggerPrefix, ","),
|
||||||
|
128,
|
||||||
|
nil,
|
||||||
|
func(text string) {
|
||||||
|
cfg.GroupTriggerPrefix = splitCSV(text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeCom
|
||||||
|
form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
|
||||||
|
cfg.WebhookURL = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) wecomAppForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.WeComApp
|
||||||
|
form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) {
|
||||||
|
cfg.Enabled = v
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["channel"]; ok {
|
||||||
|
refreshChannelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
|
||||||
|
cfg.CorpID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
|
||||||
|
cfg.CorpSecret = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
|
||||||
|
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
|
||||||
|
cfg.Token = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
|
||||||
|
cfg.EncodingAESKey = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookHost = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
|
||||||
|
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
|
||||||
|
cfg.WebhookPath = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) {
|
||||||
|
cfg.AllowFrom = splitCSV(text)
|
||||||
|
})
|
||||||
|
addIntField(
|
||||||
|
form,
|
||||||
|
"Reply Timeout",
|
||||||
|
cfg.ReplyTimeout,
|
||||||
|
func(value int) { cfg.ReplyTimeout = value },
|
||||||
|
)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
form.AddCheckbox("Enabled", enabled, func(checked bool) {
|
||||||
|
onEnabled(checked)
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
|
||||||
|
parts := strings.Split(strings.TrimSpace(input), ",")
|
||||||
|
cleaned := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
value := strings.TrimSpace(part)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, value)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int64
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
|
||||||
|
item := MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: description,
|
||||||
|
Action: action,
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
color := tcell.ColorGray
|
||||||
|
item.MainColor = &color
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
16
cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func isGatewayProcessRunning() bool {
|
||||||
|
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGatewayProcess() error {
|
||||||
|
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
72
cmd/picoclaw-launcher-tui/internal/ui/menu.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MenuAction func()
|
||||||
|
|
||||||
|
type MenuItem struct {
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Action MenuAction
|
||||||
|
Disabled bool
|
||||||
|
MainColor *tcell.Color
|
||||||
|
DescColor *tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
type Menu struct {
|
||||||
|
*tview.Table
|
||||||
|
items []MenuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMenu(title string, items []MenuItem) *Menu {
|
||||||
|
table := tview.NewTable().SetSelectable(true, false)
|
||||||
|
table.SetBorder(true).SetTitle(title)
|
||||||
|
table.SetBorders(false)
|
||||||
|
menu := &Menu{Table: table, items: items}
|
||||||
|
menu.applyItems(items)
|
||||||
|
menu.SetSelectedFunc(func(row, _ int) {
|
||||||
|
if row < 0 || row >= len(menu.items) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := menu.items[row]
|
||||||
|
if item.Disabled || item.Action == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.Action()
|
||||||
|
})
|
||||||
|
menu.SetSelectedStyle(
|
||||||
|
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
|
||||||
|
Background(tcell.NewRGBColor(189, 147, 249)),
|
||||||
|
)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Menu) applyItems(items []MenuItem) {
|
||||||
|
m.items = items
|
||||||
|
m.Clear()
|
||||||
|
for row, item := range items {
|
||||||
|
label := item.Label
|
||||||
|
if item.Disabled && label != "" {
|
||||||
|
label = label + " (disabled)"
|
||||||
|
}
|
||||||
|
left := tview.NewTableCell(label)
|
||||||
|
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
|
||||||
|
if item.MainColor != nil {
|
||||||
|
left.SetTextColor(*item.MainColor)
|
||||||
|
}
|
||||||
|
if item.DescColor != nil {
|
||||||
|
right.SetTextColor(*item.DescColor)
|
||||||
|
} else {
|
||||||
|
right.SetTextColor(tview.Styles.TertiaryTextColor)
|
||||||
|
}
|
||||||
|
if item.Disabled {
|
||||||
|
left.SetTextColor(tcell.ColorGray)
|
||||||
|
right.SetTextColor(tcell.ColorGray)
|
||||||
|
}
|
||||||
|
m.SetCell(row, 0, left)
|
||||||
|
m.SetCell(row, 1, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
343
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
343
cmd/picoclaw-launcher-tui/internal/ui/model.go
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := NewMenu("Models", items)
|
||||||
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.Rune() == ' ' {
|
||||||
|
row, _ := menu.GetSelection()
|
||||||
|
if row > 0 && row <= len(s.config.ModelList) {
|
||||||
|
model := s.config.ModelList[row-1]
|
||||||
|
if !isModelValid(model) {
|
||||||
|
s.showMessage(
|
||||||
|
"Invalid model",
|
||||||
|
"Select a model with api_key or oauth auth_method",
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.config.Agents.Defaults.Model = model.ModelName
|
||||||
|
s.dirty = true
|
||||||
|
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
|
model := &s.config.ModelList[index]
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
|
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
||||||
|
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
||||||
|
|
||||||
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
model.ModelName = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Model", model.Model, func(value string) {
|
||||||
|
model.Model = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Base", model.APIBase, func(value string) {
|
||||||
|
model.APIBase = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "API Key", model.APIKey, func(value string) {
|
||||||
|
model.APIKey = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Proxy", model.Proxy, func(value string) {
|
||||||
|
model.Proxy = value
|
||||||
|
})
|
||||||
|
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
|
||||||
|
model.AuthMethod = value
|
||||||
|
s.dirty = true
|
||||||
|
refreshMainMenuIfPresent(s)
|
||||||
|
if menu, ok := s.menus["model"]; ok {
|
||||||
|
refreshModelMenuFromState(menu, s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
|
||||||
|
model.ConnectMode = value
|
||||||
|
})
|
||||||
|
addInput(form, "Workspace", model.Workspace, func(value string) {
|
||||||
|
model.Workspace = value
|
||||||
|
})
|
||||||
|
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
|
||||||
|
model.MaxTokensField = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "RPM", model.RPM, func(value int) {
|
||||||
|
model.RPM = value
|
||||||
|
})
|
||||||
|
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
|
||||||
|
model.RequestTimeout = value
|
||||||
|
})
|
||||||
|
|
||||||
|
form.AddButton("Delete", func() {
|
||||||
|
s.deleteModel(index)
|
||||||
|
})
|
||||||
|
form.AddButton("Test", func() {
|
||||||
|
s.testModel(model)
|
||||||
|
})
|
||||||
|
form.AddButton("Back", func() {
|
||||||
|
s.pop()
|
||||||
|
})
|
||||||
|
|
||||||
|
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Key() == tcell.KeyEsc {
|
||||||
|
s.pop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInput(form *tview.Form, label, value string, onChange func(string)) {
|
||||||
|
form.AddInputField(label, value, 128, nil, func(text string) {
|
||||||
|
onChange(strings.TrimSpace(text))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
|
||||||
|
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
|
||||||
|
var parsed int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
|
||||||
|
onChange(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
|
||||||
|
s.config.ModelList = append(s.config.ModelList, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) deleteModel(index int) {
|
||||||
|
if index < 0 || index >= len(s.config.ModelList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
|
||||||
|
s.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
if valid {
|
||||||
|
color := tview.Styles.PrimaryTextColor
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
color := tcell.ColorGray
|
||||||
|
return &color
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
|
for i, model := range models {
|
||||||
|
row := i + 1
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
cell := menu.GetCell(row, 0)
|
||||||
|
if cell != nil {
|
||||||
|
cell.SetText(label)
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
color := modelStatusColor(isValid, isSelected)
|
||||||
|
if color != nil {
|
||||||
|
cell.SetTextColor(*color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
|
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
||||||
|
MenuItem{
|
||||||
|
Label: "Add model",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
index := i
|
||||||
|
model := s.config.ModelList[i]
|
||||||
|
isValid := isModelValid(model)
|
||||||
|
desc := model.APIBase
|
||||||
|
if desc == "" {
|
||||||
|
desc = model.AuthMethod
|
||||||
|
}
|
||||||
|
if desc == "" {
|
||||||
|
desc = "api_key required"
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
label = "* " + label
|
||||||
|
}
|
||||||
|
isSelected := model.ModelName == currentModel && currentModel != ""
|
||||||
|
items = append(items, MenuItem{
|
||||||
|
Label: label,
|
||||||
|
Description: desc,
|
||||||
|
MainColor: modelStatusColor(isValid, isSelected),
|
||||||
|
Action: func() {
|
||||||
|
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
menu.applyItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
|
hasKey := strings.TrimSpace(model.APIKey) != "" ||
|
||||||
|
strings.TrimSpace(model.AuthMethod) == "oauth"
|
||||||
|
hasModel := strings.TrimSpace(model.Model) != ""
|
||||||
|
return hasKey && hasModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
|
if model == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(model.APIKey) == "" {
|
||||||
|
s.showMessage("Missing API Key", "Set api_key before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(model.APIBase)
|
||||||
|
if base == "" {
|
||||||
|
s.showMessage("Missing API Base", "Set api_base before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelID := strings.TrimSpace(model.Model)
|
||||||
|
if modelID == "" {
|
||||||
|
s.showMessage("Missing Model", "Set model before testing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(modelID, "openai/") {
|
||||||
|
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelName := strings.TrimPrefix(modelID, "openai/")
|
||||||
|
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
|
||||||
|
|
||||||
|
payload := fmt.Sprintf(
|
||||||
|
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
|
||||||
|
modelName,
|
||||||
|
)
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
|
||||||
|
|
||||||
|
resp, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
s.showMessage("Test OK", resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
s.showMessage(
|
||||||
|
"Test failed",
|
||||||
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
)
|
||||||
|
}
|
||||||
37
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
37
cmd/picoclaw-launcher-tui/internal/ui/style.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyStyles() {
|
||||||
|
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
|
||||||
|
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
|
||||||
|
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
|
||||||
|
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
|
||||||
|
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
|
||||||
|
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
|
||||||
|
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
|
||||||
|
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
|
||||||
|
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
|
||||||
|
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
|
||||||
|
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bannerView() *tview.TextView {
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetDynamicColors(true)
|
||||||
|
text.SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
||||||
|
text.SetText(
|
||||||
|
"[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||||
|
"[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||||
|
"[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||||
|
"[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||||
|
"[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||||
|
"[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝",
|
||||||
|
)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
15
cmd/picoclaw-launcher-tui/main.go
Normal file
15
cmd/picoclaw-launcher-tui/main.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := ui.Run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
go.mod
5
go.mod
|
|
@ -33,13 +33,18 @@ require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/tview v0.42.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
|
|
||||||
10
go.sum
10
go.sum
|
|
@ -50,6 +50,10 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||||
|
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
|
|
@ -113,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
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-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
|
@ -148,6 +154,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||||
|
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue