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

668 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}