feat: 添加流式输出动画效果

- 添加 spinner 组件,使用 bubbletea v2 的 MiniDot 动画
- 用户输入后显示思考中动画
- 第一个 token 返回后显示思考完成
- 流式输出完成后添加空行分隔
This commit is contained in:
2026-04-11 23:55:43 +08:00
parent d42c70f5ff
commit 13ece24893
5 changed files with 157 additions and 4 deletions

View File

@@ -0,0 +1,90 @@
package internal
import (
"fmt"
"os"
"time"
"charm.land/bubbles/v2/spinner"
"charm.land/lipgloss/v2"
)
type SpinnerState int
const (
StateThinking SpinnerState = iota
StateAnswering
StateDone
)
type Spinner struct {
text string
state SpinnerState
spinner spinner.Model
stopCh chan struct{}
doneCh chan struct{}
}
func NewSpinner(text string) *Spinner {
s := spinner.New(
spinner.WithSpinner(spinner.MiniDot),
spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("243"))),
)
return &Spinner{
text: text,
state: StateThinking,
spinner: s,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
func (s *Spinner) Start() {
go s.run()
s.tick()
}
func (s *Spinner) Update(text string) {
s.text = text
s.state = StateAnswering
}
func (s *Spinner) Stop() {
close(s.stopCh)
<-s.doneCh
}
func (s *Spinner) tick() {
msg := s.spinner.Tick()
if msg, ok := msg.(spinner.TickMsg); ok {
s.spinner, _ = s.spinner.Update(msg)
}
}
func (s *Spinner) run() {
defer close(s.doneCh)
ticker := time.NewTicker(time.Second / 12)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.tick()
s.render()
case <-s.stopCh:
s.clear()
return
}
}
}
func (s *Spinner) render() {
fmt.Printf("\r%s %s", s.spinner.View(), s.text)
os.Stdout.Sync()
}
func (s *Spinner) clear() {
fmt.Printf("\r%s 思考完成.\n", s.spinner.View())
os.Stdout.Sync()
}