This commit is contained in:
user123
2026-01-28 22:18:17 +08:00
parent 02cbcc69af
commit 589d1ce2b8
8 changed files with 1264 additions and 13 deletions

View File

@@ -0,0 +1,68 @@
package style
import "github.com/charmbracelet/lipgloss"
var (
// Colors
ColorPrimary = lipgloss.Color("#7D56F4") // Purple
ColorSecondary = lipgloss.Color("#04B575") // Green
ColorError = lipgloss.Color("#FF4C4C") // Red
ColorWarning = lipgloss.Color("#FFD700") // Gold
ColorSubtle = lipgloss.Color("#626262") // Gray
ColorText = lipgloss.Color("#FAFAFA") // White
// Styles
AppStyle = lipgloss.NewStyle().
Padding(1, 2)
HeaderStyle = lipgloss.NewStyle().
Foreground(ColorPrimary).
Bold(true).
PaddingBottom(1)
StepStyle = lipgloss.NewStyle().
Foreground(ColorText)
SuccessStyle = lipgloss.NewStyle().
Foreground(ColorSecondary).
Bold(true)
ErrorStyle = lipgloss.NewStyle().
Foreground(ColorError).
Bold(true)
WarningStyle = lipgloss.NewStyle().
Foreground(ColorWarning)
SubtleStyle = lipgloss.NewStyle().
Foreground(ColorSubtle)
CmdStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FFFF")).
Padding(0, 1)
HighlightStyle = lipgloss.NewStyle().
Foreground(ColorSecondary).
Bold(true)
)
func RenderStep(prefix string, msg string, status string) string {
var statusStyle lipgloss.Style
switch status {
case "pending":
statusStyle = SubtleStyle
case "running":
statusStyle = lipgloss.NewStyle().Foreground(ColorPrimary)
case "done":
statusStyle = SuccessStyle
case "error":
statusStyle = ErrorStyle
default:
statusStyle = StepStyle
}
return lipgloss.JoinHorizontal(lipgloss.Left,
statusStyle.Width(3).Render(prefix),
statusStyle.Render(msg),
)
}

View File

