2026-04-11 22:32:43 +08:00
|
|
|
|
# hxclaw 讨论记录
|
|
|
|
|
|
|
|
|
|
|
|
## 知识点汇总
|
|
|
|
|
|
|
|
|
|
|
|
### 1. hxclaw 是什么?
|
|
|
|
|
|
|
|
|
|
|
|
- hxclaw 是基于 picoclaw 的 CLI 增强工具
|
|
|
|
|
|
- 提供流式输出(替代原有的批量输出)
|
|
|
|
|
|
- 提供 Markdown 终端渲染(提升阅读体验)
|
|
|
|
|
|
- 定位: picoclaw 的变体,面向需要更好终端交互体验的用户
|
|
|
|
|
|
|
|
|
|
|
|
### 2. 为什么采用独立二进制而非插件?
|
|
|
|
|
|
|
|
|
|
|
|
- picoclaw 目前没有正式的插件系统
|
|
|
|
|
|
- 工具扩展通过 ToolRegistry 注册(如 spi.go, i2c.go)
|
|
|
|
|
|
- 技能通过 SKILL.md 文件加载
|
|
|
|
|
|
- MCP 通过配置连接外部服务器
|
|
|
|
|
|
- 最小侵入方式:独立二进制 + 复用核心库
|
|
|
|
|
|
|
|
|
|
|
|
### 3. 如何复用 picoclaw 功能?
|
|
|
|
|
|
|
|
|
|
|
|
- 使用 Go 的 replace 机制在 go.mod 中声明依赖
|
|
|
|
|
|
- 开发时 replace 指向本地 picoclaw 目录
|
|
|
|
|
|
- 发布时 replace 指向 GitHub 具体版本(如 v0.2.4)
|
|
|
|
|
|
- hxclaw 只需导入:pkg/agent, pkg/providers, pkg/config, pkg/bus
|
|
|
|
|
|
|
|
|
|
|
|
### 4. hxclaw 架构设计
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
hxclaw/
|
|
|
|
|
|
├── cmd/hxclaw/ # CLI 入口(自己实现)
|
|
|
|
|
|
├── go.mod # 依赖配置
|
|
|
|
|
|
└── pkg/ # 空目录,全量复用 picoclaw
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- 不依赖:pkg/channels(不需要消息通道)、pkg/gateway(不需要 HTTP 服务)、web/(不需要网页)
|
|
|
|
|
|
- 依赖:pkg/agent(核心逻辑)、pkg/providers(LLM 调用)、pkg/config(配置加载)
|
|
|
|
|
|
|
|
|
|
|
|
### 5. 流式输出原理
|
|
|
|
|
|
|
|
|
|
|
|
- picoclaw 的 providers 已支持 StreamingProvider 接口
|
|
|
|
|
|
- 接口定义:ChatStream(ctx, messages, tools, model, options, onChunk)
|
|
|
|
|
|
- onChunk 是回调函数,每个 token 生成时调用
|
|
|
|
|
|
- CLI 层需要判断 provider 是否实现 StreamingProvider,然后选择调用
|
|
|
|
|
|
|
|
|
|
|
|
### 6. Markdown 终端渲染
|
|
|
|
|
|
|
|
|
|
|
|
- 使用 charmbracelet 家族库
|
|
|
|
|
|
- lipgloss:终端样式
|
|
|
|
|
|
- glow:代码高亮
|
|
|
|
|
|
- 流程:Markdown → ANSI 转义序列 → 终端显示
|
|
|
|
|
|
|
|
|
|
|
|
### 7. 部署方式
|
|
|
|
|
|
|
|
|
|
|
|
- 独立二进制 hxclaw,与 picoclaw 二进制共存于同一目录
|
|
|
|
|
|
- 用户使用 `hxclaw` 命令调用增强版 CLI
|
|
|
|
|
|
- 配置文件复用 picoclaw 的 config.json(位于 ~/.picoclaw/config.json)
|
|
|
|
|
|
|
|
|
|
|
|
### 8. 版本同步策略
|
|
|
|
|
|
|
|
|
|
|
|
- 关键版本跟进(功能大版本更新时)
|
|
|
|
|
|
- 不需要每次 picoclaw 升级都同步
|
|
|
|
|
|
- 依赖版本在 go.mod 中声明,更新时修改 replace 目标版本即可
|
|
|
|
|
|
|
|
|
|
|
|
### 9. AgentRegistry 的正确使用方式
|
|
|
|
|
|
|
|
|
|
|
|
- `AgentRegistry` 负责管理多个 Agent 实例
|
|
|
|
|
|
- `GetDefaultAgent()` 获取默认的 Agent 实例
|
|
|
|
|
|
- `AgentInstance` 包含:
|
|
|
|
|
|
- `Provider` - LLM 提供者
|
|
|
|
|
|
- `ContextBuilder` - 消息构建器
|
|
|
|
|
|
- `Tools` - 工具注册表
|
|
|
|
|
|
- `Model` - 模型名称
|
|
|
|
|
|
- 注意:`AgentRegistry` 没有 `BuildMessages` 方法,该方法属于 `ContextBuilder`
|
|
|
|
|
|
|
|
|
|
|
|
### 10. ToolDefinitions 的获取方式
|
|
|
|
|
|
|
|
|
|
|
|
- 通过 `agentInstance.Tools.ToProviderDefs()` 获取
|
|
|
|
|
|
- 返回 `[]providers.ToolDefinition` 格式
|
|
|
|
|
|
- 该方法将工具注册表中的工具转换为 provider 可用的格式
|
|
|
|
|
|
|
|
|
|
|
|
### 11. 流式输出的实现问题与解决方案
|
|
|
|
|
|
|
|
|
|
|
|
#### 问题 1:onChunk 回调接收累积文本
|
|
|
|
|
|
|
|
|
|
|
|
picoclaw 的 `StreamingProvider` 接口定义:
|
|
|
|
|
|
```go
|
|
|
|
|
|
onChunk func(accumulated string)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
注释明确说明:"onChunk receives the accumulated text so far (not individual deltas)"。
|
|
|
|
|
|
|
|
|
|
|
|
这意味着每次回调时参数是完整的累积文本(如 "你好" → "你好!再次" → "你好!再次见到"),而不是增量。
|
|
|
|
|
|
|
|
|
|
|
|
#### 问题 2:直接打印导致重复输出
|
|
|
|
|
|
|
|
|
|
|
|
如果直接打印 token:
|
|
|
|
|
|
```go
|
|
|
|
|
|
func(token string) {
|
|
|
|
|
|
fmt.Print(token) // 打印累积文本!
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
会导致输出:
|
|
|
|
|
|
```
|
|
|
|
|
|
你好
|
|
|
|
|
|
你好!再次
|
|
|
|
|
|
你好!再次见到
|
|
|
|
|
|
...
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 解决方案 1:跟踪已打印长度
|
|
|
|
|
|
|
|
|
|
|
|
使用 `printedLen` 跟踪已打印的字符位置,只打印新增部分:
|
|
|
|
|
|
```go
|
|
|
|
|
|
var printedLen int
|
|
|
|
|
|
func(accumulated string) {
|
|
|
|
|
|
if len(accumulated) > printedLen {
|
|
|
|
|
|
fmt.Print(accumulated[printedLen:])
|
|
|
|
|
|
printedLen = len(accumulated)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 问题 3:尝试使用 uilive 库
|
|
|
|
|
|
|
|
|
|
|
|
尝试使用 `github.com/gosuri/uilive` 库实现同行流动效果,但发现该库会覆盖每一行,只显示最后一行内容,不符合需求。
|
|
|
|
|
|
|
|
|
|
|
|
#### 最终解决方案:直接 Print + Sync
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
fmt.Print(accumulated[printedLen:])
|
|
|
|
|
|
os.Stdout.Sync()
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
这样:
|
|
|
|
|
|
1. 字符串自然累积增长
|
|
|
|
|
|
2. 终端自动处理换行(满一行自动 wrap)
|
|
|
|
|
|
3. 保留所有历史输出
|
|
|
|
|
|
4. 每次刷新缓冲区确保立即显示
|
|
|
|
|
|
|
2026-04-11 23:57:38 +08:00
|
|
|
|
这正是 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. 换行控制:
|
|
|
|
|
|
- "思考完成." 后需要两个换行符(一个换行 + 一个空行)
|
2026-04-15 06:14:54 +08:00
|
|
|
|
- 流式输出完成后也需要空行分隔
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 13. 重绘残留问题与新流程
|
|
|
|
|
|
|
|
|
|
|
|
#### 问题描述
|
|
|
|
|
|
|
|
|
|
|
|
之前的流程:
|
|
|
|
|
|
1. 流式实时打印 token(边收边打)
|
|
|
|
|
|
2. 完成后 Markdown 重绘
|
|
|
|
|
|
3. 问题:重绘有残留
|
|
|
|
|
|
|
|
|
|
|
|
#### 解决方案:等待完整响应后输出
|
|
|
|
|
|
|
|
|
|
|
|
改进后的流程:
|
|
|
|
|
|
1. AI 返回完整数据 ← 等待时间
|
|
|
|
|
|
2. Markdown 转译
|
|
|
|
|
|
3. 模拟流式输出(从配置读取速度)
|
|
|
|
|
|
|
|
|
|
|
|
效果更好,无残留问题。
|
|
|
|
|
|
|
|
|
|
|
|
#### 配置化
|
|
|
|
|
|
|
|
|
|
|
|
使用 `project.config.yml` 统一管理配置:
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
streaming:
|
|
|
|
|
|
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
|
|
|
|
|
|
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
|
|
|
|
|
|
|
|
|
|
|
|
markdown:
|
|
|
|
|
|
glamour_style: dark
|
|
|
|
|
|
wrap_width: 0 # 自动获取终端宽度
|
|
|
|
|
|
|
|
|
|
|
|
ui:
|
|
|
|
|
|
logo: "🦐"
|
|
|
|
|
|
user_prefix: "👀 " # 用户输入前缀
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 14. 按行延迟输出的实现
|
|
|
|
|
|
|
|
|
|
|
|
#### 核心逻辑
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func outputLineByLine(text string) {
|
|
|
|
|
|
lines := strings.Split(text, "\n")
|
|
|
|
|
|
totalLines := len(lines)
|
|
|
|
|
|
|
|
|
|
|
|
cfg := internal.GetProjectConfig()
|
|
|
|
|
|
lineDelay := time.Duration(cfg.Streaming.LineDelayMs) * time.Millisecond
|
|
|
|
|
|
lastLineDelay := time.Duration(cfg.Streaming.LastLineDelayMs) * time.Millisecond
|
|
|
|
|
|
|
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
|
if line == "" {
|
|
|
|
|
|
fmt.Println()
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Println(line)
|
|
|
|
|
|
|
|
|
|
|
|
if i < totalLines-1 {
|
|
|
|
|
|
time.Sleep(lineDelay)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
time.Sleep(lastLineDelay)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Println()
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
特点:
|
|
|
|
|
|
- 空行直接跳过
|
|
|
|
|
|
- 每行输出后延迟可配置
|
|
|
|
|
|
- 最后一行延迟可单独配置
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 15. 工具调用结果显示问题
|
|
|
|
|
|
|
|
|
|
|
|
#### 问题
|
|
|
|
|
|
|
|
|
|
|
|
使用 `ChatStream` 时,工具调用结果不显示。
|
|
|
|
|
|
|
|
|
|
|
|
#### 原因分析
|
|
|
|
|
|
|
|
|
|
|
|
1. 工具调用结果不在流式响应中返回,而是通过 `bus.PublishOutbound()` 单独发送
|
|
|
|
|
|
2. `ChatStream` 的 `onChunk` 回调只处理文本内容,不处理工具调用
|
|
|
|
|
|
3. 工具调用在 `runTurn` 循环中执行,结果通过消息总线发送
|
|
|
|
|
|
|
|
|
|
|
|
#### 解决方案
|
|
|
|
|
|
|
|
|
|
|
|
回退使用 `ProcessDirect`,因为它会正确处理:
|
|
|
|
|
|
- 工具调用流程
|
|
|
|
|
|
- 工具结果显示
|
|
|
|
|
|
- Markdown 渲染和按行输出
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 16. 状态栏优化
|
|
|
|
|
|
|
|
|
|
|
|
#### 改动内容
|
|
|
|
|
|
|
|
|
|
|
|
- 图标:`▣`
|
|
|
|
|
|
- 图标颜色:`#f0c75e`
|
|
|
|
|
|
- 文字颜色:`#2b2e32`
|
|
|
|
|
|
- 内容:只显示耗时,如 `▣ 耗时: 2.3s`
|
|
|
|
|
|
|
|
|
|
|
|
#### 之前 vs 之后
|
|
|
|
|
|
|
|
|
|
|
|
- 之前:`▣ Tokens: 120 · 耗时: 2.3s · 总Tokens: 350`
|
|
|
|
|
|
- 之后:`▣ 耗时: 2.3s`
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 17. 项目配置文件详解
|
|
|
|
|
|
|
|
|
|
|
|
#### project.config.yml 结构
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
# hxclaw 项目配置文件
|
|
|
|
|
|
|
|
|
|
|
|
# 模拟流式输出配置
|
|
|
|
|
|
streaming:
|
|
|
|
|
|
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
|
|
|
|
|
|
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
|
|
|
|
|
|
|
|
|
|
|
|
# Markdown 渲染配置
|
|
|
|
|
|
markdown:
|
|
|
|
|
|
glamour_style: dark # 渲染主题:dark, light, dracula, tokyo-night 等
|
|
|
|
|
|
wrap_width: 0 # 自动换行宽度(0=自动获取终端宽度)
|
|
|
|
|
|
|
|
|
|
|
|
# UI 配置
|
|
|
|
|
|
ui:
|
|
|
|
|
|
logo: "🦐" # Logo
|
|
|
|
|
|
user_prefix: "👀 " # 用户输入前缀
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
#### 配置加载优先级
|
|
|
|
|
|
|
|
|
|
|
|
1. 环境变量 `HXCLAW_CONFIG` 指定路径
|
|
|
|
|
|
2. 项目根目录 `project.config.yml`
|
|
|
|
|
|
|
|
|
|
|
|
#### 代码实现
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
// internal/config.go
|
|
|
|
|
|
type ProjectConfig struct {
|
|
|
|
|
|
Streaming StreamingConfig `yaml:"streaming"`
|
|
|
|
|
|
Markdown MarkdownConfig `yaml:"markdown"`
|
|
|
|
|
|
UI UIConfig `yaml:"ui"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func getConfigPath() string {
|
|
|
|
|
|
if path := os.Getenv("HXCLAW_CONFIG"); path != "" {
|
|
|
|
|
|
return path
|
|
|
|
|
|
}
|
|
|
|
|
|
return filepath.Join(".", "project.config.yml")
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
### 18. 行业经验参考
|
|
|
|
|
|
|
|
|
|
|
|
#### CLI 动画最佳实践
|
|
|
|
|
|
|
|
|
|
|
|
- 帧率:75ms/帧(约 13fps)- GitHub Copilot CLI
|
|
|
|
|
|
- Spinner 动画:70-120ms - ora 库
|
|
|
|
|
|
- AI 流式输出:30-80ms/字符或行
|
|
|
|
|
|
- 总动画时长:控制在 3 秒内 - Copilot CLI 原则
|
|
|
|
|
|
|
|
|
|
|
|
#### 关键结论
|
|
|
|
|
|
|
|
|
|
|
|
- 人眼需要约 30-50ms 才能感知单次视觉变化
|
|
|
|
|
|
- 空白字符不应逐个输出,应批量处理
|
|
|
|
|
|
- 终端宽度 100% 时 Markdown 渲染会显著增加行数和字符数
|