mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
ead22368bd
* fix(tui): fix model selection and enforce unique model_name, also fix model form button highlight * feat(tui): add footer view with navigation instructions and update menu structure * fix(tui): update model selection labels for clarity and consistency * refactor(tui): remove unused rootChannelDescription function * refactor(tui): simplify rootModelDescription and remove unused 'q' event handling in channel menu * fix(tui): keep selected model name updated Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
434 lines
14 KiB
Go
434 lines
14 KiB
Go
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
|
|
}
|