## 核心功能 - 双记忆系统合并:picoclaw MEMORY.md + hxclaw 会话摘要 - 独立上下文系统:不依赖 picoclaw session - 向量检索:硅基流动 BGE-M3 API - 三重检测:关键词/向量相似度/命令 ## 数据库 - libSQL (TursoDB) 存储 - sessions + chats 表设计 - 向量存储使用 binary 编码 ## 查询场景 - RecallHistory: 查询所有会话摘要 - RecallTopic: 按话题向量检索 - RecallSession: 指定会话详情 - RecallWithinSession: 会话内检索 ## 导出 - MongoDB 风格:~/.config/hxclaw/export-data.json - chats 嵌套在 sessions 下 - 增量导出,同 session 累加 ## UI 优化 - 合并状态显示(耗时 · 状态 · 消息数) - 颜色设计:金色图标 + 暗绿色/暗红色状态 ## 配置项 - memory.recall: keywords, auto_recall, similarity_threshold - memory.vector: max_search_results - memory.auto_export
14 KiB
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 家族库
- glamour:Markdown 渲染(自带代码高亮)
- lipgloss:终端样式
- 流程: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 接口定义:
onChunk func(accumulated string)
注释明确说明:"onChunk receives the accumulated text so far (not individual deltas)"。
这意味着每次回调时参数是完整的累积文本(如 "你好" → "你好!再次" → "你好!再次见到"),而不是增量。
问题 2:直接打印导致重复输出
如果直接打印 token:
func(token string) {
fmt.Print(token) // 打印累积文本!
}
会导致输出:
你好
你好!再次
你好!再次见到
...
解决方案 1:跟踪已打印长度
使用 printedLen 跟踪已打印的字符位置,只打印新增部分:
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
fmt.Print(accumulated[printedLen:])
os.Stdout.Sync()
这样:
- 字符串自然累积增长
- 终端自动处理换行(满一行自动 wrap)
- 保留所有历史输出
- 每次刷新缓冲区确保立即显示
这正是 ollama 等工具的流式输出效果。
12. 使用 bubbletea v2 的 spinner 组件实现加载动画
需求分析
用户希望在使用流式输出时,显示加载动画:
- 用户输入后显示 "思考中... ⠋"
- 第一个 token 返回后显示 "思考完成."
- 流式输出完成后添加空行分隔
技术选型
使用 charm.land/bubbles/v2/spinner 组件,这是 bubbletea v2 官方提供的 spinner 组件。
实现方案
创建独立的 Spinner 结构体,在独立 goroutine 中运行动画:
type Spinner struct {
text string
state SpinnerState
spinner spinner.Model
stopCh chan struct{}
doneCh chan struct{}
}
关键点:
- 使用
spinner.MiniDot动画样式 - 独立 goroutine 使用 ticker 驱动动画帧切换
- 使用
\r回车符在同一行刷新动画 - Stop 时输出 "思考完成."
官方示例参考
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。
注意事项
-
spinner model 更新必须使用返回值:
s.spinner, _ = s.spinner.Update(msg) // 正确 s.spinner.Update(msg) // 错误!动画不会动 -
动画位置:动画在前,文字在后:
fmt.Printf("\r%s %s", s.spinner.View(), s.text) // ⠋ 思考中... -
换行控制:
- "思考完成." 后需要两个换行符(一个换行 + 一个空行)
- 流式输出完成后也需要空行分隔
13. 重绘残留问题与新流程
问题描述
之前的流程:
- 流式实时打印 token(边收边打)
- 完成后 Markdown 重绘
- 问题:重绘有残留
解决方案:等待完整响应后输出
改进后的流程:
- AI 返回完整数据 ← 等待时间
- Markdown 转译
- 模拟流式输出(从配置读取速度)
效果更好,无残留问题。
配置化
使用 project.config.yml 统一管理配置:
streaming:
line_delay_ms: 1000 # 每行输出后的延迟(毫秒)
last_line_delay_ms: 600 # 最后一行延迟(毫秒)
markdown:
glamour_style: dark
wrap_width: 0 # 自动获取终端宽度
ui:
logo: "🦐"
user_prefix: "👀 " # 用户输入前缀
14. 按行延迟输出的实现
核心逻辑
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 时,工具调用结果不显示。
原因分析
- 工具调用结果不在流式响应中返回,而是通过
bus.PublishOutbound()单独发送 ChatStream的onChunk回调只处理文本内容,不处理工具调用- 工具调用在
runTurn循环中执行,结果通过消息总线发送
解决方案
回退使用 ProcessDirect,因为它会正确处理:
- 工具调用流程
- 工具结果显示
- Markdown 渲染和按行输出
16. 状态栏优化
改动内容
- 图标:
▣ - 图标颜色:
#f0c75e - 文字颜色:
#2b2e32 - 内容:只显示耗时,如
▣ 耗时: 2.3s
之前 vs 之后
- 之前:
▣ Tokens: 120 · 耗时: 2.3s · 总Tokens: 350 - 之后:
▣ 耗时: 2.3s
17. 项目配置文件详解
project.config.yml 结构
# 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: "👀 " # 用户输入前缀
# TTS 语音配置
tts:
enabled: false # 全局开关(默认关闭)
port: 9876 # daemon 端口
auto: true # AI 回复后自动朗读
配置加载优先级
- 环境变量
HXCLAW_CONFIG指定路径 - 项目根目录
project.config.yml
代码实现
// 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 渲染会显著增加行数和字符数
19. TTS 语音朗读集成
架构设计
hxclaw 作为 mimo-tts 的客户端,通过 TCP Socket 连接本地 daemon:
hxclaw (客户端) --TCP:9876--> mimo-tts daemon (服务端)
|
v
API 调用 (mimo-v2.5-tts)
|
v
返回音频文件路径
|
v
afplay 播放
配置文件
tts:
enabled: false # 全局开关(默认关闭)
port: 9876 # daemon 端口
auto: true # AI 回复后自动朗读
命令支持
| 输入 | 行为 |
|---|---|
/tts |
切换 TTS 开关 |
/tts on |
开启 TTS |
/tts off |
关闭 TTS |
/tts status |
显示状态 |
T 消息 |
临时开启并发送 |
动态提示符
- 关闭:
👀 - 开启:
👀 🔊
实现要点
- TCP 连接:使用 Go 标准库
net包 - JSON 请求:发送格式
{"text": "内容"} - 异步朗读:使用
go func()异步调用 - 静默失败:网络异常只记录警告日志,不阻塞用户
踩坑记录
ergochat/readline SetPrompt 无返回值
// 错误
func (r *Readline) SetPrompt(prompt string) error {
return r.rl.SetPrompt(prompt) // SetPrompt 返回 void
}
// 正确
func (r *Readline) SetPrompt(prompt string) {
r.rl.SetPrompt(prompt) // void 类型
}
20. 禁用 picoclaw session 历史,实现独立上下文系统
问题背景
- picoclaw 的 session 历史会被自动清空
- 不利于 hxclaw 的会话连续性
- 需要实现自我控制的上下文系统
解决方案
- 禁用 picoclaw 的 session 历史读取
- 使用 hxclaw 自己的 libSQL 数据库存储会话摘要
- 在 ProcessDirect() 调用前注入上下文摘要到用户输入
实现步骤
- 创建 GetContextPrompt() -
cmd/hxclaw/internal/memory/save.gofunc GetContextPrompt() string { // 从 hxclaw 自己的数据库获取会话摘要 return "=== 当前会话摘要 ===
" + session.Summary + "
" }
2. **注入上下文** - `cmd/hxclaw/main.go`
```go
if memoryCfg.Enabled {
contextPrompt := memory.GetContextPrompt()
if contextPrompt != "" {
input = contextPrompt + "
用户新问题: " + input
}
}
resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey)
效果
- hxclaw 完全独立于 picoclaw session 管理
- 会话摘要通过数据库持久化
- 上下文通过摘要注入传递
21. UI 合并显示与颜色设计
需求
将原来分两行显示的信息合并为一行:
- 之前:
[memory] 已保存,当前会话 8 条消息+▣ 耗时: 20.3s - 之后:
▣ 耗时: 20.3s · 会话已保存 · 当前会话 8 条消息
颜色设计
| 文字 | 颜色 | 十六进制 |
|---|---|---|
| ▣ (图标) | 金色 | #f0c75e |
| 灰色文字 | 灰色 | #2b2e32 |
| 会话已保存 | 暗绿色 | #4a9e6b |
| 会话保存异常 | 暗红色 | #c75050 |
代码实现
var (
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f0c75e"))
textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2b2e32"))
memoryOkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4a9e6b"))
memoryErrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#c75050"))
)
func printElapsed(elapsed time.Duration, chatCount int, saveErr error) {
icon := iconStyle.Render("▣ ")
timeText := textStyle.Render(fmt.Sprintf("耗时: %s", elapsedStr))
var statusText string
if saveErr != nil {
statusText = memoryErrStyle.Render("会话保存异常")
} else if chatCount > 0 {
statusText = memoryOkStyle.Render("会话已保存")
}
memCountText := textStyle.Render(fmt.Sprintf("当前会话 %d 条消息", chatCount))
fmt.Printf(" %s%s · %s · %s
", icon, timeText, statusText, memCountText)
}
关键点
SaveChat()改为返回(chatCount int, err error),便于错误处理- 状态文字单独使用颜色样式
- 失败时显示"会话保存异常"(暗红色)