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

634 lines
16 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
StateGatewayRunning
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
nextState AppState
nextCmd tea.Cmd
DidStartGateway bool
}
type checkMsg struct {
nodeVer string
nodeOk bool
needsNode bool
moltbotVer string
moltbotInstalled bool
gatewayRunning 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
if sys.IsGatewayRunning() {
m.state = StateGatewayRunning
} else {
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 = "sk-ant-api03-..."
m.input.EchoMode = textinput.EchoPassword
m.input.SetValue("")
case "2":
m.configOpts.ApiType = "openai"
m.state = StateConfigInput
m.configStep = 0
m.input.Placeholder = "https://api.openai.com/v1"
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()
// Step logic:
// Anthropic: 0(Key) -> 1(TG Token) -> 2(TG ID) -> Finish
// OpenAI: 0(BaseURL) -> 1(Key) -> 2(Model) -> 3(TG Token) -> 4(TG ID) -> Finish
if m.configOpts.ApiType == "anthropic" {
switch m.configStep {
case 0: // API Key
m.configOpts.AnthropicKey = val
m.configStep++
m.input.Placeholder = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case 1: // TG Token
m.configOpts.BotToken = val
m.configStep++
m.input.Placeholder = "123456789"
m.input.SetValue("")
case 2: // TG Admin ID
m.configOpts.AdminID = val
return m, saveConfigCmd(m.configOpts)
}
} else {
// OpenAI
switch m.configStep {
case 0: // BaseURL
m.configOpts.OpenAIBaseURL = val
m.configStep++
m.input.Placeholder = "sk-..."
m.input.EchoMode = textinput.EchoPassword
m.input.SetValue("")
case 1: // Key
m.configOpts.OpenAIKey = val
m.configStep++
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.configStep++
m.input.Placeholder = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
m.input.EchoMode = textinput.EchoNormal
m.input.SetValue("")
case 3: // TG Token
m.configOpts.BotToken = val
m.configStep++
m.input.Placeholder = "123456789"
m.input.SetValue("")
case 4: // TG Admin ID
m.configOpts.AdminID = val
return m, saveConfigCmd(m.configOpts)
}
}
return m, nil
}
m.input, cmd = m.input.Update(msg)
return m, cmd
}
// Gateway Running Conflict State
if m.state == StateGatewayRunning {
proceed := false
switch msg.String() {
case "y", "Y":
sys.KillGateway()
time.Sleep(1 * time.Second)
sys.StartGateway()
proceed = true
case "n", "N", "esc", "enter":
proceed = true
}
if proceed {
m.state = m.nextState
if m.nextState == StateInstallingNode {
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Node.js (可能需要管理员权限)...", "running"))
return m, m.nextCmd
} else if m.nextState == StateConfiguringNpm {
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
return m, m.nextCmd
}
return m, nil
}
return m, nil
}
// 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"))
}
}
// Determine Next Step
var nextState AppState
var nextCmd tea.Cmd
if msg.moltbotInstalled {
m.logs = append(m.logs, style.RenderStep("!", fmt.Sprintf("检测到 Moltbot 已安装 (%s)", msg.moltbotVer), "warning"))
nextState = StateConfirmInstall
nextCmd = nil
} else if !msg.nodeOk {
nextState = StateInstallingNode
nextCmd = installNodeCmd
} else {
nextState = StateConfiguringNpm
nextCmd = configNpmCmd
}
m.nextState = nextState
m.nextCmd = nextCmd
// Check Gateway Conflict
if msg.gatewayRunning {
m.state = StateGatewayRunning
return m, nil
}
// Proceed immediately if no conflict
m.state = nextState
if nextState == StateInstallingNode {
m.logs = append(m.logs, style.RenderStep("➜", "正在安装 Node.js (可能需要管理员权限)...", "running"))
return m, nextCmd
} else if nextState == StateConfiguringNpm {
m.logs = append(m.logs, style.RenderStep("➜", "正在配置 npm 淘宝镜像...", "running"))
return m, nextCmd
}
return m, nil
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 StateGatewayRunning:
s += fmt.Sprintf("\n%s\n", style.SubtleStyle.Render("检测到 Moltbot 网关已在运行 (端口 18789 被占用)"))
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 := ""
if m.configOpts.ApiType == "anthropic" {
switch m.configStep {
case 0:
label = "Anthropic API Key (sk-ant-...):"
case 1:
label = "Telegram Bot Token (选填, 回车跳过):"
case 2:
label = "Telegram User ID (管理员) (选填, 回车跳过):"
}
} else {
// OpenAI
switch m.configStep {
case 0:
label = "API Base URL (例如 https://api.example.com/v1):"
case 1:
label = "API Key:"
case 2:
label = "模型名称 (例如 gpt-4o):"
case 3:
label = "Telegram Bot Token (选填, 回车跳过):"
case 4:
label = "Telegram User ID (管理员) (选填, 回车跳过):"
}
}
s += fmt.Sprintf("%s\n\n%s\n\n", label, m.input.View())
s += style.SubtleStyle.Render("按 Enter 确认") + "\n"
if (m.configOpts.ApiType == "anthropic" && m.configStep >= 1) || (m.configOpts.ApiType == "openai" && m.configStep >= 3) {
s += style.SubtleStyle.Render("跳过后可通过 http://127.0.0.1:18789/ Web UI 交互") + "\n"
}
}
return style.AppStyle.Render(s)
}
// Commands
func checkEnvCmd() tea.Msg {
var (
nodeVer string
nodeOk bool
moltbotVer string
moltbotInstalled bool
gatewayRunning bool
wg sync.WaitGroup
)
wg.Add(3)
go func() {
defer wg.Done()
nodeVer, nodeOk = sys.CheckNode()
}()
go func() {
defer wg.Done()
moltbotVer, moltbotInstalled = sys.CheckMoltbot()
}()
go func() {
defer wg.Done()
gatewayRunning = sys.IsGatewayRunning()
}()
wg.Wait()
return checkMsg{
nodeVer: nodeVer,
nodeOk: nodeOk,
needsNode: !nodeOk,
moltbotVer: moltbotVer,
moltbotInstalled: moltbotInstalled,
gatewayRunning: gatewayRunning,
}
}
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))]
}