mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Remove UI components and gateway management for picoclaw-launcher-tui
- Deleted channel management UI from channel.go, including all associated forms and menu items. - Removed platform-specific gateway process management from gateway_posix.go and gateway_windows.go. - Eliminated menu structure and item management from menu.go. - Removed model management and configuration handling from model.go. - Deleted style definitions and application logic from style.go. - Cleared main entry point in main.go.
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
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.Model)
|
||||
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.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.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)
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||
return []MenuItem{
|
||||
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(
|
||||
"Matrix",
|
||||
"Matrix bot settings",
|
||||
s.config.Channels.Matrix.Enabled,
|
||||
func() { s.push("channel-matrix", s.matrixForm()) },
|
||||
),
|
||||
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()) },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *appState) channelMenu() tview.Primitive {
|
||||
menu := NewMenu("Channels", s.buildChannelMenuItems())
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func refreshChannelMenuFromState(menu *Menu, s *appState) {
|
||||
menu.applyItems(s.buildChannelMenuItems())
|
||||
}
|
||||
|
||||
func (s *appState) telegramForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Telegram
|
||||
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) discordForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Discord
|
||||
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) qqForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.QQ
|
||||
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) maixcamForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.MaixCam
|
||||
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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 })
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) whatsappForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WhatsApp
|
||||
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
|
||||
cfg.BridgeURL = strings.TrimSpace(text)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) feishuForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Feishu
|
||||
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) dingtalkForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.DingTalk
|
||||
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) slackForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Slack
|
||||
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) lineForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.LINE
|
||||
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) matrixForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.Matrix
|
||||
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
|
||||
cfg.Homeserver = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
||||
cfg.UserID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||
cfg.AccessToken = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
||||
cfg.DeviceID = strings.TrimSpace(text)
|
||||
})
|
||||
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
|
||||
cfg.JoinOnInvite = checked
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) onebotForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.OneBot
|
||||
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
},
|
||||
)
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) wecomForm() tview.Primitive {
|
||||
cfg := &s.config.Channels.WeCom
|
||||
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
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, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||
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)
|
||||
})
|
||||
addAllowFromField(form, &cfg.AllowFrom)
|
||||
addIntField(
|
||||
form,
|
||||
"Reply Timeout",
|
||||
cfg.ReplyTimeout,
|
||||
func(value int) { cfg.ReplyTimeout = value },
|
||||
)
|
||||
return wrapWithBack(form, s)
|
||||
}
|
||||
|
||||
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
|
||||
return func(v bool) {
|
||||
*enabledPtr = v
|
||||
s.dirty = true
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["channel"]; ok {
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
|
||||
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
|
||||
*allowFrom = splitCSV(text)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//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()
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//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()
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
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, 1+len(s.config.ModelList))
|
||||
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))
|
||||
},
|
||||
})
|
||||
}
|
||||
// Add model entry appended at the end so the models map to rows 1..N
|
||||
items = append(items,
|
||||
MenuItem{
|
||||
Label: "**Add model**",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
newName := s.nextAvailableModelName("new-model")
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||
)
|
||||
s.push(
|
||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||
s.modelForm(len(s.config.ModelList)-1),
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
menu := NewMenu("Models", items)
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
|
||||
if event.Rune() == ' ' {
|
||||
row, _ := menu.GetSelection()
|
||||
if row >= 0 && row < len(s.config.ModelList) {
|
||||
model := s.config.ModelList[row]
|
||||
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))
|
||||
|
||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||
if value == "" {
|
||||
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
||||
return
|
||||
}
|
||||
if s.modelNameExists(value, index) {
|
||||
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
||||
return
|
||||
}
|
||||
oldName := model.ModelName
|
||||
model.ModelName = value
|
||||
if s.config.Agents.Defaults.Model == oldName {
|
||||
s.config.Agents.Defaults.Model = value
|
||||
}
|
||||
s.dirty = true
|
||||
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||
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() {
|
||||
pageName := "confirm-delete-model"
|
||||
if s.pages.HasPage(pageName) {
|
||||
return
|
||||
}
|
||||
modal := tview.NewModal().
|
||||
SetText("Are you sure you want to delete this model?").
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
s.pages.RemovePage(pageName)
|
||||
if buttonLabel == "Delete" {
|
||||
s.deleteModel(index)
|
||||
}
|
||||
})
|
||||
modal.SetTitle("Confirm Delete").SetBorder(true)
|
||||
s.pages.AddPage(pageName, modal, true, true)
|
||||
})
|
||||
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
|
||||
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, 1+len(s.config.ModelList))
|
||||
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))
|
||||
},
|
||||
})
|
||||
}
|
||||
items = append(items,
|
||||
MenuItem{
|
||||
Label: "**Add Model**",
|
||||
Description: "Append a new model entry",
|
||||
Action: func() {
|
||||
newName := s.nextAvailableModelName("new-model")
|
||||
s.addModel(
|
||||
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"},
|
||||
)
|
||||
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||
},
|
||||
},
|
||||
)
|
||||
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) modelNameExists(name string, excludeIndex int) bool {
|
||||
target := strings.TrimSpace(name)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for i := range s.config.ModelList {
|
||||
if i == excludeIndex {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *appState) nextAvailableModelName(base string) string {
|
||||
name := strings.TrimSpace(base)
|
||||
if name == "" {
|
||||
name = "new-model"
|
||||
}
|
||||
if !s.modelNameExists(name, -1) {
|
||||
return name
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||
if !s.modelNameExists(candidate, -1) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
if err != nil {
|
||||
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
||||
return
|
||||
}
|
||||
s.showMessage(
|
||||
"Test failed",
|
||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
const (
|
||||
colorBlue = "[#3e5db9]"
|
||||
colorRed = "[#d54646]"
|
||||
banner = "\r\n[::b]" +
|
||||
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
|
||||
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
|
||||
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
|
||||
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
|
||||
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
|
||||
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
|
||||
"[:]"
|
||||
)
|
||||
|
||||
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(banner)
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
|
||||
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
||||
|
||||
func footerView() *tview.TextView {
|
||||
text := tview.NewTextView()
|
||||
text.SetTextAlign(tview.AlignCenter)
|
||||
text.SetText(footerText)
|
||||
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
||||
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||
text.SetBorder(false)
|
||||
return text
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user