diff --git a/changelog.md b/changelog.md index 32af91d..778263c 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,11 @@ - [x] 实现流式 Provider 调用 - [x] 实时打印 token - [x] 处理非流式 Provider 回退 + - [x] 添加加载动画(spinner 组件) + - [x] 使用 bubbletea v2 spinner.MiniDot 样式 + - [x] 用户输入后显示思考中动画 + - [x] 第一个 token 返回后显示思考完成 + - [x] 流式输出完成后添加空行分隔 ### v0.2.0 (计划) @@ -48,6 +53,7 @@ - [x] 实现 main.go 入口 - [x] 实现流式输出核心逻辑 - [x] 编译成功,生成 hxclaw 二进制 +- [x] 添加 spinner 加载动画组件 --- @@ -239,4 +245,73 @@ os.Stdout.Sync() - 保留所有历史输出 - 每次刷新缓冲区确保立即显示 -**知识点**:最简单的方案就是最有效的方案,不需要额外库 \ No newline at end of file +**知识点**:最简单的方案就是最有效的方案,不需要额外库 + +--- + +### spinner 组件的 model 更新必须使用返回值 + +**问题**:spinner 动画不动 + +**现象**:调用 spinner.Update(msg) 后动画不更新 + +**纠正**:spinner model 是值类型,需要使用返回值更新: +```go +// 错误写法 +func (s *Spinner) tick() { + msg := s.spinner.Tick() + if msg, ok := msg.(spinner.TickMsg); ok { + s.spinner.Update(msg) // 动画不会动! + } +} + +// 正确写法 +func (s *Spinner) tick() { + msg := s.spinner.Tick() + if msg, ok := msg.(spinner.TickMsg); ok { + s.spinner, _ = s.spinner.Update(msg) // 必须使用返回值更新 + } +} +``` + +**知识点**:bubbletea v2 的组件遵循 TEA 架构模式,Update 方法返回更新后的 model,需要显式使用返回值。 + +--- + +### spinner 和流式输出在同一行的冲突问题 + +**问题**:spinner 使用 `\r` 回到行首刷新,流式输出也在同一行打印,导致内容混在一起 + +**现象**: +``` +⠋ 回答中... 好 +⠋ 回答中... 注于 +``` + +**纠正**:在第一个 token 时停止 spinner,让 spinner 输出 "思考完成." 并换行,然后再开始流式打印: +```go +if firstToken && len(accumulated) > 0 { + spinner.Stop() // 停止 spinner,会打印 "思考完成." + firstToken = false +} +``` + +**知识点**:spinner 和流式输出需要分时工作,不能同时占用同一行。 + +--- + +### spinner 动画位置和换行策略 + +**问题**:用户期望动画在前,文字在后,且需要正确的换行 + +**效果**: +``` +思考中... ⠋ -> 用户期望 ⠋ 思考中... +思考完成. -> 用户期望 ⠋ 思考完成. +``` + +**纠正**: +- 动画在前,文字在后:`fmt.Printf("\r%s %s", s.spinner.View(), s.text)` +- 换行:"思考完成.\n" + 流式输出后 "fmt.Println()\n" + +**知识点**:终端输出需要精确控制位置和换行,否则会导致格式错乱。 \ No newline at end of file diff --git a/taolun.md b/taolun.md index 12ecc42..c0b838b 100644 --- a/taolun.md +++ b/taolun.md @@ -138,4 +138,71 @@ os.Stdout.Sync() 3. 保留所有历史输出 4. 每次刷新缓冲区确保立即显示 -这正是 ollama 等工具的流式输出效果。 \ No newline at end of file +这正是 ollama 等工具的流式输出效果。 + +--- + +### 12. 使用 bubbletea v2 的 spinner 组件实现加载动画 + +#### 需求分析 + +用户希望在使用流式输出时,显示加载动画: +- 用户输入后显示 "思考中... ⠋" +- 第一个 token 返回后显示 "思考完成." +- 流式输出完成后添加空行分隔 + +#### 技术选型 + +使用 `charm.land/bubbles/v2/spinner` 组件,这是 bubbletea v2 官方提供的 spinner 组件。 + +#### 实现方案 + +创建独立的 Spinner 结构体,在独立 goroutine 中运行动画: + +```go +type Spinner struct { + text string + state SpinnerState + spinner spinner.Model + stopCh chan struct{} + doneCh chan struct{} +} +``` + +关键点: +- 使用 `spinner.MiniDot` 动画样式 +- 独立 goroutine 使用 ticker 驱动动画帧切换 +- 使用 `\r` 回车符在同一行刷新动画 +- Stop 时输出 "思考完成." + +#### 官方示例参考 + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } +} +``` + +关键:spinner.Tick() 返回的 TickMsg 需要传给 spinner.Update(),并使用返回值更新 spinner model。 + +#### 注意事项 + +1. spinner model 更新必须使用返回值: + ```go + s.spinner, _ = s.spinner.Update(msg) // 正确 + s.spinner.Update(msg) // 错误!动画不会动 + ``` + +2. 动画位置:动画在前,文字在后: + ```go + fmt.Printf("\r%s %s", s.spinner.View(), s.text) // ⠋ 思考中... + ``` + +3. 换行控制: + - "思考完成." 后需要两个换行符(一个换行 + 一个空行) + - 流式输出完成后也需要空行分隔 \ No newline at end of file