830 lines
19 KiB
Go
830 lines
19 KiB
Go
package ui
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"openclaw-installer/internal/style"
|
||
"openclaw-installer/internal/sys"
|
||
|
||
"github.com/charmbracelet/bubbles/spinner"
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
// 会话状态
|
||
type SessionState int
|
||
|
||
const (
|
||
StateDashboard SessionState = iota
|
||
StateWizard
|
||
StateAction
|
||
)
|
||
|
||
// 向导子状态
|
||
type WizardStep int
|
||
|
||
const (
|
||
StepApiSelect WizardStep = iota
|
||
StepApiInput
|
||
StepConfirm
|
||
)
|
||
|
||
type ActionType int
|
||
|
||
const (
|
||
ActionCheckEnv ActionType = iota
|
||
ActionInstall
|
||
ActionUninstall
|
||
ActionStartGateway
|
||
ActionKillGateway
|
||
)
|
||
|
||
// 主程序模型
|
||
type Model struct {
|
||
// 全局状态
|
||
state SessionState
|
||
width int
|
||
height int
|
||
|
||
// 启动标志
|
||
DidStartGateway bool
|
||
|
||
// 仪表盘状态
|
||
menuIndex int
|
||
|
||
// 向导状态
|
||
wizardStep WizardStep
|
||
configOpts sys.ConfigOptions
|
||
input textinput.Model
|
||
inputStep int // 当前输入步骤
|
||
|
||
// 动作/进度状态
|
||
actionType ActionType
|
||
spinner spinner.Model
|
||
progressMsg string
|
||
actionErr error
|
||
actionDone bool
|
||
|
||
// 系统状态缓存
|
||
nodeVer string
|
||
nodeOk bool
|
||
openclawVer string
|
||
openclawOk bool
|
||
gitVer string
|
||
gitOk bool
|
||
gatewayOk bool
|
||
checkDone bool
|
||
|
||
configPath string
|
||
gatewayToken string
|
||
|
||
envRefreshActive bool
|
||
envRefreshAttempt int
|
||
envRefreshMax int
|
||
envRefreshExpectInstalled bool
|
||
}
|
||
|
||
// 消息定义
|
||
type checkMsg struct {
|
||
nodeVer string
|
||
nodeOk bool
|
||
openclawVer string
|
||
openclawInstalled bool
|
||
gitVer string
|
||
gitOk bool
|
||
gatewayRunning bool
|
||
configPath string
|
||
gatewayToken string
|
||
}
|
||
|
||
type actionResultMsg struct {
|
||
err error
|
||
successMsg string
|
||
}
|
||
|
||
type installProgressMsg struct {
|
||
step string
|
||
err error
|
||
done bool
|
||
channel chan installProgressMsg
|
||
}
|
||
|
||
type progressMsg string
|
||
|
||
type tickMsg time.Time
|
||
|
||
type gatewayStatusMsg bool
|
||
type envRefreshMsg int
|
||
|
||
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
|
||
}
|
||
// 向导模式返回
|
||
if m.state == StateWizard && msg.String() == "esc" {
|
||
m.state = StateDashboard
|
||
return m, nil
|
||
}
|
||
// 动作结果确认返回
|
||
if m.state == StateAction && m.actionDone && (msg.String() == "enter" || msg.String() == "esc") {
|
||
m.state = StateDashboard
|
||
// 刷新环境
|
||
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.openclawVer = msg.openclawVer
|
||
m.openclawOk = msg.openclawInstalled
|
||
m.gitVer = msg.gitVer
|
||
m.gitOk = msg.gitOk
|
||
m.gatewayOk = msg.gatewayRunning
|
||
m.configPath = msg.configPath
|
||
m.gatewayToken = msg.gatewayToken
|
||
m.checkDone = true
|
||
if m.envRefreshActive {
|
||
expect := m.envRefreshExpectInstalled
|
||
if m.nodeOk == expect && m.gitOk == expect && m.openclawOk == expect {
|
||
m.envRefreshActive = false
|
||
}
|
||
}
|
||
|
||
// 如果在动作模式下检查环境,可能需要切回主菜单
|
||
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 {
|
||
if msg.successMsg != "" {
|
||
m.progressMsg = msg.successMsg
|
||
} else {
|
||
m.progressMsg = "操作成功完成!"
|
||
}
|
||
if m.actionType == ActionStartGateway {
|
||
m.DidStartGateway = true
|
||
}
|
||
if m.actionType == ActionInstall || m.actionType == ActionUninstall {
|
||
m.envRefreshActive = true
|
||
m.envRefreshAttempt = 0
|
||
m.envRefreshMax = 5
|
||
m.envRefreshExpectInstalled = m.actionType == ActionInstall
|
||
return m, envRefreshCmd(0)
|
||
}
|
||
} else {
|
||
m.progressMsg = fmt.Sprintf("操作失败: %v", msg.err)
|
||
}
|
||
return m, nil
|
||
|
||
case installProgressMsg:
|
||
if msg.err != nil {
|
||
m.actionErr = msg.err
|
||
m.actionDone = true
|
||
m.progressMsg = fmt.Sprintf("安装失败: %v", msg.err)
|
||
return m, nil
|
||
}
|
||
if msg.done {
|
||
m.actionDone = true
|
||
m.progressMsg = "安装流程完成!"
|
||
m.envRefreshActive = true
|
||
m.envRefreshAttempt = 0
|
||
m.envRefreshMax = 5
|
||
m.envRefreshExpectInstalled = true
|
||
return m, envRefreshCmd(0)
|
||
}
|
||
m.progressMsg = msg.step
|
||
return m, waitForInstallProgress(msg.channel)
|
||
|
||
case envRefreshMsg:
|
||
if !m.envRefreshActive {
|
||
return m, nil
|
||
}
|
||
attempt := int(msg)
|
||
m.envRefreshAttempt = attempt
|
||
cmds := []tea.Cmd{checkEnvCmd}
|
||
if attempt+1 < m.envRefreshMax {
|
||
cmds = append(cmds, envRefreshCmd(attempt+1))
|
||
}
|
||
return m, tea.Batch(cmds...)
|
||
}
|
||
|
||
// 状态更新
|
||
switch m.state {
|
||
case StateDashboard:
|
||
return m.updateDashboard(msg)
|
||
case StateWizard:
|
||
return m.updateWizard(msg)
|
||
case StateAction:
|
||
// 动作模式下主要等待消息
|
||
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个选项 (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: // 启动/重启网关
|
||
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: // 配置
|
||
m.state = StateWizard
|
||
m.wizardStep = StepApiSelect
|
||
m.configOpts = sys.ConfigOptions{}
|
||
return m, nil
|
||
case 2: // 安装/更新
|
||
m.state = StateAction
|
||
m.actionType = ActionInstall
|
||
m.actionDone = false
|
||
m.actionErr = nil
|
||
m.progressMsg = "准备安装..."
|
||
return m, runInstallFlowCmd()
|
||
case 3: // 卸载
|
||
m.state = StateAction
|
||
m.actionType = ActionUninstall
|
||
m.actionDone = false
|
||
m.actionErr = nil
|
||
m.progressMsg = "正在卸载..."
|
||
return m, runUninstallCmd
|
||
case 4: // 退出
|
||
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()
|
||
// 处理 Anthropic 或 OpenAI 流程
|
||
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" {
|
||
// 执行保存
|
||
m.state = StateAction
|
||
m.actionDone = false
|
||
m.actionErr = nil
|
||
m.progressMsg = "正在保存配置..."
|
||
return m, runSaveConfigCmd(m.configOpts)
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// 视图渲染
|
||
func (m Model) View() string {
|
||
if m.width == 0 {
|
||
return "加载中..."
|
||
}
|
||
|
||
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 := style.HeaderStyle.Render("OpenClaw Installer")
|
||
|
||
// 2. 状态栏
|
||
nodeStatus := style.Badge("检测中...", "info")
|
||
if m.checkDone {
|
||
if m.nodeOk {
|
||
nodeStatus = style.Badge(m.nodeVer, "success")
|
||
} else {
|
||
nodeStatus = style.Badge("缺失", "error")
|
||
}
|
||
}
|
||
|
||
openclawStatus := style.Badge("检测中...", "info")
|
||
if m.checkDone {
|
||
if m.openclawOk {
|
||
ver := m.openclawVer
|
||
if ver == "" {
|
||
ver = "已安装"
|
||
}
|
||
openclawStatus = style.Badge(ver, "success")
|
||
} else {
|
||
openclawStatus = style.Badge("未安装", "warning")
|
||
}
|
||
}
|
||
|
||
gitStatus := style.Badge("检测中...", "info")
|
||
if m.checkDone {
|
||
if m.gitOk {
|
||
ver := m.gitVer
|
||
// 清理版本字符串 "git version 2.x.y..."
|
||
ver = strings.Replace(ver, "git version ", "", 1)
|
||
gitStatus = style.Badge(ver, "success")
|
||
} else {
|
||
gitStatus = style.Badge("缺失", "warning")
|
||
}
|
||
}
|
||
|
||
gwStatus := style.Badge("...", "info")
|
||
if m.checkDone {
|
||
if m.gatewayOk {
|
||
gwStatus = style.Badge("运行中", "success")
|
||
} else {
|
||
gwStatus = style.Badge("已停止", "warning")
|
||
}
|
||
}
|
||
|
||
statusRows := []string{
|
||
style.SubHeaderStyle.Render("系统状态"),
|
||
fmt.Sprintf("Node.js 环境: %s", nodeStatus),
|
||
fmt.Sprintf("Git 环境: %s", gitStatus),
|
||
fmt.Sprintf("OpenClaw 核心: %s", openclawStatus),
|
||
fmt.Sprintf("网关进程: %s", gwStatus),
|
||
}
|
||
|
||
if m.openclawOk && m.configPath != "" {
|
||
statusRows = append(statusRows, "")
|
||
statusRows = append(statusRows, fmt.Sprintf("配置文件: %s", m.configPath))
|
||
}
|
||
|
||
if m.gatewayOk && m.gatewayToken != "" {
|
||
statusRows = append(statusRows, "")
|
||
url := fmt.Sprintf("http://127.0.0.1:18789/?token=%s", m.gatewayToken)
|
||
statusRows = append(statusRows, fmt.Sprintf("访问地址: %s", style.SuccessStyle.Render(url)))
|
||
}
|
||
|
||
statusPanel := style.PanelStyle.Render(lipgloss.JoinVertical(lipgloss.Left, statusRows...))
|
||
|
||
// 3. 菜单
|
||
menuItems := []struct{ title, desc string }{
|
||
{"启动/重启服务", "管理后台网关进程"},
|
||
{"配置向导", "设置 API 密钥与机器人参数"},
|
||
{"安装/更新环境", "一键部署 Node.js 与核心组件"},
|
||
{"卸载 OpenClaw", "清理所有文件与配置"},
|
||
{"退出", "关闭控制台"},
|
||
}
|
||
|
||
// 动态切换文本
|
||
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 确认"),
|
||
))
|
||
|
||
// 4. 布局
|
||
if m.width > 100 {
|
||
// 并排
|
||
return style.AppStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
|
||
header,
|
||
"",
|
||
lipgloss.JoinHorizontal(lipgloss.Top, statusPanel, " ", menuPanel),
|
||
))
|
||
}
|
||
|
||
// 垂直堆叠
|
||
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("路径: ~/.openclaw/openclaw.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))
|
||
}
|
||
|
||
// 指令处理
|
||
|
||
func checkEnvCmd() tea.Msg {
|
||
sys.ResetPathCache()
|
||
nodeVer, nodeOk := sys.CheckNode()
|
||
openclawVer, openclawOk := sys.CheckOpenclaw()
|
||
gitVer, gitOk := sys.CheckGit()
|
||
gwRun := sys.IsGatewayRunning()
|
||
|
||
cfgPath, _ := sys.GetConfigPath()
|
||
token, _ := sys.GetGatewayToken()
|
||
|
||
return checkMsg{
|
||
nodeVer: nodeVer,
|
||
nodeOk: nodeOk,
|
||
openclawVer: openclawVer,
|
||
openclawInstalled: openclawOk,
|
||
gitVer: gitVer,
|
||
gitOk: gitOk,
|
||
gatewayRunning: gwRun,
|
||
configPath: cfgPath,
|
||
gatewayToken: token,
|
||
}
|
||
}
|
||
|
||
func envRefreshDelay(attempt int) time.Duration {
|
||
delay := 300 * time.Millisecond
|
||
for i := 0; i < attempt; i++ {
|
||
delay *= 2
|
||
if delay > 3*time.Second {
|
||
return 3 * time.Second
|
||
}
|
||
}
|
||
return delay
|
||
}
|
||
|
||
func envRefreshCmd(attempt int) tea.Cmd {
|
||
return tea.Tick(envRefreshDelay(attempt), func(t time.Time) tea.Msg {
|
||
return envRefreshMsg(attempt)
|
||
})
|
||
}
|
||
|
||
func runStartGatewayCmd() tea.Msg {
|
||
sys.StartGateway()
|
||
time.Sleep(1 * time.Second) // 等待启动
|
||
return actionResultMsg{err: nil}
|
||
}
|
||
|
||
func runKillGatewayCmd() tea.Msg {
|
||
sys.KillGateway()
|
||
time.Sleep(1 * time.Second)
|
||
return actionResultMsg{err: nil}
|
||
}
|
||
|
||
func runUninstallCmd() tea.Msg {
|
||
// 1. 尝试停止网关
|
||
_ = sys.KillGateway()
|
||
time.Sleep(1 * time.Second)
|
||
|
||
// 2. 卸载文件
|
||
err := sys.UninstallOpenclaw()
|
||
return actionResultMsg{err: err}
|
||
}
|
||
|
||
func runSaveConfigCmd(opts sys.ConfigOptions) tea.Cmd {
|
||
return func() tea.Msg {
|
||
err := sys.GenerateAndWriteConfig(opts)
|
||
msg := ""
|
||
if err == nil {
|
||
msg = "配置已保存!"
|
||
}
|
||
return actionResultMsg{err: err, successMsg: msg}
|
||
}
|
||
}
|
||
|
||
func waitForInstallProgress(sub chan installProgressMsg) tea.Cmd {
|
||
return func() tea.Msg {
|
||
msg, ok := <-sub
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return msg
|
||
}
|
||
}
|
||
|
||
func runInstallFlowCmd() tea.Cmd {
|
||
ch := make(chan installProgressMsg)
|
||
|
||
go func() {
|
||
defer close(ch)
|
||
|
||
// 1. Install Node
|
||
ch <- installProgressMsg{step: "正在安装 Node.js...", channel: ch}
|
||
if err := sys.InstallNode(); err != nil {
|
||
ch <- installProgressMsg{err: fmt.Errorf("node.js 安装失败: %v", err), channel: ch}
|
||
return
|
||
}
|
||
|
||
// 2. Install Git
|
||
ch <- installProgressMsg{step: "正在安装 Git...", channel: ch}
|
||
if err := sys.InstallGit(); err != nil {
|
||
ch <- installProgressMsg{err: fmt.Errorf("git 安装失败: %v", err), channel: ch}
|
||
return
|
||
}
|
||
|
||
// 3. Configure Git Proxy
|
||
ch <- installProgressMsg{step: "正在配置 Git 代理...", channel: ch}
|
||
if err := sys.ConfigureGitProxy(); err != nil {
|
||
ch <- installProgressMsg{err: fmt.Errorf("git 代理配置失败: %v", err), channel: ch}
|
||
return
|
||
}
|
||
|
||
// 4. Configure NPM Mirror
|
||
ch <- installProgressMsg{step: "正在配置 NPM 镜像...", channel: ch}
|
||
if err := sys.ConfigureNpmMirror(); err != nil {
|
||
ch <- installProgressMsg{err: fmt.Errorf("npm 配置失败: %v", err), channel: ch}
|
||
return
|
||
}
|
||
|
||
// 5. Install OpenClaw
|
||
ch <- installProgressMsg{step: "正在安装 OpenClaw...", channel: ch}
|
||
if err := sys.InstallOpenclawNpm("latest"); err != nil {
|
||
ch <- installProgressMsg{err: fmt.Errorf("openclaw 安装失败: %v", err), channel: ch}
|
||
return
|
||
}
|
||
|
||
// 6. Finalize
|
||
ch <- installProgressMsg{step: "正在配置环境...", channel: ch}
|
||
sys.EnsureOnPath()
|
||
sys.RunDoctor()
|
||
|
||
ch <- installProgressMsg{done: true, channel: ch}
|
||
}()
|
||
|
||
return waitForInstallProgress(ch)
|
||
}
|
||
|
||
func tickCmd() tea.Cmd {
|
||
return tea.Tick(1*time.Second, func(t time.Time) tea.Msg {
|
||
return tickMsg(t)
|
||
})
|
||
}
|
||
|
||
func checkGatewayCmd() tea.Msg {
|
||
return gatewayStatusMsg(sys.IsGatewayRunning())
|
||
}
|