Files
shell/moltbot/installer/internal/ui/model.go
T
2026-01-29 15:01:13 +08:00

668 lines
15 KiB
Go

package ui
import (
"fmt"
"strings"
"time"
"moltbot-installer/internal/style"
"moltbot-installer/internal/sys"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// SessionState defines the high-level mode of the application
type SessionState int
const (
StateDashboard SessionState = iota
StateWizard
StateAction
)
// Sub-states for specific flows
type WizardStep int
const (
StepApiSelect WizardStep = iota
StepApiInput
StepConfirm
)
type ActionType int
const (
ActionCheckEnv ActionType = iota
ActionInstall
ActionUninstall
ActionStartGateway
ActionKillGateway
)
// Model is the main application state
type Model struct {
// Global State
state SessionState
width int
height int
// Flag to signal main.go (legacy/compatibility)
DidStartGateway bool
// Dashboard State
menuIndex int
// Wizard State
wizardStep WizardStep
configOpts sys.ConfigOptions
input textinput.Model
inputStep int // For multi-field steps like API Input
// Action/Progress State
actionType ActionType
spinner spinner.Model
progressMsg string
actionErr error
actionDone bool
// System Status Cache
nodeVer string
nodeOk bool
moltbotVer string
moltbotOk bool
gatewayOk bool
checkDone bool
}
// Messages
type checkMsg struct {
nodeVer string
nodeOk bool
moltbotVer string
moltbotInstalled bool
gatewayRunning bool
}
type actionResultMsg struct {
err error
}
type progressMsg string
type tickMsg time.Time
type gatewayStatusMsg bool
func InitialModel() Model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = style.HeaderStyle
ti := textinput.New()
ti.Cursor.Style = style.HeaderStyle
ti.Focus()
return Model{
state: StateDashboard,
spinner: s,
input: ti,
menuIndex: 0,
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
checkEnvCmd,
tickCmd(),
)
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
// Global Back handler for Wizard
if m.state == StateWizard && msg.String() == "esc" {
m.state = StateDashboard
return m, nil
}
// Global Back handler for Action Result
if m.state == StateAction && m.actionDone && (msg.String() == "enter" || msg.String() == "esc") {
m.state = StateDashboard
// Refresh env after action
return m, checkEnvCmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case checkMsg:
m.nodeVer = msg.nodeVer
m.nodeOk = msg.nodeOk
m.moltbotVer = msg.moltbotVer
m.moltbotOk = msg.moltbotInstalled
m.gatewayOk = msg.gatewayRunning
m.checkDone = true
// If we are in Action mode checking env, this might be a refresh
if m.state == StateAction && m.actionType == ActionCheckEnv {
m.state = StateDashboard
}
return m, nil
case progressMsg:
m.progressMsg = string(msg)
return m, nil
case tickMsg:
return m, tea.Batch(checkGatewayCmd, tickCmd())
case gatewayStatusMsg:
m.gatewayOk = bool(msg)
return m, nil
case actionResultMsg:
m.actionErr = msg.err
m.actionDone = true
if msg.err == nil {
m.progressMsg = "操作成功完成!"
if m.actionType == ActionStartGateway {
m.DidStartGateway = true
}
} else {
m.progressMsg = fmt.Sprintf("操作失败: %v", msg.err)
}
return m, nil
}
// State-specific updates
switch m.state {
case StateDashboard:
return m.updateDashboard(msg)
case StateWizard:
return m.updateWizard(msg)
case StateAction:
// In action state, mostly waiting for msgs, but maybe handle quit
return m, nil
}
return m, nil
}
func (m Model) updateDashboard(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.menuIndex > 0 {
m.menuIndex--
}
case "down", "j":
if m.menuIndex < 4 { // 5 items (0-4)
m.menuIndex++
}
case "enter":
return m.handleMenuSelect()
case "q":
return m, tea.Quit
}
}
return m, nil
}
func (m Model) handleMenuSelect() (tea.Model, tea.Cmd) {
switch m.menuIndex {
case 0: // Start/Restart Gateway
m.state = StateAction
m.actionDone = false
m.actionErr = nil
if m.gatewayOk {
m.actionType = ActionKillGateway
m.progressMsg = "正在停止网关..."
return m, runKillGatewayCmd
} else {
m.actionType = ActionStartGateway
m.progressMsg = "正在启动网关..."
return m, runStartGatewayCmd
}
case 1: // Configure
m.state = StateWizard
m.wizardStep = StepApiSelect
m.configOpts = sys.ConfigOptions{}
return m, nil
case 2: // Install/Update
m.state = StateAction
m.actionType = ActionInstall
m.actionDone = false
m.actionErr = nil
m.progressMsg = "准备安装..."
return m, runInstallFlowCmd
case 3: // Uninstall
m.state = StateAction
m.actionType = ActionUninstall
m.actionDone = false
m.actionErr = nil
m.progressMsg = "正在卸载..."
return m, runUninstallCmd
case 4: // Exit
return m, tea.Quit
}
return m, nil
}
func (m Model) updateWizard(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch m.wizardStep {
case StepApiSelect:
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "1":
m.configOpts.ApiType = "anthropic"
m.wizardStep = StepApiInput
m.inputStep = 0
m.input.Placeholder = "sk-ant-api03-..."
m.input.EchoMode = textinput.EchoPassword
m.input.SetValue("")
case "2":
m.configOpts.ApiType = "openai"
m.wizardStep = StepApiInput
m.inputStep = 0
m.input.Placeholder = "https://api.openai.com/v1"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
}
}
case StepApiInput:
switch k := msg.(type) {
case tea.KeyMsg:
if k.String() == "enter" {
val := m.input.Value()
// Logic handles both Anthropic and OpenAI flows
isAnthropic := m.configOpts.ApiType == "anthropic"
if isAnthropic {
switch m.inputStep {
case 0: // Key
m.configOpts.AnthropicKey = val
m.inputStep++
m.input.Placeholder = "123456:ABC-DEF..."
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case 1: // Bot Token
m.configOpts.BotToken = val
m.inputStep++
m.input.Placeholder = "123456789"
m.input.SetValue("")
case 2: // Admin ID
m.configOpts.AdminID = val
m.wizardStep = StepConfirm
}
} else { // OpenAI
switch m.inputStep {
case 0: // Base URL
m.configOpts.OpenAIBaseURL = val
m.inputStep++
m.input.Placeholder = "sk-..."
m.input.EchoMode = textinput.EchoPassword
m.input.SetValue("")
case 1: // Key
m.configOpts.OpenAIKey = val
m.inputStep++
m.input.Placeholder = "gpt-4o / claude-3-5-sonnet"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case 2: // Model
m.configOpts.OpenAIModel = val
m.inputStep++
m.input.Placeholder = "123456:ABC-DEF..."
m.input.SetValue("")
case 3: // Bot Token
m.configOpts.BotToken = val
m.inputStep++
m.input.Placeholder = "123456789"
m.input.SetValue("")
case 4: // Admin ID
m.configOpts.AdminID = val
m.wizardStep = StepConfirm
}
}
return m, nil
}
}
m.input, cmd = m.input.Update(msg)
return m, cmd
case StepConfirm:
if k, ok := msg.(tea.KeyMsg); ok {
if k.String() == "enter" {
// Run Save Action
m.state = StateAction
m.actionDone = false
m.actionErr = nil
m.progressMsg = "正在保存配置..."
return m, runSaveConfigCmd(m.configOpts)
}
}
}
return m, nil
}
// VIEW RENDERING
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
}
switch m.state {
case StateDashboard:
return m.renderDashboard()
case StateWizard:
return m.renderWizard()
case StateAction:
return m.renderAction()
}
return ""
}
func (m Model) renderDashboard() string {
// 1. Header
header := style.HeaderStyle.Render("Moltbot Installer")
// 2. Status Bar
nodeStatus := style.Badge("检测中...", "info")
if m.checkDone {
if m.nodeOk {
nodeStatus = style.Badge(m.nodeVer, "success")
} else {
nodeStatus = style.Badge("缺失", "error")
}
}
moltStatus := style.Badge("检测中...", "info")
if m.checkDone {
if m.moltbotOk {
ver := m.moltbotVer
if ver == "" {
ver = "已安装"
}
moltStatus = style.Badge(ver, "success")
} else {
moltStatus = style.Badge("未安装", "warning")
}
}
gwStatus := style.Badge("...", "info")
if m.checkDone {
if m.gatewayOk {
gwStatus = style.Badge("运行中", "success")
} else {
gwStatus = style.Badge("已停止", "warning")
}
}
statusPanel := style.PanelStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
style.SubHeaderStyle.Render("系统状态"),
fmt.Sprintf("Node.js 环境: %s", nodeStatus),
fmt.Sprintf("Moltbot 核心: %s", moltStatus),
fmt.Sprintf("网关进程: %s", gwStatus),
))
// 3. Menu
menuItems := []struct{ title, desc string }{
{"启动/重启服务", "管理后台网关进程"},
{"配置向导", "设置 API 密钥与机器人参数"},
{"安装/更新环境", "一键部署 Node.js 与核心组件"},
{"卸载 Moltbot", "清理所有文件与配置"},
{"退出", "关闭控制台"},
}
// Dynamic text for toggle
if m.gatewayOk {
menuItems[0].title = "重启服务"
menuItems[0].desc = "停止当前进程并重新启动"
} else {
menuItems[0].title = "启动服务"
menuItems[0].desc = "启动后台网关进程"
}
var menuView string
for i, item := range menuItems {
if i == m.menuIndex {
menuView += style.MenuSelectedStyle.Render(fmt.Sprintf("%s\n%s", item.title, style.DescriptionStyle.Render(item.desc)))
} else {
menuView += style.MenuNormalStyle.Render(item.title)
}
menuView += "\n\n"
}
menuPanel := style.FocusedPanelStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
style.SubHeaderStyle.Render("主菜单"),
menuView,
style.SubtleStyle.Render("使用 ↑/↓ 选择,Enter 确认"),
))
// Layout
if m.width > 100 {
// Side by Side
return style.AppStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
header,
"",
lipgloss.JoinHorizontal(lipgloss.Top, statusPanel, " ", menuPanel),
))
}
// Vertical Stack
return style.AppStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
header,
"",
statusPanel,
"",
menuPanel,
))
}
func (m Model) renderWizard() string {
var content string
switch m.wizardStep {
case StepApiSelect:
content = lipgloss.JoinVertical(lipgloss.Left,
style.SubHeaderStyle.Render("Step 1: 选择 API 提供商"),
"",
style.MenuNormalStyle.Render("[1] Anthropic 官方 API (推荐)"),
style.DescriptionStyle.Render(" 直接连接 Claude 服务,最稳定"),
"",
style.MenuNormalStyle.Render("[2] OpenAI 兼容 API"),
style.DescriptionStyle.Render(" 支持 DeepSeek, GPT-4 等第三方模型"),
"",
style.SubtleStyle.Render("按 1 或 2 选择,Esc 返回"),
)
case StepApiInput:
label := "配置项"
if m.configOpts.ApiType == "anthropic" {
switch m.inputStep {
case 0:
label = "Anthropic API Key"
case 1:
label = "Telegram Bot Token (可选)"
case 2:
label = "Telegram 账户ID (可选)"
}
} else {
switch m.inputStep {
case 0:
label = "API 地址"
case 1:
label = "API Key"
case 2:
label = "模型名称"
case 3:
label = "Telegram Bot Token (可选)"
case 4:
label = "Telegram 账户ID (可选)"
}
}
var extraHelp string
if strings.Contains(label, "(可选)") {
extraHelp = style.InputHelpStyle.Render("如无需配置,请直接按 Enter 跳过。")
}
content = lipgloss.JoinVertical(lipgloss.Left,
style.SubHeaderStyle.Render("Step 2: 录入凭证"),
"",
style.InputHelpStyle.Render("请在下方输入您的 "+label+"。"),
style.InputHelpStyle.Render("支持使用 Ctrl+V 或 鼠标右键粘贴内容。"),
extraHelp,
"",
style.TitleStyle.Render(label),
"",
style.InputFocusedStyle.Render(m.input.View()),
"",
style.DescriptionStyle.Render("示例格式: "+m.input.Placeholder),
"",
style.SubtleStyle.Render("Enter 下一步"),
)
case StepConfirm:
content = lipgloss.JoinVertical(lipgloss.Left,
style.SubHeaderStyle.Render("Step 3: 确认配置"),
"",
"配置已就绪,准备写入文件。",
style.DescriptionStyle.Render("路径: ~/.clawdbot/clawdbot.json"),
"",
style.SubtleStyle.Render("Enter 确认写入,Esc 取消"),
)
}
return style.AppStyle.Render(style.WizardPanelStyle.Render(content))
}
func (m Model) renderAction() string {
icon := m.spinner.View()
title := "正在处理..."
if m.actionDone {
icon = "✅"
if m.actionErr != nil {
icon = "❌"
title = "操作失败"
} else {
title = "操作完成"
}
}
content := lipgloss.JoinVertical(lipgloss.Center,
style.SubHeaderStyle.Render(title),
"",
fmt.Sprintf("%s %s", icon, m.progressMsg),
"",
)
if m.actionDone {
content = lipgloss.JoinVertical(lipgloss.Center, content, style.SubtleStyle.Render("按 Enter 返回主菜单"))
}
return style.AppStyle.Render(style.PanelStyle.Render(content))
}
// COMMANDS
func checkEnvCmd() tea.Msg {
nodeVer, nodeOk := sys.CheckNode()
moltVer, moltOk := sys.CheckMoltbot()
gwRun := sys.IsGatewayRunning()
return checkMsg{
nodeVer: nodeVer,
nodeOk: nodeOk,
moltbotVer: moltVer,
moltbotInstalled: moltOk,
gatewayRunning: gwRun,
}
}
func runStartGatewayCmd() tea.Msg {
sys.StartGateway()
time.Sleep(1 * time.Second) // Wait for startup
return actionResultMsg{err: nil}
}
func runKillGatewayCmd() tea.Msg {
sys.KillGateway()
time.Sleep(1 * time.Second)
return actionResultMsg{err: nil}
}
func runUninstallCmd() tea.Msg {
// 1. Try to stop gateway if running
_ = sys.KillGateway()
time.Sleep(1 * time.Second)
// 2. Uninstall files
err := sys.UninstallMoltbot()
return actionResultMsg{err: err}
}
func runSaveConfigCmd(opts sys.ConfigOptions) tea.Cmd {
return func() tea.Msg {
err := sys.GenerateAndWriteConfig(opts)
return actionResultMsg{err: err}
}
}
func runInstallFlowCmd() tea.Msg {
// Linear flow: Check Node -> Install Node -> Config NPM -> Install Moltbot -> Config System
// Simplified to a blocking chain for "Action" state simplicity,
// or we can use tea.Sequence if we want granular updates.
// For now, we do a blocking sequence in a goroutine wrapper.
err := sys.InstallNode()
if err != nil {
return actionResultMsg{err: fmt.Errorf("node.js 安装失败: %v", err)}
}
err = sys.ConfigureNpmMirror()
if err != nil {
return actionResultMsg{err: fmt.Errorf("npm 配置失败: %v", err)}
}
err = sys.InstallMoltbotNpm("latest")
if err != nil {
return actionResultMsg{err: fmt.Errorf("moltbot 安装失败: %v", err)}
}
_, err = sys.EnsureOnPath()
if err != nil {
// Non-fatal
}
sys.RunDoctor()
return actionResultMsg{err: nil}
}
func tickCmd() tea.Cmd {
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func checkGatewayCmd() tea.Msg {
return gatewayStatusMsg(sys.IsGatewayRunning())
}