Files
shell/moltbot/installer/internal/ui/model.go
2026-01-28 22:35:05 +08:00

545 lines
14 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"
"math/rand"
"sync"
"time"
"moltbot-installer/internal/style"
"moltbot-installer/internal/sys"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type AppState int
const (
StateInit AppState = iota
StateChecking
StateConfirmInstall
StateInstallingNode
StateConfiguringNpm
StateInstallingMoltbot
StateConfiguring
StateInstalled
StateMenu
StateConfigApiSelect
StateConfigInput
StateUninstallConfirm
StateUninstalling
StateError
)
type Model struct {
state AppState
spinner spinner.Model
err error
logs []string
nodeVer string
nodeOk bool
installMsg string
quitting bool
// Config Wizard
input textinput.Model
configOpts sys.ConfigOptions
configStep int
menuIndex int
DidStartGateway bool
}
type checkMsg struct {
nodeVer string
nodeOk bool
needsNode bool
moltbotVer string
moltbotInstalled bool
}
type installNodeMsg struct{ err error }
type configNpmMsg struct{ err error }
type installMoltbotMsg struct {
version string
err error
}
type configMsg struct {
restartPath bool
err error
}
type saveConfigMsg struct{ err error }
type uninstallMsg struct{ err error }
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: StateInit,
spinner: s,
input: ti,
logs: []string{},
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
func() tea.Msg {
time.Sleep(500 * time.Millisecond)
return checkMsg{}
},
)
}
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" {
m.quitting = true
return m, tea.Quit
}
// Menu Navigation
if m.state == StateMenu {
switch msg.String() {
case "up", "k":
if m.menuIndex > 0 {
m.menuIndex--
}
case "down", "j":
if m.menuIndex < 2 {
m.menuIndex++
}
case "enter":
switch m.menuIndex {
case 0: // Start
sys.StartGateway()
m.DidStartGateway = true
return m, tea.Quit
case 1: // Configure
m.state = StateConfigApiSelect
m.configOpts = sys.ConfigOptions{}
case 2: // Uninstall
m.state = StateUninstallConfirm
case 3: // Exit
return m, tea.Quit
}
}
return m, nil
}
// Uninstall Confirm State
if m.state == StateUninstallConfirm {
switch msg.String() {
case "y", "Y":
m.state = StateUninstalling
m.logs = append(m.logs, style.RenderStep("➜", "正在卸载 Moltbot 并清理配置...", "running"))
return m, uninstallCmd
case "n", "N", "enter":
m.state = StateMenu
}
return m, nil
}
// Config API Selection
if m.state == StateConfigApiSelect {
switch msg.String() {
case "1":
m.configOpts.ApiType = "anthropic"
m.state = StateConfigInput
m.configStep = 0
m.input.Placeholder = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case "2":
m.configOpts.ApiType = "openai"
m.state = StateConfigInput
m.configStep = 0
m.input.Placeholder = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case "q", "esc":
m.state = StateMenu // Back to menu
}
return m, nil
}
// Config Input Steps
if m.state == StateConfigInput {
switch msg.String() {
case "enter":
val := m.input.Value()
// Save current step value
switch m.configStep {
case 0: // Bot Token
m.configOpts.BotToken = val
m.configStep++
m.input.Placeholder = "123456789"
m.input.SetValue("")
case 1: // Admin ID
m.configOpts.AdminID = val
m.configStep++
if m.configOpts.ApiType == "anthropic" {
m.input.Placeholder = "sk-ant-api03-..."
m.input.EchoMode = textinput.EchoPassword
} else {
m.input.Placeholder = "https://api.openai.com/v1"
m.input.EchoMode = textinput.EchoNormal
}
m.input.SetValue("")
case 2: // Key (Anthropic) OR BaseURL (OpenAI)
if m.configOpts.ApiType == "anthropic" {
m.configOpts.AnthropicKey = val
// Finish Anthropic
return m, saveConfigCmd(m.configOpts)
} else {
m.configOpts.OpenAIBaseURL = val
m.configStep++
m.input.Placeholder = "sk-..."
m.input.EchoMode = textinput.EchoPassword
m.input.SetValue("")
}
case 3: // Key (OpenAI)
m.configOpts.OpenAIKey = val
m.configStep++
m.input.Placeholder = "gpt-4o / claude-3-5-sonnet"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case 4: // Model (OpenAI)
m.configOpts.OpenAIModel = val
// Finish OpenAI
return m, saveConfigCmd(m.configOpts)
}
return m, nil
}
m.input, cmd = m.input.Update(msg)
return m, cmd
}
// Confirm Install State
if m.state == StateConfirmInstall {
switch msg.String() {
case "y", "Y":
m.logs = append(m.logs, style.RenderStep("➜", "开始安装...", "running"))
if !m.nodeOk {
m.state = StateInstallingNode
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Node.js (可能需要管理员权限)...", "running"))
return m, installNodeCmd
}
m.state = StateConfiguringNpm
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
return m, configNpmCmd
case "n", "N", "enter":
m.logs = append(m.logs, style.RenderStep("!", "跳过安装步骤", "warning"))
m.state = StateConfiguring
return m, configCmd
}
return m, nil
}
// Installed State (Transition to Config)
if m.state == StateInstalled {
if msg.String() == "enter" {
m.state = StateConfigApiSelect
m.configOpts = sys.ConfigOptions{}
}
return m, nil
}
case checkMsg:
if m.state == StateInit {
m.state = StateChecking
return m, checkEnvCmd
}
m.nodeVer = msg.nodeVer
m.nodeOk = msg.nodeOk
m.logs = append(m.logs, style.RenderStep("✓", "Windows 系统检测完毕", "done"))
if msg.nodeOk {
m.logs = append(m.logs, style.RenderStep("✓", fmt.Sprintf("发现 Node.js %s", msg.nodeVer), "done"))
} else {
if msg.nodeVer != "" {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("发现 Node.js %s (需要 v22+)", msg.nodeVer), "warning"))
} else {
m.logs = append(m.logs, style.RenderStep("!", "未检测到 Node.js", "warning"))
}
}
if msg.moltbotInstalled {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("检测到 Moltbot 已安装 (%s)", msg.moltbotVer), "warning"))
m.state = StateConfirmInstall
return m, nil
}
if !msg.nodeOk {
m.state = StateInstallingNode
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Node.js (可能需要管理员权限)...", "running"))
return m, installNodeCmd
}
m.state = StateConfiguringNpm
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
return m, configNpmCmd
case installNodeMsg:
if msg.err != nil {
m.err = msg.err
m.state = StateError
return m, nil
}
m.logs = append(m.logs, style.RenderStep("✓", "Node.js 安装成功", "done"))
m.state = StateConfiguringNpm
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
return m, configNpmCmd
case configNpmMsg:
if msg.err != nil {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("配置镜像失败 (跳过): %v", msg.err), "warning"))
} else {
m.logs = append(m.logs, style.RenderStep("✓", "npm 淘宝镜像配置成功", "done"))
}
m.state = StateInstallingMoltbot
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Moltbot...", "running"))
return m, installMoltbotCmd
case installMoltbotMsg:
if msg.err != nil {
m.err = msg.err
m.state = StateError
return m, nil
}
if msg.version != "" {
m.logs = append(m.logs, style.RenderStep("✓", fmt.Sprintf("Moltbot 安装成功 (%s)", msg.version), "done"))
} else {
m.logs = append(m.logs, style.RenderStep("✓", "Moltbot 安装成功", "done"))
}
m.state = StateConfiguring
return m, configCmd
case configMsg:
if msg.err != nil {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("配置迁移失败: %v", msg.err), "warning"))
} else {
m.logs = append(m.logs, style.RenderStep("✓", "配置迁移/初始化完成", "done"))
}
if msg.restartPath {
m.logs = append(m.logs, style.RenderStep("!", "已添加 PATH 环境变量,请重启终端生效", "warning"))
}
m.state = StateInstalled
m.installMsg = getRandomWelcomeMsg()
return m, nil
case saveConfigMsg:
if msg.err != nil {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("保存配置失败: %v", msg.err), "warning"))
} else {
m.logs = append(m.logs, style.RenderStep("✓", "配置文件已生成!", "done"))
m.logs = append(m.logs, style.RenderStep("✓", "配置完成,准备启动", "done"))
}
m.state = StateMenu
m.menuIndex = 0 // Default to Start
return m, nil
case uninstallMsg:
if msg.err != nil {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("卸载失败: %v", msg.err), "warning"))
} else {
m.logs = append(m.logs, style.RenderStep("✓", "Moltbot 已卸载并清理配置", "done"))
}
m.state = StateMenu
m.menuIndex = 0
return m, nil
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m Model) View() string {
if m.err != nil {
return fmt.Sprintf("\n%s\n\n%s: %v\n\n按 q 退出\n",
style.HeaderStyle.Render("Moltbot 安装程序"),
style.ErrorStyle.Render("发生错误"),
m.err,
)
}
s := fmt.Sprintf("\n%s\n\n", style.HeaderStyle.Render("Moltbot 安装程序"))
// Show logs for install process
if m.state != StateMenu && m.state != StateConfigApiSelect && m.state != StateConfigInput {
for _, log := range m.logs {
s += log + "\n"
}
}
// Dynamic Content based on State
switch m.state {
case StateInstallingNode, StateConfiguringNpm, StateInstallingMoltbot, StateConfiguring, StateUninstalling:
s += fmt.Sprintf("\n%s %s\n", m.spinner.View(), style.SubtleStyle.Render("处理中..."))
case StateConfirmInstall:
s += fmt.Sprintf("\n%s\n", style.SubtleStyle.Render("是否强制重新安装/更新?[y/N]"))
case StateUninstallConfirm:
s += fmt.Sprintf("\n%s\n", style.SubtleStyle.Render("确定要卸载 Moltbot 吗?(这将删除配置文件) [y/N]"))
case StateInstalled:
s += fmt.Sprintf("\n%s\n", style.SuccessStyle.Render("安装完成!"))
s += style.SubtleStyle.Render(m.installMsg) + "\n\n"
s += style.StepStyle.Render("按 Enter 进入配置向导") + "\n"
case StateMenu:
s += style.HeaderStyle.Render("主菜单") + "\n\n"
choices := []string{"启动 Moltbot 网关", "配置 Moltbot", "卸载 Moltbot", "退出"}
for i, choice := range choices {
cursor := " "
if m.menuIndex == i {
cursor = "➜"
choice = style.HighlightStyle.Render(choice)
}
s += fmt.Sprintf(" %s %s\n", cursor, choice)
}
s += "\n" + style.SubtleStyle.Render("使用 ↑/↓ 选择Enter 确认") + "\n"
// Show logs below menu if desired, or keep clean
if len(m.logs) > 0 {
s += "\n" + style.SubtleStyle.Render("--- 安装日志 ---") + "\n"
start := len(m.logs) - 3
if start < 0 {
start = 0
}
for _, log := range m.logs[start:] {
s += log + "\n"
}
}
case StateConfigApiSelect:
s += style.HeaderStyle.Render("配置向导 - 选择 API 类型") + "\n\n"
s += "1. Anthropic 官方 API\n"
s += "2. OpenAI 兼容 API (中转站/其他模型)\n\n"
s += style.SubtleStyle.Render("按 1 或 2 选择Esc 返回") + "\n"
case StateConfigInput:
s += style.HeaderStyle.Render("配置向导") + "\n\n"
label := ""
switch m.configStep {
case 0:
label = "Telegram Bot Token:"
case 1:
label = "Telegram User ID (管理员):"
case 2:
if m.configOpts.ApiType == "anthropic" {
label = "Anthropic API Key (sk-ant-...):"
} else {
label = "API Base URL (例如 https://api.example.com/v1):"
}
case 3:
label = "API Key:"
case 4:
label = "模型名称 (例如 gpt-4o):"
}
s += fmt.Sprintf("%s\n\n%s\n\n", label, m.input.View())
s += style.SubtleStyle.Render("按 Enter 确认") + "\n"
}
return style.AppStyle.Render(s)
}
// Commands
func checkEnvCmd() tea.Msg {
var (
nodeVer string
nodeOk bool
moltbotVer string
moltbotInstalled bool
wg sync.WaitGroup
)
wg.Add(2)
go func() {
defer wg.Done()
nodeVer, nodeOk = sys.CheckNode()
}()
go func() {
defer wg.Done()
moltbotVer, moltbotInstalled = sys.CheckMoltbot()
}()
wg.Wait()
return checkMsg{
nodeVer: nodeVer,
nodeOk: nodeOk,
needsNode: !nodeOk,
moltbotVer: moltbotVer,
moltbotInstalled: moltbotInstalled,
}
}
func installNodeCmd() tea.Msg {
err := sys.InstallNode()
return installNodeMsg{err: err}
}
func configNpmCmd() tea.Msg {
err := sys.ConfigureNpmMirror()
return configNpmMsg{err: err}
}
func installMoltbotCmd() tea.Msg {
err := sys.InstallMoltbotNpm("latest")
if err != nil {
return installMoltbotMsg{err: err}
}
ver, _ := sys.CheckMoltbot()
return installMoltbotMsg{version: ver, err: nil}
}
func configCmd() tea.Msg {
restart, _ := sys.EnsureOnPath()
sys.RunDoctor()
return configMsg{restartPath: restart, err: nil}
}
func saveConfigCmd(opts sys.ConfigOptions) tea.Cmd {
return func() tea.Msg {
err := sys.GenerateAndWriteConfig(opts)
return saveConfigMsg{err: err}
}
}
func uninstallCmd() tea.Msg {
err := sys.UninstallMoltbot()
return uninstallMsg{err: err}
}
func getRandomWelcomeMsg() string {
msgs := []string{
"所有系统准备就绪",
"Moltbot 已就绪,随时为您服务",
"环境配置完成,开始使用吧",
"安装成功,期待您的使用",
}
return msgs[rand.Intn(len(msgs))]
}