diff --git a/moltbot/installer/installer.exe b/moltbot/installer/installer.exe index 1ea8bf6..e5706d0 100644 Binary files a/moltbot/installer/installer.exe and b/moltbot/installer/installer.exe differ diff --git a/moltbot/installer/internal/sys/sys.go b/moltbot/installer/internal/sys/sys.go index 82bd605..8612811 100644 --- a/moltbot/installer/internal/sys/sys.go +++ b/moltbot/installer/internal/sys/sys.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" ) @@ -342,6 +343,15 @@ func getNpmPrefix() (string, error) { return cachedNpmPrefix, nil } +func ResetPathCache() { + cachedNpmPrefix = "" + cachedNodePath = "" + cachedGitPath = "" + prefixOnce = sync.Once{} + nodePathOnce = sync.Once{} + gitPathOnce = sync.Once{} +} + // ConfigureNpmMirror 配置镜像 func ConfigureNpmMirror() error { npmPath, err := getNpmPath() @@ -374,7 +384,6 @@ func downloadFile(url, dest, expectedSHA256 string) error { return err } - fmt.Printf("正在下载: %s\n", url) if err := downloadWithResume(url, partPath, size, acceptRanges); err != nil { return err } @@ -394,16 +403,16 @@ func downloadFile(url, dest, expectedSHA256 string) error { func downloadWithResume(url, dest string, size int64, acceptRanges bool) error { if size > 0 && acceptRanges { if info, err := os.Stat(dest); err == nil && info.Size() > 0 && info.Size() < size { - return downloadRange(url, dest, info.Size(), size-1) + return downloadRange(url, dest, info.Size(), size-1, size) } if size >= downloadConcurrentThreshold { return downloadConcurrent(url, dest, size, downloadConcurrentParts) } } - return downloadRange(url, dest, 0, -1) + return downloadRange(url, dest, 0, -1, size) } -func downloadRange(url, dest string, start, end int64) error { +func downloadRange(url, dest string, start, end, total int64) error { out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("创建文件失败: %v", err) @@ -442,15 +451,23 @@ func downloadRange(url, dest string, start, end int64) error { return fmt.Errorf("下载失败,状态码: %d", resp.StatusCode) } - if _, err = io.Copy(out, resp.Body); err != nil { + if total <= 0 && resp.ContentLength > 0 { + total = start + resp.ContentLength + } + progress := newProgressReporter(total, start) + progress.Start() + reader := &countingReader{r: resp.Body, written: progress.written} + if _, err = io.Copy(out, reader); err != nil { + progress.Stop() return fmt.Errorf("写入文件失败: %v", err) } + progress.Stop() return nil } func downloadConcurrent(url, dest string, size int64, parts int) error { if parts < 2 { - return downloadRange(url, dest, 0, -1) + return downloadRange(url, dest, 0, -1, size) } out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) @@ -464,6 +481,8 @@ func downloadConcurrent(url, dest string, size int64, parts int) error { var wg sync.WaitGroup errCh := make(chan error, parts) + progress := newProgressReporter(size, 0) + progress.Start() partSize := size / int64(parts) for i := 0; i < parts; i++ { @@ -493,7 +512,7 @@ func downloadConcurrent(url, dest string, size int64, parts int) error { errCh <- fmt.Errorf("分段下载失败,状态码: %d", resp.StatusCode) return } - writer := &writeAtWriter{file: out, offset: s} + writer := &writeAtWriter{file: out, offset: s, written: progress.written} if _, err := io.Copy(writer, resp.Body); err != nil { errCh <- fmt.Errorf("写入文件失败: %v", err) return @@ -504,6 +523,7 @@ func downloadConcurrent(url, dest string, size int64, parts int) error { wg.Wait() close(errCh) out.Close() + progress.Stop() for err := range errCh { if err != nil { @@ -516,14 +536,89 @@ func downloadConcurrent(url, dest string, size int64, parts int) error { type writeAtWriter struct { file *os.File offset int64 + written *int64 } func (w *writeAtWriter) Write(p []byte) (int, error) { n, err := w.file.WriteAt(p, w.offset) w.offset += int64(n) + if w.written != nil && n > 0 { + atomic.AddInt64(w.written, int64(n)) + } return n, err } +type countingReader struct { + r io.Reader + written *int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if n > 0 && c.written != nil { + atomic.AddInt64(c.written, int64(n)) + } + return n, err +} + +type progressReporter struct { + total int64 + written *int64 + done chan struct{} + once sync.Once +} + +func newProgressReporter(total, initial int64) *progressReporter { + current := initial + return &progressReporter{ + total: total, + written: ¤t, + done: make(chan struct{}), + } +} + +func (p *progressReporter) Start() { + if p == nil || p.total <= 0 { + return + } + p.print() + go func() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.print() + case <-p.done: + p.print() + fmt.Print("\n") + return + } + } + }() +} + +func (p *progressReporter) Stop() { + if p == nil || p.total <= 0 { + return + } + p.once.Do(func() { + close(p.done) + }) +} + +func (p *progressReporter) print() { + current := atomic.LoadInt64(p.written) + if current < 0 { + current = 0 + } + if current > p.total { + current = p.total + } + percent := float64(current) * 100 / float64(p.total) + fmt.Printf("\r下载进度: %.2f%%", percent) +} + func probeRemoteFile(url string) (int64, bool, error) { client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest("HEAD", url, nil) diff --git a/moltbot/installer/internal/ui/model.go b/moltbot/installer/internal/ui/model.go index f9ab59f..e7d139d 100644 --- a/moltbot/installer/internal/ui/model.go +++ b/moltbot/installer/internal/ui/model.go @@ -77,6 +77,11 @@ type Model struct { gitOk bool gatewayOk bool checkDone bool + + envRefreshActive bool + envRefreshAttempt int + envRefreshMax int + envRefreshExpectInstalled bool } // 消息定义 @@ -99,6 +104,7 @@ type progressMsg string type tickMsg time.Time type gatewayStatusMsg bool +type envRefreshMsg int func InitialModel() Model { s := spinner.New() @@ -162,6 +168,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.gitOk = msg.gitOk m.gatewayOk = msg.gatewayRunning m.checkDone = true + if m.envRefreshActive { + expect := m.envRefreshExpectInstalled + if m.nodeOk == expect && m.gitOk == expect && m.moltbotOk == expect { + m.envRefreshActive = false + } + } // 如果在动作模式下检查环境,可能需要切回主菜单 if m.state == StateAction && m.actionType == ActionCheckEnv { @@ -188,10 +200,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 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...) } // 状态更新 @@ -602,6 +633,7 @@ func (m Model) renderAction() string { // 指令处理 func checkEnvCmd() tea.Msg { + sys.ResetPathCache() nodeVer, nodeOk := sys.CheckNode() moltVer, moltOk := sys.CheckMoltbot() gitVer, gitOk := sys.CheckGit() @@ -617,6 +649,23 @@ func checkEnvCmd() tea.Msg { } } +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) // 等待启动