# 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` 接口定义: ```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. 每次刷新缓冲区确保立即显示 这正是 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. 换行控制: - "思考完成." 后需要两个换行符(一个换行 + 一个空行) - 流式输出完成后也需要空行分隔 --- ### 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: "👀 " # 用户输入前缀 # TTS 语音配置 tts: enabled: false # 全局开关(默认关闭) port: 9876 # daemon 端口 auto: true # AI 回复后自动朗读 ``` #### 配置加载优先级 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 渲染会显著增加行数和字符数 --- ### 19. TTS 语音朗读集成 #### 架构设计 hxclaw 作为 mimo-tts 的客户端,通过 TCP Socket 连接本地 daemon: ``` hxclaw (客户端) --TCP:9876--> mimo-tts daemon (服务端) | v API 调用 (mimo-v2.5-tts) | v 返回音频文件路径 | v afplay 播放 ``` #### 配置文件 ```yaml tts: enabled: false # 全局开关(默认关闭) port: 9876 # daemon 端口 auto: true # AI 回复后自动朗读 ``` #### 命令支持 | 输入 | 行为 | |------|------| | `/tts` | 切换 TTS 开关 | | `/tts on` | 开启 TTS | | `/tts off` | 关闭 TTS | | `/tts status` | 显示状态 | | `T 消息` | 临时开启并发送 | #### 动态提示符 - 关闭:`👀 ` - 开启:`👀 🔊 ` #### 实现要点 1. TCP 连接:使用 Go 标准库 `net` 包 2. JSON 请求:发送格式 `{"text": "内容"}` 3. 异步朗读:使用 `go func()` 异步调用 4. 静默失败:网络异常只记录警告日志,不阻塞用户 #### 踩坑记录 **ergochat/readline SetPrompt 无返回值** ```go // 错误 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() 调用前注入上下文摘要到用户输入 #### 实现步骤 1. **创建 GetContextPrompt()** - `cmd/hxclaw/internal/memory/save.go` ```go func 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 | #### 代码实现 ```go 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)`,便于错误处理 - 状态文字单独使用颜色样式 - 失败时显示"会话保存异常"(暗红色)