668 lines
15 KiB
Go
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())
|
||
}
|