@@ -0,0 +1,556 @@
package sys
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Config Structures
type MoltbotConfig struct {
Gateway GatewayConfig `json:"gateway"`
Env map[string]string `json:"env,omitempty"`
Agents AgentsConfig `json:"agents"`
Models *ModelsConfig `json:"models,omitempty"`
Tools ToolsConfig `json:"tools"`
Channels ChannelsConfig `json:"channels"`
}
type GatewayConfig struct {
Mode string `json:"mode"`
Bind string `json:"bind"`
Port int `json:"port"`
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
}
type AgentDefaults struct {
Model ModelRef `json:"model"`
ElevatedDefault string `json:"elevatedDefault,omitempty"`
Compaction *CompactionConfig `json:"compaction,omitempty"`
MaxConcurrent int `json:"maxConcurrent,omitempty"`
}
type ModelRef struct {
Primary string `json:"primary"`
}
type CompactionConfig struct {
Mode string `json:"mode"`
}
type ModelsConfig struct {
Mode string `json:"mode"`
Providers map[string]ProviderConfig `json:"providers"`
}
type ProviderConfig struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
API string `json:"api"`
Models []ModelEntry `json:"models"`
}
type ModelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ToolsConfig struct {
Exec *ExecConfig `json:"exec,omitempty"`
Elevated ElevatedConfig `json:"elevated"`
Allow []string `json:"allow"`
}
type ExecConfig struct {
BackgroundMs int `json:"backgroundMs"`
TimeoutSec int `json:"timeoutSec"`
CleanupMs int `json:"cleanupMs"`
NotifyOnExit bool `json:"notifyOnExit"`
}
type ElevatedConfig struct {
Enabled bool `json:"enabled"`
AllowFrom map[string][]string `json:"allowFrom"`
}
type ChannelsConfig struct {
Telegram TelegramConfig `json:"telegram"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled"`
BotToken string `json:"botToken"`
DMPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
}
// GetMoltbotPath 尝试解析 moltbot 或 clawdbot 的绝对路径
func GetMoltbotPath() (string, error) {
// 优先检查 clawdbot
if path, err := exec.LookPath("clawdbot"); err == nil {
return path, nil
}
if path, err := exec.LookPath("moltbot"); err == nil {
return path, nil
}
npmPath, err := getNpmPath()
if err != nil {
return "", fmt.Errorf("无法定位 npm: %v", err)
}
cmd := exec.Command(npmPath, "config", "get", "prefix")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("无法获取 npm prefix: %v", err)
}
npmPrefix := strings.TrimSpace(string(out))
// 检查 clawdbot.cmd
possibleClawd := filepath.Join(npmPrefix, "clawdbot.cmd")
if _, err := os.Stat(possibleClawd); err == nil {
return possibleClawd, nil
}
// 检查 moltbot.cmd
possibleMolt := filepath.Join(npmPrefix, "moltbot.cmd")
if _, err := os.Stat(possibleMolt); err == nil {
return possibleMolt, nil
}
return "", fmt.Errorf("未找到 moltbot 或 clawdbot 可执行文件")
}
// GetNodePath 获取 Node.js 可执行文件的绝对路径
func GetNodePath() (string, error) {
if path, err := exec.LookPath("node"); err == nil {
return path, nil
}
defaultPath := `C:\Program Files\nodejs\node.exe`
if _, err := os.Stat(defaultPath); err == nil {
return defaultPath, nil
}
return "", fmt.Errorf("未找到 Node.js")
}
// SetupNodeEnv 将 Node.js 所在目录添加到当前进程的 PATH 环境变量
// 这对于刚安装完 Node.js 但未重启终端的情况非常重要
func SetupNodeEnv() error {
nodeExe, err := GetNodePath()
if err != nil {
return err
}
nodeDir := filepath.Dir(nodeExe)
pathEnv := os.Getenv("PATH")
// 简单检查是否已包含 (忽略大小写)
if strings.Contains(strings.ToLower(pathEnv), strings.ToLower(nodeDir)) {
return nil
}
newPath := nodeDir + string(os.PathListSeparator) + pathEnv
// 同时确保 npm prefix 在 PATH 中
npmPath, err := getNpmPath()
if err == nil {
cmd := exec.Command(npmPath, "config", "get", "prefix")
if out, err := cmd.Output(); err == nil {
npmPrefix := strings.TrimSpace(string(out))
if !strings.Contains(strings.ToLower(newPath), strings.ToLower(npmPrefix)) {
newPath = npmPrefix + string(os.PathListSeparator) + newPath
}
}
}
return os.Setenv("PATH", newPath)
}
// CheckMoltbot 检查 Moltbot 是否已安装
// 返回: (版本号, 是否已安装)
func CheckMoltbot() (string, bool) {
// 确保环境正确
SetupNodeEnv()
cmdName, err := GetMoltbotPath()
if err != nil {
return "", false
}
// Get version
cmd := exec.Command("cmd", "/c", cmdName, "--version")
out, err := cmd.Output()
if err != nil {
return "", false
}
return strings.TrimSpace(string(out)), true
}
// CheckNode 检查 Node.js 版本是否 >= 22
func CheckNode() (string, bool) {
// Try to find node
nodePath, err := GetNodePath()
if err != nil {
return "", false
}
cmd := exec.Command(nodePath, "-v")
out, err := cmd.Output()
if err != nil {
return "", false
}
versionStr := strings.TrimSpace(string(out)) // e.g., "v22.1.0"
re := regexp.MustCompile(`v(\d+)\.`)
matches := re.FindStringSubmatch(versionStr)
if len(matches) < 2 {
return versionStr, false
}
majorVer, err := strconv.Atoi(matches[1])
if err != nil {
return versionStr, false
}
if majorVer >= 22 {
return versionStr, true
}
return versionStr, false
}
// getNpmPath 获取 npm 可执行文件路径
func getNpmPath() (string, error) {
path, err := exec.LookPath("npm")
if err == nil {
return path, nil
}
// Try default Windows install path
defaultPath := `C:\Program Files\nodejs\npm.cmd`
if _, err := os.Stat(defaultPath); err == nil {
return defaultPath, nil
}
return "", fmt.Errorf("未找到 npm请确认 Node.js 安装成功")
}
// ConfigureNpmMirror 设置 npm 淘宝镜像
func ConfigureNpmMirror() error {
npmPath, err := getNpmPath()
if err != nil {
return err
}
// 设置 registry 为淘宝镜像
cmd := exec.Command(npmPath, "config", "set", "registry", "https://registry.npmmirror.com/")
if err := cmd.Run(); err != nil {
return fmt.Errorf("设置 npm 镜像失败: %v", err)
}
return nil
}
// InstallNode 下载并安装 Node.js MSI
func InstallNode() error {
msiUrl := "https://nodejs.org/dist/v24.13.0/node-v24.13.0-x64.msi"
tempDir := os.TempDir()
msiPath := filepath.Join(tempDir, "node-v24.13.0-x64.msi")
// 1. 下载 MSI
fmt.Printf("正在下载 Node.js: %s\n", msiUrl)
resp, err := http.Get(msiUrl)
if err != nil {
return fmt.Errorf("下载失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败,状态码: %d", resp.StatusCode)
}
out, err := os.Create(msiPath)
if err != nil {
return fmt.Errorf("创建文件失败: %v", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %v", err)
}
// 2. 安装 MSI (静默安装)
// msiexec /i <file> /qn
fmt.Println("正在安装 Node.js...")
installCmd := exec.Command("msiexec", "/i", msiPath, "/qn")
if err := installCmd.Run(); err != nil {
return fmt.Errorf("安装失败: %v", err)
}
// 3. 刷新环境变量 (当前进程无法立即生效,但后续调用 getNpmPath 会尝试绝对路径)
SetupNodeEnv()
return nil
}
// InstallMoltbotNpm 使用 npm 全局安装
func InstallMoltbotNpm(tag string) error {
// 确保 Node 环境就绪
SetupNodeEnv()
// 强制使用 clawdbot 包,因为用户反馈该包更稳定
// 如果之前传入的是 beta重置为 latest因为 clawdbot 的版本管理可能不同
pkgName := "clawdbot"
if tag == "" || tag == "beta" {
tag = "latest"
}
npmPath, err := getNpmPath()
if err != nil {
return err
}
// 设置环境变量以减少 npm 输出
os.Setenv("NPM_CONFIG_LOGLEVEL", "error")
os.Setenv("NPM_CONFIG_UPDATE_NOTIFIER", "false")
os.Setenv("NPM_CONFIG_FUND", "false")
os.Setenv("NPM_CONFIG_AUDIT", "false")
cmd := exec.Command(npmPath, "install", "-g", fmt.Sprintf("%s@%s", pkgName, tag))
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Run(); err != nil {
return err
}
return nil
}
// EnsureOnPath 确保 moltbot 或 clawdbot 在 PATH 中
// 返回值: (需要重启终端, error)
func EnsureOnPath() (bool, error) {
if _, err := exec.LookPath("clawdbot"); err == nil {
return false, nil // 已存在
}
if _, err := exec.LookPath("moltbot"); err == nil {
return false, nil // 已存在
}
npmPath, err := getNpmPath()
if err != nil {
return false, err
}
// 获取 npm prefix
cmd := exec.Command(npmPath, "config", "get", "prefix")
out, err := cmd.Output()
if err != nil {
return false, err
}
npmPrefix := strings.TrimSpace(string(out))
npmBin := filepath.Join(npmPrefix, "bin")
// 查找 clawdbot.cmd 或 moltbot.cmd
possiblePath := npmPrefix
// Check priority: clawdbot -> moltbot
if _, err := os.Stat(filepath.Join(npmPrefix, "clawdbot.cmd")); os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(npmPrefix, "moltbot.cmd")); os.IsNotExist(err) {
// Check bin subdir
possiblePath = npmBin
}
}
// 添加到用户 PATH
// 这里我们添加包含 .cmd 文件的目录到 PATH
psCmd := fmt.Sprintf(`
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not ($userPath -split ";" | Where-Object { $_ -ieq "%s" })) {
[Environment]::SetEnvironmentVariable("Path", "$userPath;%s", "User")
}
`, possiblePath, possiblePath)
exec.Command("powershell", "-Command", psCmd).Run()
return true, nil // 已添加,需要重启终端
}
// RunDoctor 运行迁移
func RunDoctor() error {
cmdName, err := GetMoltbotPath()
if err != nil {
// 尝试直接运行,虽然可能失败
cmdName = "moltbot"
}
// 在 Windows 上,需要通过 cmd /c 或 powershell 来运行 .cmd 文件
// 但 exec.Command 如果指向 .cmd 文件通常可以直接运行
// 为了保险,使用 cmd /c
cmd := exec.Command("cmd", "/c", cmdName, "doctor", "--non-interactive")
return cmd.Run()
}
// RunOnboard 运行引导 (未使用,保留备用)
func RunOnboard() error {
cmdName, err := GetMoltbotPath()
if err != nil {
cmdName = "moltbot"
}
cmd := exec.Command("cmd", "/c", cmdName, "onboard")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Config Options Struct
type ConfigOptions struct {
ApiType string // "anthropic" or "openai"
BotToken string
AdminID string
AnthropicKey string
OpenAIBaseURL string
OpenAIKey string
OpenAIModel string
}
// GenerateAndWriteConfig 生成并写入配置文件
func GenerateAndWriteConfig(opts ConfigOptions) error {
userHome, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("无法获取用户目录: %v", err)
}
configDir := filepath.Join(userHome, ".clawdbot")
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("创建配置目录失败: %v", err)
}
configFile := filepath.Join(configDir, "clawdbot.json")
config := MoltbotConfig{
Gateway: GatewayConfig{
Mode: "local",
Bind: "loopback",
Port: 18789,
},
Tools: ToolsConfig{
Elevated: ElevatedConfig{
Enabled: true,
AllowFrom: map[string][]string{
"telegram": {opts.AdminID},
},
},
Allow: []string{"exec", "process", "read", "write", "edit", "web_search", "web_fetch", "cron"},
},
Channels: ChannelsConfig{
Telegram: TelegramConfig{
Enabled: true,
BotToken: opts.BotToken,
DMPolicy: "pairing",
AllowFrom: []string{opts.AdminID},
},
},
}
if opts.ApiType == "anthropic" {
config.Env = map[string]string{
"ANTHROPIC_API_KEY": opts.AnthropicKey,
}
config.Agents = AgentsConfig{
Defaults: AgentDefaults{
Model: ModelRef{
Primary: "anthropic/claude-opus-4-5",
},
},
}
} else {
// OpenAI Compatible
config.Agents = AgentsConfig{
Defaults: AgentDefaults{
Model: ModelRef{
Primary: fmt.Sprintf("openai-compat/%s", opts.OpenAIModel),
},
ElevatedDefault: "full",
Compaction: &CompactionConfig{
Mode: "safeguard",
},
MaxConcurrent: 4,
},
}
config.Models = &ModelsConfig{
Mode: "merge",
Providers: map[string]ProviderConfig{
"openai-compat": {
BaseURL: opts.OpenAIBaseURL,
APIKey: opts.OpenAIKey,
API: "openai-completions",
Models: []ModelEntry{
{ID: opts.OpenAIModel, Name: opts.OpenAIModel},
},
},
},
}
// Add extra exec config for OpenAI mode (as per install.sh)
config.Tools.Exec = &ExecConfig{
BackgroundMs: 10000,
TimeoutSec: 1800,
CleanupMs: 1800000,
NotifyOnExit: true,
}
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("序列化配置失败: %v", err)
}
return os.WriteFile(configFile, data, 0644)
}
// StartGateway 在新窗口启动网关
func StartGateway() error {
cmdName, err := GetMoltbotPath()
if err != nil {
// Fallback if not found (though unlikely if installed)
cmdName = "moltbot"
}
// 使用 start 命令在新窗口运行
// Windows start command: start "Title" "Executable" args...
cmd := exec.Command("cmd", "/c", "start", "Moltbot Gateway", cmdName, "gateway", "--verbose")
return cmd.Start()
}
// UninstallMoltbot 卸载 Moltbot/Clawdbot 并清理配置
func UninstallMoltbot() error {
npmPath, err := getNpmPath()
if err != nil {
return err
}
// 1. Uninstall global packages
packages := []string{"clawdbot", "moltbot"}
for _, pkg := range packages {
cmd := exec.Command(npmPath, "uninstall", "-g", pkg)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Run() // Ignore errors if not installed
}
// 2. Remove configuration directory
userHome, err := os.UserHomeDir()
if err == nil {
configDir := filepath.Join(userHome, ".clawdbot")
os.RemoveAll(configDir)
// Also check for legacy .moltbot if exists
legacyDir := filepath.Join(userHome, ".moltbot")
os.RemoveAll(legacyDir)
}
return nil
}

View File

@@ -0,0 +1,523 @@
package ui
import (
"fmt"
"math/rand"
"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 {
nodeVer, nodeOk := sys.CheckNode()
moltbotVer, moltbotInstalled := sys.CheckMoltbot()
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))]
}