17 Commits

Author SHA1 Message Date
724981b50a fix: 修复 TTS JSON 请求格式,兼容 Windows daemon
Some checks failed
Release / build (push) Failing after 23s
2026-04-26 03:54:29 +08:00
94328d4f28 删除 hxclaw 2026-04-26 03:17:03 +08:00
a7c240c833 docs: 全面更新文档(agents.md, taolun.md)包含 TTS 功能说明 2026-04-26 03:16:02 +08:00
1c73fb469c docs: 更新目前进度 2026-04-26 03:13:37 +08:00
73b480323d docs: 清理 changelog 重复内容 2026-04-26 03:12:59 +08:00
6c74d4032c docs: 更新 changelog,修正已实现功能状态 2026-04-26 03:12:35 +08:00
b12378df10 docs: 更新 changelog.md,修正版本状态 2026-04-26 03:10:54 +08:00
e4e5cd82c3 feat: 添加 TTS 语音朗读功能 (v0.2.0)
Some checks failed
Release / build (push) Failing after 6m27s
2026-04-26 03:01:28 +08:00
3f9443c14b docs: 更新讨论记录,添加 v0.1.0 相关知识点
Some checks failed
Release / build (push) Failing after 43s
2026-04-15 06:14:54 +08:00
bdad44de7d feat: 优化流式输出,添加按行延迟和配置化
Some checks failed
Release / build (push) Failing after 22s
- 使用 ProcessDirect 替代 ChatStream,支持工具调用结果显示
- 新增 project.config.yml 统一配置(Logo、用户前缀、流式延迟、Markdown等)
- Markdown 渲染支持自动终端宽度换行
- 按行输出文本,每行延迟可配置
- 简化状态栏,只显示耗时(图标颜色 #f0c75e,文字颜色 #2b2e32)
- spinner 动画右移两个字符
- 用户输入前缀可配置化
2026-04-15 06:13:52 +08:00
432a8db938 chore: 更新 agents.md 添加 picoclaw 地址,添加 hxclaw 到 .gitignore,切换到 picoclaw v0.2.6 2026-04-12 03:36:04 +08:00
a536375f41 ci: 切换到 picoclaw v0.2.6 2026-04-12 03:34:19 +08:00
8a631831df ci: 切换到远程 picoclaw 依赖 2026-04-12 03:28:51 +08:00
98d0b5200b ci: 改用 /workspace 目录
Some checks failed
Release / build (push) Failing after 19s
2026-04-12 03:23:52 +08:00
af1c509257 ci: 修复 cp 错误
Some checks failed
Release / build (push) Failing after 1m38s
2026-04-12 03:21:55 +08:00
9b1c8b5566 ci: 添加 clone 步骤
Some checks failed
Release / build (push) Failing after 1m38s
2026-04-12 03:19:58 +08:00
4fca6bc081 ci: 简化工作目录,修复 YAML 格式
Some checks failed
Release / build (push) Failing after 21s
2026-04-12 03:19:12 +08:00
13 changed files with 766 additions and 182 deletions

View File

@@ -16,15 +16,15 @@ jobs:
- name: Checkout
run: |
apk add git bash
rm -rf /workspace
git clone https://hub.gaomia.site/titor/hxclaw.git /workspace
- name: Download dependencies
run: |
cd /workspace/titor/hxclaw
go mod download
run: cd /workspace && go mod download
- name: Build
run: |
cd /workspace/titor/hxclaw
cd /workspace
for p in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
os=${p%/*}
arch=${p#*/}
@@ -34,15 +34,13 @@ jobs:
done
- name: Checksums
run: |
cd /workspace/titor/hxclaw
sha256sum hxclaw-* > checksums.txt
run: cd /workspace && sha256sum hxclaw-* > checksums.txt
- name: Release
env:
GITEA_TOKEN: ${{ secrets.release_token }}
GITEA_TOKEN: "${{ secrets.release_token }}"
run: |
cd /workspace/titor/hxclaw
cd /workspace
apk add curl jq
TAG_NAME="${GITHUB_REF#refs/tags/}"

122
agents.md
View File

@@ -41,36 +41,104 @@
## 当前任务
### v0.1.0 目标
### v0.2.0 目标
实现流式输出功能:
1. 创建 go.mod 配置依赖
2. 实现 main.go 入口
3. 实现流式 Provider 调用
4.时打印 token
5. 处理非流式 Provider 回退
6. Markdown 终端渲染glamour
实现 TTS 语音朗读功能:
1. 集成 mimo-tts clientTCP 连接本地 daemon
2. 添加配置文件 TTS 开关
3. 实现命令行切换(/tts on/off/status
4.现临时 TTS 前缀(`T 消息`
5. 动态提示符显示状态(👀 🔊)
---
## 实现进度
### v0.1.0 已完成功能
### v0.2.0 已完成功能
1. **流式输出**
- 实时打印 token
- Spinner 显示"思考中..."
- 第一个 token 到达时停止 spinner
1. **TTS 语音朗读**
- 集成 mimo-tts clientTCP 连接)
- 配置文件开关tts.enabled
- 命令行切换(/tts on/off/status
- 临时 TTS 前缀(`T 消息`
- 动态提示符显示状态(👀 🔊)
- 静默失败处理(网络异常时仅记录日志)
2. **Markdown 渲染**
2. **流式输出(新流程)**
- 等待 AI 返回完整响应
- Markdown 转译
- 模拟流式输出(从配置读取速度)
- 效果更好,无残留问题
3. **Markdown 渲染**
- 使用 glamour 库渲染 Markdown
- 支持多种主题dark, light, dracula, tokyo-night 等)
- 通过 GLAMOUR_STYLE 环境变量配置主题
- 通过 project.config.yml 配置主题
3. **重绘逻辑**
- 响应完成后尝试重绘
- 使用 termenv 库清除屏幕
- ⚠️ 存在轻微残留 bug可接受
4. **项目配置**
- 通过 project.config.yml 统一管理配置项
- 支持流式速度、渲染主题、Logo、TTS 等配置
---
## 项目配置
### 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 等
# UI 配置
ui:
logo: "🦐"
user_prefix: "👀 "
# TTS 语音配置
tts:
enabled: false # 全局开关(默认关闭)
port: 9876 # mimo-tts daemon 端口
auto: true # AI 回复后自动朗读
```
配置加载优先级:
1. 环境变量 `HXCLAW_CONFIG` 指定路径
2. 项目根目录 `project.config.yml`
---
## TTS 使用指南
### 命令
| 输入 | 行为 |
|------|------|
| `/tts` | 切换 TTS 开关 |
| `/tts on` | 开启 TTS |
| `/tts off` | 关闭 TTS |
| `/tts status` | 显示 TTS 状态 |
| `T 消息` | 临时开启 TTS 并发送消息 |
### 动态提示符
- 关闭:`👀 `
- 开启:`👀 🔊 `
### 注意事项
- 需要先安装并启动 mimo-tts daemon`mimo-tts daemon start`
- TTS 服务端地址:本地 9876 端口(默认)
- 网络异常时会静默失败,仅记录日志
---
@@ -82,32 +150,38 @@
- `charm.land/lipgloss/v2` - 终端样式
- `charm.land/x/term` - 终端控制
- `github.com/muesli/termenv` - 终端环境工具
- `gopkg.in/yaml.v3` - 配置文件解析
- `github.com/ergochat/readline` - 终端输入
### 配置文件
- `cmd/hxclaw/main.go` - 主入口逻辑
- `cmd/hxclaw/internal/markdown.go` - Markdown 渲染器
- `cmd/hxclaw/internal/helpers.go` - 辅助函数
- `cmd/hxclaw/internal/helpers.go` - 辅助函数Readline
- `cmd/hxclaw/internal/config.go` - 项目配置加载
- `cmd/hxclaw/internal/tts.go` - TTS 客户端
---
## 已知问题
1. **重绘残留**:某些情况下有轻微文本重复(可接受
1. **重绘残留**:某些情况下有轻微文本重复(已通过新流程解决
2. **终端兼容性**termenv 在某些终端可能不完全工作
---
## 待优化
1. 优化重绘逻辑,解决残留问题
1. 打印和 TTS 朗读同时进行(而非先打印完再读)
2. 添加更多主题支持
3. 添加命令-line 参数支持主题选择
3. 添加命令参数支持主题选择
---
## 构建命令
picoclaw GitHub地址https://github.com/sipeed/picoclaw.git
```bash
go build -o hxclaw ./cmd/hxclaw
```
@@ -120,4 +194,4 @@ go build -o hxclaw ./cmd/hxclaw
- 不要修改 picoclaw 源码
- 保持代码独立,便于后续版本同步
- 优先实现核心功能,再考虑增强功能
- 文档和代码同步更新
- 文档和代码同步更新

View File

@@ -2,44 +2,51 @@
## 版本记录
### v0.1.0 (规划中)
### v0.2.1
- 修复 TTS JSON 请求格式,兼容 Windows daemon
- 发送格式改为 `{"text": "内容"}`
---
### v0.2.0
- 新增 TTS 语音朗读功能
- 集成 mimo-tts client 功能,通过 TCP 连接本地 daemon
- 支持配置文件开关tts.enabled
- 支持命令行切换(/tts on/off/status
- 支持临时 TTS 前缀(`T 消息` 临时开启)
- 动态提示符显示 TTS 状态(👀 🔊)
- 静默失败处理(网络异常时警告日志)
---
### v0.1.0
- 创建 hxclaw 项目
- 实现流式输出功能
- Markdown 渲染功能(待实现
- 代码高亮功能(待实现
- Markdown 渲染glamour自动代码高亮
- 项目配置化project.config.yml
---
## 待实现功能
### v0.1.0 (当前)
### v0.2.0 (当前)
- [x] 流式输出功能
- [x] 导入 picoclaw 核心库
- [x] 实现流式 Provider 调用
- [x] 实时打印 token
- [x] 处理非流式 Provider 回退
- [x] 添加加载动画spinner 组件)
- [x] 使用 bubbletea v2 spinner.MiniDot 样式
- [x] 用户输入后显示思考中动画
- [x] 第一个 token 返回后显示思考完成
- [x] 流式输出完成后添加空行分隔
### v0.2.0 (计划)
- [ ] Markdown 渲染
- [ ] Markdown 解析
- [ ] 基础样式(粗体、斜体、链接)
- [ ] 代码块渲染
- [ ] 表格渲染
- [ ] 列表渲染
- [x] TTS 语音朗读功能
- [x] 集成 mimo-tts client (TCP 连接)
- [x] 配置文件开关 (tts.enabled)
- [x] 命令行切换 (/tts on/off/status)
- [x] 临时 TTS 前缀 (T 消息)
- [x] 动态提示符显示状态
- [x] 静默失败处理
### v0.3.0 (计划)
- [ ] 代码高亮
- [ ] 集成 glow 或类似库
- [ ] 支持常见语言语法高亮
- [ ] 命令行参数支持(--theme, --tts 等)
- [ ] 多语言支持
- [ ] 会话历史持久化
---
@@ -54,6 +61,9 @@
- [x] 实现流式输出核心逻辑
- [x] 编译成功,生成 hxclaw 二进制
- [x] 添加 spinner 加载动画组件
- [x] 实现 Markdown 渲染glamour
- [x] 实现项目配置化project.config.yml
- [x] 实现 TTS 语音朗读功能
---

View File

@@ -0,0 +1,127 @@
package internal
import (
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
type ProjectConfig struct {
Streaming StreamingConfig `yaml:"streaming"`
Markdown MarkdownConfig `yaml:"markdown"`
UI UIConfig `yaml:"ui"`
TTS TTSConfig `yaml:"tts"`
}
type StreamingConfig struct {
LineDelayMs int `yaml:"line_delay_ms"`
LastLineDelayMs int `yaml:"last_line_delay_ms"`
}
type MarkdownConfig struct {
GlamourStyle string `yaml:"glamour_style"`
WrapWidth int `yaml:"wrap_width"`
}
type UIConfig struct {
Logo string `yaml:"logo"`
UserPrefix string `yaml:"user_prefix"`
}
type TTSConfig struct {
Enabled bool `yaml:"enabled"`
Port int `yaml:"port"`
Auto bool `yaml:"auto"`
}
var (
defaultCfg = ProjectConfig{
Streaming: StreamingConfig{
LineDelayMs: 1000,
LastLineDelayMs: 600,
},
Markdown: MarkdownConfig{
GlamourStyle: "dark",
WrapWidth: 0,
},
UI: UIConfig{
Logo: "🦐",
UserPrefix: "👀 ",
},
TTS: TTSConfig{
Enabled: false,
Port: 9876,
Auto: true,
},
}
projCfg *ProjectConfig
projCfgLock sync.RWMutex
)
func LoadProjectConfig() error {
projCfgLock.Lock()
defer projCfgLock.Unlock()
cfgPath := getConfigPath()
if cfgPath == "" {
projCfg = &defaultCfg
return nil
}
data, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
projCfg = &defaultCfg
return nil
}
return err
}
var cfg ProjectConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return err
}
if cfg.Streaming.LineDelayMs <= 0 {
cfg.Streaming.LineDelayMs = defaultCfg.Streaming.LineDelayMs
}
if cfg.Streaming.LastLineDelayMs <= 0 {
cfg.Streaming.LastLineDelayMs = defaultCfg.Streaming.LastLineDelayMs
}
if cfg.Markdown.GlamourStyle == "" {
cfg.Markdown.GlamourStyle = defaultCfg.Markdown.GlamourStyle
}
if cfg.Markdown.WrapWidth < 0 {
cfg.Markdown.WrapWidth = 0
}
if cfg.UI.Logo == "" {
cfg.UI.Logo = defaultCfg.UI.Logo
}
if cfg.UI.UserPrefix == "" {
cfg.UI.UserPrefix = defaultCfg.UI.UserPrefix
}
if cfg.TTS.Port <= 0 {
cfg.TTS.Port = defaultCfg.TTS.Port
}
projCfg = &cfg
return nil
}
func GetProjectConfig() *ProjectConfig {
projCfgLock.RLock()
defer projCfgLock.RUnlock()
if projCfg == nil {
return &defaultCfg
}
return projCfg
}
func getConfigPath() string {
if path := os.Getenv("HXCLAW_CONFIG"); path != "" {
return path
}
return filepath.Join(".", "project.config.yml")
}

View File

@@ -49,7 +49,8 @@ func GetConfigPath() string {
// Readline 实例包装
type Readline struct {
rl *readline.Instance
rl *readline.Instance
basePrompt string
}
// NewReadline 创建一个新的 Readline 实例
@@ -68,7 +69,18 @@ func NewReadline(prompt string) (*Readline, error) {
if err != nil {
return nil, err
}
return &Readline{rl: rl}, nil
return &Readline{rl: rl, basePrompt: prompt}, nil
}
// SetPrompt 更新提示符
func (r *Readline) SetPrompt(prompt string) {
r.basePrompt = prompt
r.rl.SetPrompt(prompt)
}
// GetBasePrompt 返回基础提示符
func (r *Readline) GetBasePrompt() string {
return r.basePrompt
}
// Readline 读取一行输入

View File

@@ -2,9 +2,11 @@ package internal
import (
"os"
"strconv"
"strings"
"charm.land/glamour/v2"
"github.com/charmbracelet/x/term"
)
func RenderMarkdown(md string) string {
@@ -13,10 +15,11 @@ func RenderMarkdown(md string) string {
}
style := getStyle()
wrapWidth := getWrapWidth()
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style),
glamour.WithWordWrap(80),
glamour.WithWordWrap(wrapWidth),
)
if err != nil {
return md
@@ -43,10 +46,11 @@ func RenderParagraph(text string) string {
}
style := getStyle()
wrapWidth := getWrapWidth()
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style),
glamour.WithWordWrap(80),
glamour.WithWordWrap(wrapWidth),
)
if err != nil {
return text
@@ -62,9 +66,33 @@ func RenderParagraph(text string) string {
}
func getStyle() string {
style := "dark"
if s := os.Getenv("GLAMOUR_STYLE"); s != "" {
style = s
if cfg := GetProjectConfig(); cfg != nil {
if cfg.Markdown.GlamourStyle != "" {
return cfg.Markdown.GlamourStyle
}
}
return style
if s := os.Getenv("GLAMOUR_STYLE"); s != "" {
return s
}
return "dark"
}
func getWrapWidth() int {
if cfg := GetProjectConfig(); cfg != nil {
if cfg.Markdown.WrapWidth > 0 {
return cfg.Markdown.WrapWidth
}
}
if cols := os.Getenv("COLUMNS"); cols != "" {
if w, err := strconv.Atoi(cols); err == nil && w > 0 {
return w
}
}
width, _, err := term.GetSize(0)
if err != nil || width <= 0 {
return 80
}
return width
}

View File

@@ -80,11 +80,11 @@ func (s *Spinner) run() {
}
func (s *Spinner) render() {
fmt.Printf("\r%s %s", s.spinner.View(), s.text)
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())
fmt.Printf("\r %s 思考完成.\n", s.spinner.View())
os.Stdout.Sync()
}

View File

@@ -18,12 +18,16 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
var totalCompletionTokens int
const Logo = "🦐"
func main() {
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", Logo)
if err := internal.LoadProjectConfig(); err != nil {
fmt.Fprintf(os.Stderr, "错误:加载项目配置失败: %v\n", err)
os.Exit(1)
}
logo := internal.GetProjectConfig().UI.Logo
fmt.Printf("%s HxClaw - PicoClaw 增强版 CLI\n\n", logo)
cfg, err := internal.LoadConfig()
if err != nil {
@@ -62,7 +66,8 @@ func main() {
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", Logo)
basePrompt := internal.GetProjectConfig().UI.UserPrefix
prompt := internal.GetTTSPrompt(basePrompt)
rl, err := internal.NewReadline(prompt)
if err != nil {
@@ -73,6 +78,11 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
}
defer rl.Close()
ttsCfg := internal.GetProjectConfig().TTS
if ttsCfg.Enabled {
internal.SetTTSEnabled(true)
}
for {
line, err := rl.Readline()
if err != nil {
@@ -94,14 +104,37 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
return
}
runWithStreaming(agentLoop, input, sessionKey)
isTempTTS := false
if len(input) > 0 && input[0] == 'T' && (len(input) == 1 || input[1] == ' ') {
input = strings.TrimPrefix(input, "T")
input = strings.TrimPrefix(input, " ")
isTempTTS = true
}
if strings.HasPrefix(input, "/tts") {
handleTTSCommand(input, rl, basePrompt)
continue
}
if isTempTTS {
enabled := internal.ToggleTTS()
if enabled {
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
}
}
runWithStreaming(agentLoop, input, sessionKey, isTempTTS)
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := internal.NewSimpleReader()
ttsCfg := internal.GetProjectConfig().TTS
if ttsCfg.Enabled {
internal.SetTTSEnabled(true)
}
for {
fmt.Print(fmt.Sprintf("%s You: ", Logo))
fmt.Print(internal.GetTTSPrompt(internal.GetProjectConfig().UI.UserPrefix))
line, err := reader.ReadString()
if err != nil {
if err == internal.ErrEOF {
@@ -122,135 +155,103 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
return
}
runWithStreaming(agentLoop, input, sessionKey)
isTempTTS := false
if len(input) > 0 && input[0] == 'T' && (len(input) == 1 || input[1] == ' ') {
input = strings.TrimPrefix(input, "T")
input = strings.TrimPrefix(input, " ")
isTempTTS = true
}
if strings.HasPrefix(input, "/tts") {
handleTTSCommandSimple(input)
continue
}
if isTempTTS {
internal.ToggleTTS()
}
runWithStreaming(agentLoop, input, sessionKey, isTempTTS)
}
}
// runWithStreaming 尝试使用流式输出,如果 Provider 不支持则回退到普通模式
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string) {
// runWithStreaming 使用 ProcessDirect 处理请求,支持工具调用和结果显示
func runWithStreaming(agentLoop *agent.AgentLoop, input, sessionKey string, tempTTS bool) {
startTime := time.Now()
agentInstance := agentLoop.GetRegistry().GetDefaultAgent()
if agentInstance == nil {
fmt.Println("错误:无法获取 Agent 实例")
spinner := internal.NewSpinner("思考中...")
spinner.Start()
resp, err := agentLoop.ProcessDirect(context.Background(), input, sessionKey)
spinner.Stop()
if err != nil {
fmt.Printf("错误: %v\n", err)
return
}
provider := agentInstance.Provider
ctx := context.Background()
rendered := internal.RenderMarkdown(resp)
clearSpinnerLine()
outputLineByLine(rendered)
// 判断是否支持流式
if sp, ok := provider.(providers.StreamingProvider); ok {
// 从 session 中获取历史消息
history := agentInstance.Sessions.GetHistory(sessionKey)
summary := agentInstance.Sessions.GetSummary(sessionKey)
ttsCfg := internal.GetProjectConfig().TTS
if ttsCfg.Enabled || tempTTS || internal.IsTTSEnabled() {
go internal.SpeakText(resp)
}
// 使用 ContextBuilder 构建消息,包含历史
messages := agentInstance.ContextBuilder.BuildMessages(
history,
summary,
input,
nil, // media
"cli", // channel
sessionKey,
"", // senderID
"", // senderDisplayName
)
elapsed := time.Since(startTime)
printElapsed(elapsed)
}
// 获取工具定义
toolDefs := agentInstance.Tools.ToProviderDefs()
func clearSpinnerLine() {
output := termenv.DefaultOutput()
output.ClearLine()
fmt.Print("\r")
os.Stdout.Sync()
}
// 启动 spinner显示 "思考中..."
spinner := internal.NewSpinner("思考中...")
spinner.Start()
func outputLineByLine(text string) {
if text == "" {
return
}
fmt.Print("\n")
var result strings.Builder
var printedLen int
firstToken := true
resp, err := sp.ChatStream(ctx, messages, toolDefs, agentInstance.Model, nil, func(accumulated string) {
if firstToken && len(accumulated) > 0 {
spinner.Stop()
firstToken = false
}
if len(accumulated) > printedLen {
newText := accumulated[printedLen:]
fmt.Print(newText)
os.Stdout.Sync()
result.WriteString(newText)
printedLen = len(accumulated)
}
})
lines := strings.Split(text, "\n")
totalLines := len(lines)
if err != nil {
spinner.Stop()
fmt.Printf("流式调用错误: %v\n", err)
return
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
}
if result.Len() > 0 {
allOutput := result.String()
rendered := internal.RenderMarkdown(allOutput)
if rendered != allOutput && rendered != "" {
lines := strings.Count(allOutput, "\n") + 1
output := termenv.DefaultOutput()
output.CursorUp(1)
output.ClearLine()
output.ClearLines(lines)
fmt.Print(rendered)
fmt.Println()
fmt.Println()
} else {
fmt.Println()
fmt.Println()
}
fmt.Println(line)
elapsed := time.Since(startTime)
printStats(resp, elapsed)
agentInstance.Sessions.AddMessage(sessionKey, "user", input)
agentInstance.Sessions.AddMessage(sessionKey, "assistant", allOutput)
}
} else {
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("错误: %v\n", err)
return
}
rendered := internal.RenderMarkdown(response)
if rendered != "" && rendered != response {
fmt.Printf("\n%s\n\n", rendered)
if i < totalLines-1 {
time.Sleep(lineDelay)
} else {
fmt.Printf("\n%s %s\n\n", Logo, response)
time.Sleep(lastLineDelay)
}
}
fmt.Println()
}
var (
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffcc80"))
textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5c7a9a"))
iconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f0c75e"))
textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2b2e32"))
)
func printStats(resp *providers.LLMResponse, elapsed time.Duration) {
if resp == nil || resp.Usage == nil {
return
}
completionTokens := resp.Usage.CompletionTokens
if completionTokens <= 0 {
return
}
totalCompletionTokens += completionTokens
func printElapsed(elapsed time.Duration) {
elapsedSec := math.Round(elapsed.Seconds()*10) / 10
thisTokens := formatTokens(completionTokens)
totalTokens := formatTokens(totalCompletionTokens)
elapsedStr := formatDuration(elapsedSec)
icon := iconStyle.Render("▣ ")
text := textStyle.Render(fmt.Sprintf("Tokens: %s · 耗时: %s · 总Tokens: %s", thisTokens, elapsedStr, totalTokens))
text := textStyle.Render(fmt.Sprintf("耗时: %s", elapsedStr))
fmt.Printf(" %s%s\n\n", icon, text)
}
@@ -267,3 +268,66 @@ func formatDuration(s float64) string {
}
return fmt.Sprintf("%.1fs", s)
}
func handleTTSCommand(input string, rl *internal.Readline, basePrompt string) {
args := strings.Fields(input)
if len(args) == 1 {
enabled := internal.ToggleTTS()
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
status := "关闭"
if enabled {
status = "开启"
}
fmt.Printf("TTS 已%s\n", status)
return
}
switch args[1] {
case "on":
internal.SetTTSEnabled(true)
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
fmt.Println("TTS 已开启")
case "off":
internal.SetTTSEnabled(false)
rl.SetPrompt(internal.GetTTSPrompt(basePrompt))
fmt.Println("TTS 已关闭")
case "status":
status := "关闭"
if internal.IsTTSEnabled() {
status = "开启"
}
fmt.Printf("TTS 状态: %s\n", status)
default:
fmt.Println("用法: /tts [on|off|status]")
}
}
func handleTTSCommandSimple(input string) {
args := strings.Fields(input)
if len(args) == 1 {
internal.ToggleTTS()
status := "关闭"
if internal.IsTTSEnabled() {
status = "开启"
}
fmt.Printf("TTS 已%s\n", status)
return
}
switch args[1] {
case "on":
internal.SetTTSEnabled(true)
fmt.Println("TTS 已开启")
case "off":
internal.SetTTSEnabled(false)
fmt.Println("TTS 已关闭")
case "status":
status := "关闭"
if internal.IsTTSEnabled() {
status = "开启"
}
fmt.Printf("TTS 状态: %s\n", status)
default:
fmt.Println("用法: /tts [on|off|status]")
}
}

7
go.mod
View File

@@ -8,7 +8,8 @@ require (
charm.land/lipgloss/v2 v2.0.2
github.com/ergochat/readline v0.1.3
github.com/muesli/termenv v0.16.0
github.com/sipeed/picoclaw v0.0.0
github.com/sipeed/picoclaw v0.2.6
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -92,12 +93,8 @@ require (
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
)
// 开发时指向本地 picoclaw
replace github.com/sipeed/picoclaw => /Users/titor/picoclaw

2
go.sum
View File

@@ -159,6 +159,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sipeed/picoclaw v0.2.6 h1:MqV6hidBW2WVPqE+nmIkjO3sJI/ng0nrAyFDJVHUOBg=
github.com/sipeed/picoclaw v0.2.6/go.mod h1:gl9BuZhxUIvrJM1oQXw4Xa8wlmarGGz1y1z6XdcMu3Y=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

BIN
hxclaw

Binary file not shown.

22
project.config.yml Normal file
View File

@@ -0,0 +1,22 @@
# 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: "🦐"
user_prefix: "👀 "
# TTS 语音配置
tts:
enabled: false # 全局开关(默认关闭)
port: 9876 # mimo-tts daemon 端口
auto: true # AI 回复后自动朗读

254
taolun.md
View File

@@ -46,8 +46,8 @@ hxclaw/
### 6. Markdown 终端渲染
- 使用 charmbracelet 家族库
- glamourMarkdown 渲染(自带代码高亮)
- lipgloss终端样式
- glow代码高亮
- 流程Markdown → ANSI 转义序列 → 终端显示
### 7. 部署方式
@@ -205,4 +205,254 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 类型
}
```